Skip to content

fix(agent): re-raise InputCheckError/OutputCheckError from _run() so callers can catch them#7632

Open
ayushh0110 wants to merge 4 commits intoagno-agi:mainfrom
ayushh0110:fix/input-check-error-not-propagated
Open

fix(agent): re-raise InputCheckError/OutputCheckError from _run() so callers can catch them#7632
ayushh0110 wants to merge 4 commits intoagno-agi:mainfrom
ayushh0110:fix/input-check-error-not-propagated

Conversation

@ayushh0110
Copy link
Copy Markdown

@ayushh0110 ayushh0110 commented Apr 22, 2026


Description

Fixes InputCheckError (and OutputCheckError) raised inside a guardrail or any
pre-hook being silently swallowed by the agent run loop, making user-side
except InputCheckError blocks unreachable.

Fixes #7604


Root Cause

All 8 except (InputCheckError, OutputCheckError) blocks across agent/_run.py
(covering sync, async, streaming, and session-wrapped variants) caught and
discarded
the exception instead of re-raising it.

Non-stream variant (before):

except (InputCheckError, OutputCheckError) as e:
    run_response.status = RunStatus.error
    run_response.content = str(e)
    log_error(...)
    cleanup_and_store(...)
    return run_response        # ← exception gone, user can never catch it

Stream variant (before):

except (InputCheckError, OutputCheckError) as e:
    ...
    yield run_error
    break                      # ← same problem

So this user pattern never worked:

try:
    agent.run("some input")
except InputCheckError as e:
    print(f"Blocked: {e.message}")   # ← unreachable

Fix

After performing cleanup (storing the partial RunOutput to the session DB),
re-raise the exception with bare raise instead of returning/breaking.

Non-stream variant (after):

except (InputCheckError, OutputCheckError) as e:
    run_response.status = RunStatus.error
    run_response.content = str(e)
    log_error(...)
    cleanup_and_store(...)
    raise                      # ← propagates to caller ✓

Stream variant (after): the error event is still yielded first so
streaming consumers get a structured RunErrorEvent in the stream, then the
exception propagates:

yield run_error   # streaming consumers still get the event
raise             # then exception propagates to caller ✓

Migration Note for Streaming Consumers

Behavioral change for streaming paths.

Previously, when a guardrail raised InputCheckError or OutputCheckError during a streaming run, the generator yielded a RunErrorEvent and then ended cleanly (via break). Consumers iterating with for 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 a try/except will now see an unhandled InputCheckError / OutputCheckError.

Action required: If you consume streaming runs and want to handle guardrail errors gracefully, wrap the iteration loop:

# Before (error event appeared, loop ended silently):
for event in agent.run("some input", stream=True):
    handle(event)

# After (exception now propagates — add try/except):
try:
    for event in agent.run("some input", stream=True):
        handle(event)
except InputCheckError as e:
    print(f"Blocked: {e.message}")

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.py

All 8 catch-sites updated:

Line Variant Old terminal New terminal
641 _run() sync non-stream return run_response raise
1135 _run_stream() sync stream break raise
1739 _arun() async non-stream return run_response raise
2347 _arun_stream() async stream break raise
3048 run_with_session() return run_response raise
3319 run_stream_with_session() break raise
3838 arun_with_session() return run_response raise
4301 arun_stream_with_session() break raise

libs/agno/tests/unit/agent/test_input_check_error_propagation.py (new)

10 regression tests covering the bug scenario:

Hook-layer tests (sanity checks):

Test What it covers
test_execute_pre_hooks_raises_input_check_error BaseGuardrail sync propagation through hook layer
test_aexecute_pre_hooks_raises_input_check_error BaseGuardrail async propagation through hook layer
test_plain_pre_hook_raises_input_check_error_propagates Plain sync callable raising InputCheckError
test_async_plain_pre_hook_raises_input_check_error_propagates Plain async callable raising InputCheckError

End-to-end tests (exercise the actual _run.py fix):

Test What it covers
test_agent_run_raises_input_check_error Sync non-stream via agent.run()
test_agent_arun_raises_input_check_error Async non-stream via agent.arun()
test_agent_run_stream_raises_input_check_error Sync stream via agent.run(stream=True)
test_agent_arun_stream_raises_input_check_error Async stream via agent.arun(stream=True)
test_agent_run_plain_hook_raises_input_check_error Plain callable sync via agent.run()
test_agent_arun_plain_hook_raises_input_check_error Plain callable async via agent.arun()

Reverting the _run.py changes would cause the e2e tests to FAIL, closing the test gap noted in review.


Test Results

  • 10 passed in test_input_check_error_propagation.py
  • 0 regressions introduced

Type of Change

  • Bug fix (non-breaking change which fixes an issue)
  • Tests added

…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
@ayushh0110 ayushh0110 requested a review from a team as a code owner April 22, 2026 18:41
Copy link
Copy Markdown

@VANDRANKI VANDRANKI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@ayushh0110
Copy link
Copy Markdown
Author

Thanks for the thorough review @VANDRANKI — both points addressed:

  1. Test gap closed : Added 6 end-to-end tests in TestEndToEndInputCheckErrorPropagation that call the real agent.run() / agent.arun() API (sync, async, stream, non-stream) with a blocking guardrail and assert InputCheckError propagates to the caller. Reverting the _run.py changes would cause these tests to fail. Total is now 10 tests (4 hook-layer + 6 e2e).

  2. Streaming migration note added: Added a "Migration Note for Streaming Consumers" section to the PR description documenting the behavioral change (yield + break → yield + raise) and showing the try/except pattern streaming consumers should adopt.

@VANDRANKI
Copy link
Copy Markdown

Both points addressed cleanly.

Tests: TestEndToEndInputCheckErrorPropagation covers exactly the paths that were broken - sync/async x stream/non-stream x blocking guardrail, all calling the real agent.run() / agent.arun() API. Reverting the _run.py raise changes would cause these to fail, which is the right property. The plain-hook variants close the remaining gap. 10 tests total is thorough for this scope.

Migration note: the PR description section is clear and actionable - the yield + break to yield + raise behavior change for streaming consumers is documented with the exact try/except pattern they should adopt. That is the right place for it since it is visible before any code is merged.

LGTM.

@ayushh0110
Copy link
Copy Markdown
Author

ayushh0110 commented Apr 25, 2026

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!

Copy link
Copy Markdown

@VANDRANKI VANDRANKI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

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.

[Bug] Cannot raise InputCheckError

2 participants