Skip to content

Commit c23a93f

Browse files
committed
code review: keep output guardrails
1 parent e9c856a commit c23a93f

File tree

2 files changed

+57
-19
lines changed

2 files changed

+57
-19
lines changed

src/agents/run.py

Lines changed: 12 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1085,24 +1085,6 @@ async def _start_streaming(
10851085
if server_conversation_tracker is not None:
10861086
server_conversation_tracker.track_server_items(turn_result.model_response)
10871087

1088-
# Check for soft cancel after tool execution completes (before next step)
1089-
if streamed_result._cancel_mode == "after_turn": # type: ignore[comparison-overlap]
1090-
# Save session with complete tool execution (tool calls + tool results)
1091-
if session is not None:
1092-
should_skip_session_save = (
1093-
await AgentRunner._input_guardrail_tripwire_triggered_for_stream(
1094-
streamed_result
1095-
)
1096-
)
1097-
if should_skip_session_save is False:
1098-
await AgentRunner._save_result_to_session(
1099-
session, [], turn_result.new_step_items
1100-
)
1101-
1102-
streamed_result.is_complete = True
1103-
streamed_result._event_queue.put_nowait(QueueCompleteSentinel())
1104-
break
1105-
11061088
if isinstance(turn_result.next_step, NextStepHandoff):
11071089
# Save the conversation to session if enabled (before handoff)
11081090
# Note: Non-streaming path doesn't save handoff turns immediately,
@@ -1125,6 +1107,12 @@ async def _start_streaming(
11251107
streamed_result._event_queue.put_nowait(
11261108
AgentUpdatedStreamEvent(new_agent=current_agent)
11271109
)
1110+
1111+
# Check for soft cancel after handoff (before next turn)
1112+
if streamed_result._cancel_mode == "after_turn": # type: ignore[comparison-overlap]
1113+
streamed_result.is_complete = True
1114+
streamed_result._event_queue.put_nowait(QueueCompleteSentinel())
1115+
break
11281116
elif isinstance(turn_result.next_step, NextStepFinalOutput):
11291117
streamed_result._output_guardrails_task = asyncio.create_task(
11301118
cls._run_output_guardrails(
@@ -1170,6 +1158,12 @@ async def _start_streaming(
11701158
await AgentRunner._save_result_to_session(
11711159
session, [], turn_result.new_step_items
11721160
)
1161+
1162+
# Check for soft cancel after tool execution completes (before next turn)
1163+
if streamed_result._cancel_mode == "after_turn": # type: ignore[comparison-overlap]
1164+
streamed_result.is_complete = True
1165+
streamed_result._event_queue.put_nowait(QueueCompleteSentinel())
1166+
break
11731167
except AgentsException as exc:
11741168
streamed_result.is_complete = True
11751169
streamed_result._event_queue.put_nowait(QueueCompleteSentinel())

tests/test_soft_cancel.py

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44

55
import pytest
66

7-
from agents import Agent, Runner, SQLiteSession
7+
from agents import Agent, OutputGuardrail, Runner, SQLiteSession
8+
from agents.guardrail import GuardrailFunctionOutput
89

910
from .fake_model import FakeModel
1011
from .test_responses import get_function_tool, get_function_tool_call, get_text_message
@@ -485,3 +486,46 @@ async def test_soft_cancel_with_session_and_multiple_turns():
485486

486487
# Cleanup
487488
await session.clear_session()
489+
490+
491+
@pytest.mark.asyncio
492+
async def test_soft_cancel_runs_output_guardrails_before_canceling():
493+
"""Verify output guardrails run even when cancellation happens after final output."""
494+
model = FakeModel()
495+
496+
# Track if guardrail was called
497+
guardrail_called = False
498+
499+
def output_guardrail_fn(context, agent, output):
500+
nonlocal guardrail_called
501+
guardrail_called = True
502+
return GuardrailFunctionOutput(output_info=None, tripwire_triggered=False)
503+
504+
agent = Agent(
505+
name="Assistant",
506+
model=model,
507+
output_guardrails=[OutputGuardrail(guardrail_function=output_guardrail_fn)],
508+
)
509+
510+
# Setup: agent produces final output
511+
model.add_multiple_turn_outputs([[get_text_message("Final answer")]])
512+
513+
result = Runner.run_streamed(agent, input="What is the answer?")
514+
515+
# Cancel after seeing the message output event (indicates turn completed)
516+
# but before consuming all events
517+
async for event in result.stream_events():
518+
if event.type == "run_item_stream_event" and event.name == "message_output_created":
519+
# Cancel after turn completes - guardrails should still run
520+
result.cancel(mode="after_turn")
521+
# Don't break - continue consuming to let guardrails complete
522+
523+
# Guardrail should have been called
524+
assert guardrail_called, "Output guardrail should run even when canceling after final output"
525+
526+
# Final output should be set
527+
assert result.final_output is not None, "final_output should be set even when canceling"
528+
assert result.final_output == "Final answer"
529+
530+
# Output guardrail results should be recorded
531+
assert len(result.output_guardrail_results) == 1, "Output guardrail results should be recorded"

0 commit comments

Comments
 (0)