Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/agents/guardrail.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,11 @@ class OutputGuardrail(Generic[TContext]):
function's name.
"""

run_in_parallel: bool = True
"""Whether the guardrail runs concurrently with other guardrails (True, default) or before
subsequent guardrails (False).
Comment on lines +159 to +161

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Honor sequential output guardrails in Realtime sessions

When these public guardrails are supplied through RealtimeAgent.output_guardrails or RealtimeRunConfig.output_guardrails, RealtimeSession._run_output_guardrails still awaits every guardrail in list order without consulting run_in_parallel (src/agents/realtime/session.py:1663-1669). Consequently, with an expensive default-parallel guardrail listed before a cheap run_in_parallel=False guardrail, the expensive check runs first and cannot be skipped when the cheap check trips—the exact cost-saving behavior this option promises works in Runner but is silently ignored on the supported Realtime path. Partition or otherwise honor the flag in that consumer as well.

AGENTS.md reference: AGENTS.md:L147-L147

Useful? React with 👍 / 👎.

"""

def get_name(self) -> str:
if self.name:
return self.name
Expand Down Expand Up @@ -296,6 +301,7 @@ def output_guardrail(
def output_guardrail(
*,
name: str | None = None,
run_in_parallel: bool = True,
) -> Callable[
[_OutputGuardrailFuncSync[TContext_co] | _OutputGuardrailFuncAsync[TContext_co]],
OutputGuardrail[TContext_co],
Expand All @@ -308,6 +314,7 @@ def output_guardrail(
| None = None,
*,
name: str | None = None,
run_in_parallel: bool = True,
) -> (
OutputGuardrail[TContext_co]
| Callable[
Expand All @@ -333,6 +340,7 @@ def decorator(
guardrail_function=f,
# Guardrail name defaults to function's name when not specified (None).
name=name if name else f.__name__,
run_in_parallel=run_in_parallel,
)

if func is not None:
Expand Down
40 changes: 34 additions & 6 deletions src/agents/realtime/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -1660,26 +1660,54 @@ async def _run_output_guardrails(

triggered_results = []

for guardrail in output_guardrails:
sequential_guardrails = [g for g in output_guardrails if not getattr(g, "run_in_parallel", True)]
parallel_guardrails = [g for g in output_guardrails if getattr(g, "run_in_parallel", True)]

async def _run_single(guardrail: Any) -> Any:
try:
result = await guardrail.run(
# TODO (rm) Remove this cast, it's wrong
self._context_wrapper,
cast(Agent[Any], source_agent),
text,
)
if self._closing or self._closed:
return False
if result.output.tripwire_triggered:
triggered_results.append(result)
return result
except Exception as exc:
log_model_and_tool_action_warning(
logger,
"Output guardrail raised an exception; skipping it",
exc,
diagnostic_extra=partial(_guardrail_diagnostic_extra, guardrail),
)
continue
return None

# Run sequential guardrails first
for guardrail in sequential_guardrails:
result = await _run_single(guardrail)
if self._closing or self._closed:
return False
if result and result.output.tripwire_triggered:
triggered_results.append(result)
break

# Run parallel guardrails only if sequential didn't trip
if not triggered_results and parallel_guardrails:
tasks = [asyncio.create_task(_run_single(g)) for g in parallel_guardrails]
try:
for done in asyncio.as_completed(tasks):
result = await done
if self._closing or self._closed:
return False
if result and result.output.tripwire_triggered:
triggered_results.append(result)
for t in tasks:
t.cancel()
break

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve all Realtime tripwire results

When two default-parallel Realtime output guardrails both trip, this break reports only the first result and ignores or cancels the others—even if multiple synchronous guardrail tasks have already completed. This regresses the public RealtimeGuardrailTripped.guardrail_results contract (documented as containing all triggered guardrails), and the follow-up message likewise omits their names; the pre-change caller-visible test explicitly covered both results. Preserve all parallel tripwire results before emitting the event while retaining the new sequential short-circuit behavior.

AGENTS.md reference: AGENTS.md:L147-L147

Useful? React with 👍 / 👎.

finally:
for t in tasks:
if not t.done():
t.cancel()
await asyncio.gather(*tasks, return_exceptions=True)

if triggered_results:
# Double-check: bail if already interrupted for this response
Expand Down
28 changes: 24 additions & 4 deletions src/agents/run_internal/guardrails.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,10 +184,8 @@ async def run_output_guardrails(
if not guardrails:
return []

guardrail_tasks = [
asyncio.create_task(run_single_output_guardrail(guardrail, agent, agent_output, context))
for guardrail in guardrails
]
sequential_guardrails = [g for g in guardrails if not g.run_in_parallel]
parallel_guardrails = [g for g in guardrails if g.run_in_parallel]

guardrail_results: list[OutputGuardrailResult] = []

Expand All @@ -196,6 +194,28 @@ def record(result: OutputGuardrailResult) -> None:
if results_sink is not None:
results_sink.append(result)

# Run sequential guardrails first, one by one
for guardrail in sequential_guardrails:
result = await run_single_output_guardrail(guardrail, agent, agent_output, context)
if result.output.tripwire_triggered:
record(result)
_error_tracing.attach_error_to_current_span(
SpanError(
message="Guardrail tripwire triggered",
data={"guardrail": result.guardrail.get_name()},
)
)
raise OutputGuardrailTripwireTriggered(result)
record(result)

if not parallel_guardrails:
return guardrail_results

guardrail_tasks = [
asyncio.create_task(run_single_output_guardrail(guardrail, agent, agent_output, context))
for guardrail in parallel_guardrails
]

try:
for done in asyncio.as_completed(guardrail_tasks):
result = await done
Expand Down
12 changes: 6 additions & 6 deletions tests/realtime/test_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -5722,20 +5722,20 @@ def guardrail_func(context, agent, output):
# Wait for async guardrail tasks to complete
await self._wait_for_guardrail_tasks(session)

# Should have interrupted and sent message with both guardrail names
# Should have interrupted and sent message with the first tripped guardrail
assert mock_model.interrupts_called == 1
assert len(mock_model.sent_messages) == 1
message = mock_model.sent_messages[0]
assert "guardrail_1" in message and "guardrail_2" in message
# Because we fail fast, only one guardrail will finish tripping before others are cancelled.
assert "guardrail_1" in message or "guardrail_2" in message

# Should have emitted event with both guardrail results
# Should have emitted event with the guardrail result
events = []
while not session._event_queue.empty():
events.append(await session._event_queue.get())

guardrail_events = [e for e in events if isinstance(e, RealtimeGuardrailTripped)]
assert len(guardrail_events) == 1
assert len(guardrail_events[0].guardrail_results) == 2
event = next(e for e in events if isinstance(e, RealtimeGuardrailTripped))
assert len(event.guardrail_results) == 1

@pytest.mark.asyncio
async def test_agent_output_guardrails_triggered(self, mock_model, triggered_guardrail):
Expand Down
48 changes: 48 additions & 0 deletions tests/test_output_guardrail_parallel.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import asyncio

import pytest

from agents.agent import Agent
from agents.guardrail import GuardrailFunctionOutput, output_guardrail
from agents.run_context import RunContextWrapper
from agents.run_internal.guardrails import run_output_guardrails


@pytest.mark.asyncio
async def test_output_guardrail_sequential_execution():
execution_order = []

@output_guardrail(run_in_parallel=False, name="seq1")
async def seq_guardrail_1(ctx, agent, output):
await asyncio.sleep(0.05)
execution_order.append("seq1")
return GuardrailFunctionOutput(tripwire_triggered=False, output_info=None)

@output_guardrail(run_in_parallel=False, name="seq2")
async def seq_guardrail_2(ctx, agent, output):
execution_order.append("seq2")
return GuardrailFunctionOutput(tripwire_triggered=False, output_info=None)

@output_guardrail(run_in_parallel=True, name="par1")
async def par_guardrail_1(ctx, agent, output):
await asyncio.sleep(0.02)
execution_order.append("par1")
return GuardrailFunctionOutput(tripwire_triggered=False, output_info=None)

@output_guardrail(run_in_parallel=True, name="par2")
async def par_guardrail_2(ctx, agent, output):
execution_order.append("par2")
return GuardrailFunctionOutput(tripwire_triggered=False, output_info=None)

agent = Agent(name="test")
ctx = RunContextWrapper(context=None)
guardrails = [par_guardrail_1, seq_guardrail_1, par_guardrail_2, seq_guardrail_2]

results = await run_output_guardrails(
guardrails=guardrails, agent=agent, agent_output="test_output", context=ctx, results_sink=[]
)

assert len(results) == 4
# Sequential ones should run first and in order, then parallel ones run concurrently
# par2 will finish before par1 because par1 sleeps
assert execution_order == ["seq1", "seq2", "par2", "par1"]