fix(agent): re-raise InputCheckError/OutputCheckError from _run() so callers can catch them#7632
Conversation
…callers can catch them
Previously, InputCheckError and OutputCheckError raised inside a
guardrail pre-hook (or any pre-hook that raises them) were silently
caught by the eight try/except blocks inside _run(), _run_stream(),
_arun(), and _arun_stream() variants. The exceptions were converted to
a RunOutput with status=error and the run function returned/broke out of
the loop — making it impossible for user code to write:
try:
agent.run(...)
except InputCheckError as e:
... # never reached
Fix: after performing cleanup (storing the partial RunOutput to the
session DB), re-raise the caught exception with
aise instead of
returning/breaking. For streaming variants an error event is still
yielded first so streaming consumers receive a structured error in the
stream before the exception propagates.
All 8 catch-sites in _run.py are updated:
• _run() – sync non-stream (return → raise)
• _run_stream() – sync stream (break → raise, error event kept)
• _arun() – async non-stream (return → raise)
• _arun_stream() – async stream (break → raise, error event kept)
• run_with_session() – return → raise
• run_stream_with_session() – break → raise
• arun_with_session() – return → raise
• arun_stream_with_session() – break → raise
Fixes agno-agi#7604
VANDRANKI
left a comment
There was a problem hiding this comment.
The root cause and the fix are both correct. The except (InputCheckError, OutputCheckError) blocks in all four non-streaming paths (_run, _arun, _continue_run, _acontinue_run) were silently converting the exception into a RunOutput with status=error - making user-side except InputCheckError unreachable. raise is the right replacement.
Streaming paths need a note
For the four streaming variants, the change goes from yield run_error; break to yield run_error; raise. This is a behavioral change for consumers: previously a streaming consumer got the error event and the generator ended cleanly. Now the consumer gets the error event and then an exception propagates from the async for / for loop. Existing streaming consumers without a try/except around their async for event in agent.arun(stream=True) will now get an unhandled exception. This is the correct behavior for making the exception catchable, but it is worth a CHANGELOG note or a migration guide sentence so users know to add the try/except.
Test gap: the fix in _run.py is not directly tested
The test file exercises execute_pre_hooks and aexecute_pre_hooks at the hook layer - which already re-raised before this PR. The actual change being made (removing return run_response and break from _run.py) has no end-to-end test. A test that calls agent.run() or agent.arun() with a guardrail that raises InputCheckError and asserts that InputCheckError propagates out would directly verify the fix and prevent regression. Right now, reverting all the _run.py changes would leave the tests green.
The eight-site change is internally consistent - all 8 blocks get the same treatment. The comment updates clearly explain the intent. The fix is correct; the two items above are what I would address before merge.
…ent.run()/arun() Address reviewer feedback: add 6 e2e tests that exercise the actual _run.py fix by calling agent.run() and agent.arun() (sync, async, stream, non-stream) with a blocking guardrail and asserting InputCheckError propagates to caller. Reverting the _run.py changes would cause these tests to FAIL, closing the test gap noted in review.
|
Thanks for the thorough review @VANDRANKI — both points addressed:
|
|
Both points addressed cleanly. Tests: Migration note: the PR description section is clear and actionable - the LGTM. |
|
Hi @ashpreetbedi — this bug fix has a LGTM, e2e regression tests, and passing CI. Would appreciate a review when you get a chance. Happy to address any feedback! |
VANDRANKI
left a comment
There was a problem hiding this comment.
The fix is correct and covers all 8 paths.
Verification
I independently audited before reading this PR and identified the same 8 blocks:
| Path | Function | Before | After |
|---|---|---|---|
| sync non-stream | |||
| sync stream | |||
| async non-stream | |||
| async stream | |||
| sync continue non-stream | |||
| sync continue stream | |||
| async continue non-stream | |||
| async continue stream |
All 8 are patched. LGTM.
Streaming raise correctness
inside a generator works exactly as intended. When the consumer iterates the stream (), it receives the event on the current call. On the following call, the bare re-raises the original exception which exits the generator frame and surfaces to the caller as /. Python preserves the exception context across the suspension point, so there is no risk of re-raising the wrong exception.
This is the same pattern used in via PR #7418.
Test coverage
315-line test file is strong. One gap worth noting: the tests for and paths are missing. Those two paths ARE patched in the production code but do not have dedicated test functions. The core / tests validate the pattern, so the omission is not blocking, but adding two more test functions for the stream variants would give complete coverage across all 8 paths.
Suggestion (non-blocking): the comment stubs that were removed (, , , ) were mostly noise. The new single-line comments are clearer. Good housekeeping.
LGTM. Ready to merge.
Description
Fixes
InputCheckError(andOutputCheckError) raised inside a guardrail or anypre-hook being silently swallowed by the agent run loop, making user-side
except InputCheckErrorblocks unreachable.Fixes #7604
Root Cause
All 8
except (InputCheckError, OutputCheckError)blocks acrossagent/_run.py(covering sync, async, streaming, and session-wrapped variants) caught and
discarded the exception instead of re-raising it.
Non-stream variant (before):
Stream variant (before):
So this user pattern never worked:
Fix
After performing cleanup (storing the partial
RunOutputto the session DB),re-raise the exception with bare
raiseinstead of returning/breaking.Non-stream variant (after):
Stream variant (after): the error event is still
yielded first sostreaming consumers get a structured
RunErrorEventin the stream, then theexception propagates:
Migration Note for Streaming Consumers
Previously, when a guardrail raised
InputCheckErrororOutputCheckErrorduring a streaming run, the generator yielded aRunErrorEventand then ended cleanly (viabreak). Consumers iterating withfor event in agent.run(stream=True)would see the error event and the loop would exit normally.After this fix, the generator still yields the
RunErrorEvent, but then raises the exception. This means existing streaming consumers that do not wrap their iteration in atry/exceptwill now see an unhandledInputCheckError/OutputCheckError.Action required: If you consume streaming runs and want to handle guardrail errors gracefully, wrap the iteration loop:
This is the correct behavior — it makes guardrail exceptions catchable by user code, which was the original bug (#7604).
Files Changed
libs/agno/agno/agent/_run.pyAll 8 catch-sites updated:
_run()sync non-streamreturn run_responseraise_run_stream()sync streambreakraise_arun()async non-streamreturn run_responseraise_arun_stream()async streambreakraiserun_with_session()return run_responseraiserun_stream_with_session()breakraisearun_with_session()return run_responseraisearun_stream_with_session()breakraiselibs/agno/tests/unit/agent/test_input_check_error_propagation.py(new)10 regression tests covering the bug scenario:
Hook-layer tests (sanity checks):
test_execute_pre_hooks_raises_input_check_errorBaseGuardrailsync propagation through hook layertest_aexecute_pre_hooks_raises_input_check_errorBaseGuardrailasync propagation through hook layertest_plain_pre_hook_raises_input_check_error_propagatesInputCheckErrortest_async_plain_pre_hook_raises_input_check_error_propagatesInputCheckErrorEnd-to-end tests (exercise the actual
_run.pyfix):test_agent_run_raises_input_check_erroragent.run()test_agent_arun_raises_input_check_erroragent.arun()test_agent_run_stream_raises_input_check_erroragent.run(stream=True)test_agent_arun_stream_raises_input_check_erroragent.arun(stream=True)test_agent_run_plain_hook_raises_input_check_erroragent.run()test_agent_arun_plain_hook_raises_input_check_erroragent.arun()Test Results
test_input_check_error_propagation.pyType of Change