Skip to content

Commit a15d063

Browse files
committed
fix(run): report input guardrail results when a tripwire aborts the run
Runner.run() and Runner.run_sync() raised InputGuardrailTripwireTriggered with an empty RunErrorDetails.input_guardrail_results, while Runner.run_streamed() reported every completed result. run_input_guardrails() accumulated results locally and raised before run.py could merge them into the run-level list. Record results into a caller-owned accumulator as each guardrail completes, including the tripping result, so all three entry points expose the same observable guardrail state.
1 parent c3f1781 commit a15d063

3 files changed

Lines changed: 157 additions & 10 deletions

File tree

src/agents/run.py

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -797,16 +797,16 @@ def _finalize_result(result: RunResult) -> RunResult:
797797
g for g in all_input_guardrails if not g.run_in_parallel
798798
]
799799
parallel_guardrails = [g for g in all_input_guardrails if g.run_in_parallel]
800-
sequential_results: list[InputGuardrailResult] = []
801800
if sandbox_runtime.enabled and sequential_guardrails:
802801
# Blocking first-turn guardrails must run before sandbox prep so a tripwire
803802
# can prevent session creation, startup, or live-session mutation.
804803
try:
805-
sequential_results = await run_input_guardrails(
804+
await run_input_guardrails(
806805
starting_agent,
807806
sequential_guardrails,
808807
copy_input_items(original_input),
809808
context_wrapper,
809+
input_guardrail_results,
810810
)
811811
except InputGuardrailTripwireTriggered:
812812
session_input_items_for_persistence = (
@@ -1221,11 +1221,12 @@ def _finalize_result(result: RunResult) -> RunResult:
12211221
if current_turn <= 1:
12221222
try:
12231223
if sequential_guardrails:
1224-
sequential_results = await run_input_guardrails(
1224+
await run_input_guardrails(
12251225
starting_agent,
12261226
sequential_guardrails,
12271227
copy_input_items(original_input),
12281228
context_wrapper,
1229+
input_guardrail_results,
12291230
)
12301231
except InputGuardrailTripwireTriggered:
12311232
session_input_items_for_persistence = (
@@ -1240,7 +1241,6 @@ def _finalize_result(result: RunResult) -> RunResult:
12401241
)
12411242
raise
12421243

1243-
parallel_results: list[InputGuardrailResult] = []
12441244
model_task = asyncio.create_task(
12451245
run_single_turn(
12461246
bindings=current_bindings,
@@ -1272,10 +1272,11 @@ def _finalize_result(result: RunResult) -> RunResult:
12721272
parallel_guardrails,
12731273
copy_input_items(original_input),
12741274
context_wrapper,
1275+
input_guardrail_results,
12751276
)
12761277
)
12771278
try:
1278-
parallel_results, turn_result = await asyncio.gather(
1279+
_, turn_result = await asyncio.gather(
12791280
guardrail_task,
12801281
model_task,
12811282
)
@@ -1310,9 +1311,6 @@ def _finalize_result(result: RunResult) -> RunResult:
13101311
raise
13111312
else:
13121313
turn_result = await model_task
1313-
1314-
input_guardrail_results.extend(sequential_results)
1315-
input_guardrail_results.extend(parallel_results)
13161314
else:
13171315
turn_result = await run_single_turn(
13181316
bindings=current_bindings,

src/agents/run_internal/guardrails.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,8 +121,14 @@ async def run_input_guardrails(
121121
guardrails: list[InputGuardrail[TContext]],
122122
input: str | list[TResponseInputItem],
123123
context: RunContextWrapper[TContext],
124+
results_sink: list[InputGuardrailResult] | None = None,
124125
) -> list[InputGuardrailResult]:
125-
"""Run input guardrails concurrently and raise on tripwires."""
126+
"""Run input guardrails concurrently and raise on tripwires.
127+
128+
Results are recorded into ``results_sink`` as each guardrail completes, including the
129+
tripping result, so callers can report them even when this function raises. The streamed
130+
path publishes the same results through `RunResultStreaming.input_guardrail_results`.
131+
"""
126132
if not guardrails:
127133
return []
128134

@@ -133,10 +139,16 @@ async def run_input_guardrails(
133139

134140
guardrail_results: list[InputGuardrailResult] = []
135141

142+
def record(result: InputGuardrailResult) -> None:
143+
guardrail_results.append(result)
144+
if results_sink is not None:
145+
results_sink.append(result)
146+
136147
try:
137148
for done in asyncio.as_completed(guardrail_tasks):
138149
result = await done
139150
if result.output.tripwire_triggered:
151+
record(result)
140152
for t in guardrail_tasks:
141153
t.cancel()
142154
await asyncio.gather(*guardrail_tasks, return_exceptions=True)
@@ -147,7 +159,7 @@ async def run_input_guardrails(
147159
)
148160
)
149161
raise InputGuardrailTripwireTriggered(result)
150-
guardrail_results.append(result)
162+
record(result)
151163
except BaseException:
152164
# On any error (including a guardrail raising or the caller being cancelled),
153165
# cancel and await siblings so they don't leak past this function's return.

tests/test_guardrails.py

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1997,3 +1997,140 @@ async def raise_after_sibling_starts(ctx, agent, agent_output):
19971997

19981998
assert sibling_cancelled.is_set(), "Sibling task should have been cancelled"
19991999
assert not sibling_completed.is_set(), "Sibling task should not have completed"
2000+
2001+
2002+
def _ordered_input_guardrails(
2003+
*, second_triggers: bool, second_raises: bool = False, run_in_parallel: bool = False
2004+
) -> list[InputGuardrail[Any]]:
2005+
"""Build two guardrails whose completion order is fixed by an explicit barrier."""
2006+
first_done = asyncio.Event()
2007+
2008+
async def first_fn(
2009+
context: RunContextWrapper[Any], agent: Agent[Any], input: str | list[TResponseInputItem]
2010+
) -> GuardrailFunctionOutput:
2011+
first_done.set()
2012+
return GuardrailFunctionOutput(output_info="passes", tripwire_triggered=False)
2013+
2014+
async def second_fn(
2015+
context: RunContextWrapper[Any], agent: Agent[Any], input: str | list[TResponseInputItem]
2016+
) -> GuardrailFunctionOutput:
2017+
await first_done.wait()
2018+
if second_raises:
2019+
raise RuntimeError("guardrail exploded")
2020+
return GuardrailFunctionOutput(output_info="second", tripwire_triggered=second_triggers)
2021+
2022+
return [
2023+
InputGuardrail(guardrail_function=first_fn, name="passes", run_in_parallel=run_in_parallel),
2024+
InputGuardrail(
2025+
guardrail_function=second_fn,
2026+
name="raises" if second_raises else "trips",
2027+
run_in_parallel=run_in_parallel,
2028+
),
2029+
]
2030+
2031+
2032+
def _tripwire_agent(model: FakeModel, *, run_in_parallel: bool) -> Agent[Any]:
2033+
return Agent(
2034+
name="guardrail_results_agent",
2035+
model=model,
2036+
input_guardrails=_ordered_input_guardrails(
2037+
second_triggers=True, run_in_parallel=run_in_parallel
2038+
),
2039+
)
2040+
2041+
2042+
def _result_names(results: list[Any]) -> list[str]:
2043+
return [result.guardrail.get_name() for result in results]
2044+
2045+
2046+
@pytest.mark.asyncio
2047+
@pytest.mark.parametrize("run_in_parallel", [False, True])
2048+
async def test_input_guardrail_tripwire_reports_results(run_in_parallel: bool):
2049+
"""Runner.run() reports every completed guardrail result on the raised tripwire."""
2050+
model = FakeModel()
2051+
model.set_next_output([get_text_message("hello")])
2052+
2053+
with pytest.raises(InputGuardrailTripwireTriggered) as exc_info:
2054+
await Runner.run(_tripwire_agent(model, run_in_parallel=run_in_parallel), "test input")
2055+
2056+
run_data = exc_info.value.run_data
2057+
assert run_data is not None
2058+
assert _result_names(run_data.input_guardrail_results) == ["passes", "trips"]
2059+
assert exc_info.value.guardrail_result.guardrail.get_name() == "trips"
2060+
2061+
2062+
@pytest.mark.asyncio
2063+
@pytest.mark.parametrize("run_in_parallel", [False, True])
2064+
async def test_input_guardrail_tripwire_reports_results_streamed(run_in_parallel: bool):
2065+
"""The streamed path reports the same results, including on the streamed result object."""
2066+
model = FakeModel()
2067+
model.set_next_output([get_text_message("hello")])
2068+
2069+
result = Runner.run_streamed(
2070+
_tripwire_agent(model, run_in_parallel=run_in_parallel), "test input"
2071+
)
2072+
with pytest.raises(InputGuardrailTripwireTriggered) as exc_info:
2073+
async for _ in result.stream_events():
2074+
pass
2075+
2076+
run_data = exc_info.value.run_data
2077+
assert run_data is not None
2078+
assert _result_names(run_data.input_guardrail_results) == ["passes", "trips"]
2079+
assert _result_names(result.input_guardrail_results) == ["passes", "trips"]
2080+
2081+
2082+
def test_input_guardrail_tripwire_reports_results_sync():
2083+
"""Runner.run_sync() matches the async entry points."""
2084+
model = FakeModel()
2085+
model.set_next_output([get_text_message("hello")])
2086+
2087+
with pytest.raises(InputGuardrailTripwireTriggered) as exc_info:
2088+
Runner.run_sync(_tripwire_agent(model, run_in_parallel=False), "test input")
2089+
2090+
run_data = exc_info.value.run_data
2091+
assert run_data is not None
2092+
assert _result_names(run_data.input_guardrail_results) == ["passes", "trips"]
2093+
2094+
2095+
@pytest.mark.asyncio
2096+
async def test_input_guardrail_results_reported_on_success():
2097+
"""Passing guardrails still land on the successful result exactly once."""
2098+
model = FakeModel()
2099+
model.set_next_output([get_text_message("hello")])
2100+
agent = Agent(
2101+
name="guardrail_results_agent",
2102+
model=model,
2103+
input_guardrails=[
2104+
InputGuardrail(
2105+
guardrail_function=get_sync_guardrail(triggers=False),
2106+
name="blocking",
2107+
run_in_parallel=False,
2108+
),
2109+
InputGuardrail(
2110+
guardrail_function=get_sync_guardrail(triggers=False),
2111+
name="parallel",
2112+
run_in_parallel=True,
2113+
),
2114+
],
2115+
)
2116+
2117+
result = await Runner.run(agent, "test input")
2118+
2119+
assert _result_names(result.input_guardrail_results) == ["blocking", "parallel"]
2120+
2121+
2122+
@pytest.mark.asyncio
2123+
async def test_input_guardrail_exception_reports_completed_results():
2124+
"""A guardrail raising a non-tripwire error still preserves earlier results."""
2125+
2126+
collected: list[Any] = []
2127+
with pytest.raises(RuntimeError, match="guardrail exploded"):
2128+
await run_input_guardrails(
2129+
Agent(name="t"),
2130+
_ordered_input_guardrails(second_triggers=False, second_raises=True),
2131+
"test input",
2132+
RunContextWrapper(context=None),
2133+
collected,
2134+
)
2135+
2136+
assert _result_names(collected) == ["passes"]

0 commit comments

Comments
 (0)