Skip to content

Commit 6c816c3

Browse files
authored
feat: support agent output guardrails in realtime (#1381)
## Summary - allow RealtimeAgent to define output guardrails - run agent-level guardrails in realtime sessions - avoid running duplicate guardrails when supplied on both agent and run config - document and test agent-level output guardrails ## Testing - `make format` - `make lint` - `make mypy` - `make tests` - `make build-docs` ------ https://chatgpt.com/codex/tasks/task_i_6892a5f23df0832492b8cba9ed214272
1 parent 5539afc commit 6c816c3

File tree

5 files changed

+115
-1
lines changed

5 files changed

+115
-1
lines changed

docs/ja/realtime/guide.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,24 @@ main_agent = RealtimeAgent(
134134

135135
Realtime エージェントでは出力ガードレールのみがサポートされます。パフォーマンス低下を防ぐためにデバウンスされ、リアルタイム生成中に毎単語ではなく定期的に実行されます。デフォルトのデバウンス長は 100 文字で、設定可能です。
136136

137+
ガードレールは `RealtimeAgent` に直接設定することも、セッションの `run_config` で指定することもできます。両方のリストが同時に実行されます。
138+
139+
```python
140+
from agents.guardrail import GuardrailFunctionOutput, OutputGuardrail
141+
142+
def sensitive_data_check(context, agent, output):
143+
return GuardrailFunctionOutput(
144+
tripwire_triggered="password" in output,
145+
output_info=None,
146+
)
147+
148+
agent = RealtimeAgent(
149+
name="Assistant",
150+
instructions="...",
151+
output_guardrails=[OutputGuardrail(guardrail_function=sensitive_data_check)],
152+
)
153+
```
154+
137155
ガードレールがトリガーされると `guardrail_tripped` イベントが生成され、エージェントの現在の応答を中断できます。デバウンス動作により安全性とリアルタイム性能のバランスを取ります。テキストエージェントとは異なり、realtime エージェントはガードレールがトリップしても Exception を発生させません。
138156

139157
## オーディオ処理

docs/realtime/guide.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,24 @@ For complete event details, see [`RealtimeSessionEvent`][agents.realtime.events.
130130

131131
Only output guardrails are supported for realtime agents. These guardrails are debounced and run periodically (not on every word) to avoid performance issues during real-time generation. The default debounce length is 100 characters, but this is configurable.
132132

133+
Guardrails can be attached directly to a `RealtimeAgent` or provided via the session's `run_config`. Guardrails from both sources run together.
134+
135+
```python
136+
from agents.guardrail import GuardrailFunctionOutput, OutputGuardrail
137+
138+
def sensitive_data_check(context, agent, output):
139+
return GuardrailFunctionOutput(
140+
tripwire_triggered="password" in output,
141+
output_info=None,
142+
)
143+
144+
agent = RealtimeAgent(
145+
name="Assistant",
146+
instructions="...",
147+
output_guardrails=[OutputGuardrail(guardrail_function=sensitive_data_check)],
148+
)
149+
```
150+
133151
When a guardrail is triggered, it generates a `guardrail_tripped` event and can interrupt the agent's current response. The debounce behavior helps balance safety with real-time performance requirements. Unlike text agents, realtime agents do **not** raise an Exception when guardrails are tripped.
134152

135153
## Audio processing

src/agents/realtime/agent.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from typing import Any, Callable, Generic, cast
88

99
from ..agent import AgentBase
10+
from ..guardrail import OutputGuardrail
1011
from ..handoffs import Handoff
1112
from ..lifecycle import AgentHooksBase, RunHooksBase
1213
from ..logger import logger
@@ -62,6 +63,11 @@ class RealtimeAgent(AgentBase, Generic[TContext]):
6263
modularity.
6364
"""
6465

66+
output_guardrails: list[OutputGuardrail[TContext]] = field(default_factory=list)
67+
"""A list of checks that run on the final output of the agent, after generating a response.
68+
Runs only if the agent produces a final output.
69+
"""
70+
6571
hooks: RealtimeAgentHooks | None = None
6672
"""A class that receives callbacks on various lifecycle events for this agent.
6773
"""

src/agents/realtime/session.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -444,7 +444,17 @@ def _get_new_history(
444444

445445
async def _run_output_guardrails(self, text: str) -> bool:
446446
"""Run output guardrails on the given text. Returns True if any guardrail was triggered."""
447-
output_guardrails = self._run_config.get("output_guardrails", [])
447+
combined_guardrails = self._current_agent.output_guardrails + self._run_config.get(
448+
"output_guardrails", []
449+
)
450+
seen_ids: set[int] = set()
451+
output_guardrails = []
452+
for guardrail in combined_guardrails:
453+
guardrail_id = id(guardrail)
454+
if guardrail_id not in seen_ids:
455+
output_guardrails.append(guardrail)
456+
seen_ids.add(guardrail_id)
457+
448458
if not output_guardrails or self._interrupted_by_guardrail:
449459
return False
450460

tests/realtime/test_session.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ def mock_agent():
104104
agent.get_all_tools = AsyncMock(return_value=[])
105105

106106
type(agent).handoffs = PropertyMock(return_value=[])
107+
type(agent).output_guardrails = PropertyMock(return_value=[])
107108
return agent
108109

109110

@@ -1063,6 +1064,37 @@ async def test_transcript_delta_triggers_guardrail_at_threshold(
10631064
assert len(guardrail_events) == 1
10641065
assert guardrail_events[0].message == "this is more than ten characters"
10651066

1067+
@pytest.mark.asyncio
1068+
async def test_agent_and_run_config_guardrails_not_run_twice(self, mock_model):
1069+
"""Guardrails shared by agent and run config should execute once."""
1070+
1071+
call_count = 0
1072+
1073+
def guardrail_func(context, agent, output):
1074+
nonlocal call_count
1075+
call_count += 1
1076+
return GuardrailFunctionOutput(output_info={}, tripwire_triggered=False)
1077+
1078+
shared_guardrail = OutputGuardrail(
1079+
guardrail_function=guardrail_func, name="shared_guardrail"
1080+
)
1081+
1082+
agent = RealtimeAgent(name="agent", output_guardrails=[shared_guardrail])
1083+
run_config: RealtimeRunConfig = {
1084+
"output_guardrails": [shared_guardrail],
1085+
"guardrails_settings": {"debounce_text_length": 5},
1086+
}
1087+
1088+
session = RealtimeSession(mock_model, agent, None, run_config=run_config)
1089+
1090+
await session.on_event(
1091+
RealtimeModelTranscriptDeltaEvent(item_id="item_1", delta="hello", response_id="resp_1")
1092+
)
1093+
1094+
await self._wait_for_guardrail_tasks(session)
1095+
1096+
assert call_count == 1
1097+
10661098
@pytest.mark.asyncio
10671099
async def test_transcript_delta_multiple_thresholds_same_item(
10681100
self, mock_model, mock_agent, triggered_guardrail
@@ -1210,6 +1242,36 @@ def guardrail_func(context, agent, output):
12101242
assert len(guardrail_events) == 1
12111243
assert len(guardrail_events[0].guardrail_results) == 2
12121244

1245+
@pytest.mark.asyncio
1246+
async def test_agent_output_guardrails_triggered(self, mock_model, triggered_guardrail):
1247+
"""Test that guardrails defined on the agent are executed."""
1248+
agent = RealtimeAgent(name="agent", output_guardrails=[triggered_guardrail])
1249+
run_config: RealtimeRunConfig = {
1250+
"guardrails_settings": {"debounce_text_length": 10},
1251+
}
1252+
1253+
session = RealtimeSession(mock_model, agent, None, run_config=run_config)
1254+
1255+
transcript_event = RealtimeModelTranscriptDeltaEvent(
1256+
item_id="item_1", delta="this is more than ten characters", response_id="resp_1"
1257+
)
1258+
1259+
await session.on_event(transcript_event)
1260+
await self._wait_for_guardrail_tasks(session)
1261+
1262+
assert session._interrupted_by_guardrail is True
1263+
assert mock_model.interrupts_called == 1
1264+
assert len(mock_model.sent_messages) == 1
1265+
assert "triggered_guardrail" in mock_model.sent_messages[0]
1266+
1267+
events = []
1268+
while not session._event_queue.empty():
1269+
events.append(await session._event_queue.get())
1270+
1271+
guardrail_events = [e for e in events if isinstance(e, RealtimeGuardrailTripped)]
1272+
assert len(guardrail_events) == 1
1273+
assert guardrail_events[0].message == "this is more than ten characters"
1274+
12131275

12141276
class TestModelSettingsIntegration:
12151277
"""Test suite for model settings integration in RealtimeSession."""

0 commit comments

Comments
 (0)