diff --git a/docs/realtime/guide.md b/docs/realtime/guide.md index 672c086678..4c73c1f945 100644 --- a/docs/realtime/guide.md +++ b/docs/realtime/guide.md @@ -206,6 +206,18 @@ agent = RealtimeAgent( ) ``` +By default, the model generates a follow-up response after each tool's output is sent back. For +side-effect tools where you do not want the agent to speak again immediately (for example, a tool +that records analytics or schedules background work), pass `start_response=False`: + +```python +@function_tool(start_response=False) +def log_analytics(event: str) -> str: + """Record an analytics event without prompting the agent to speak again.""" + record_event(event) + return "logged" +``` + ### Tool approvals Function tools can require human approval before execution. When that happens, the session emits `tool_approval_required` and pauses the tool run until you call `approve_tool_call()` or `reject_tool_call()`. diff --git a/src/agents/realtime/session.py b/src/agents/realtime/session.py index 89f63b02fa..0b2470bbc7 100644 --- a/src/agents/realtime/session.py +++ b/src/agents/realtime/session.py @@ -512,7 +512,7 @@ async def _send_tool_rejection( RealtimeModelSendToolOutput( tool_call=event, output=rejection_message, - start_response=True, + start_response=bool(getattr(tool, "start_response", True)), ) ) @@ -656,7 +656,7 @@ async def _handle_tool_call( RealtimeModelSendToolOutput( tool_call=event, output=_serialize_tool_output(result), - start_response=True, + start_response=bool(getattr(func_tool, "start_response", True)), ) ) diff --git a/src/agents/tool.py b/src/agents/tool.py index ca13ee201e..403fe223d2 100644 --- a/src/agents/tool.py +++ b/src/agents/tool.py @@ -350,6 +350,13 @@ class FunctionTool: defer_loading: bool = False """Whether the Responses API should hide this tool definition until tool search loads it.""" + start_response: bool = field(default=True, kw_only=True) + """Whether the model should automatically generate a follow-up response after this tool's + output is sent back. Currently only honored by Realtime sessions. Set to False when a tool + completes a side-effect (e.g. updates external state, logs telemetry, schedules background + work) and you do not want the agent to immediately speak again afterwards. Defaults to True + to preserve existing behavior.""" + _failure_error_function: ToolErrorFunction | None = field( default=None, kw_only=True, @@ -488,6 +495,7 @@ def _build_wrapped_function_tool( timeout_behavior: ToolTimeoutBehavior = "error_as_result", timeout_error_function: ToolErrorFunction | None = None, defer_loading: bool = False, + start_response: bool = True, sync_invoker: bool = False, mcp_title: str | None = None, tool_origin: ToolOrigin | None = None, @@ -515,6 +523,7 @@ def _build_wrapped_function_tool( timeout_behavior=timeout_behavior, timeout_error_function=timeout_error_function, defer_loading=defer_loading, + start_response=start_response, _mcp_title=mcp_title, _tool_origin=tool_origin, ), @@ -1694,6 +1703,7 @@ def function_tool( timeout_behavior: ToolTimeoutBehavior = "error_as_result", timeout_error_function: ToolErrorFunction | None = None, defer_loading: bool = False, + start_response: bool = True, ) -> FunctionTool: """Overload for usage as @function_tool (no parentheses).""" ... @@ -1717,6 +1727,7 @@ def function_tool( timeout_behavior: ToolTimeoutBehavior = "error_as_result", timeout_error_function: ToolErrorFunction | None = None, defer_loading: bool = False, + start_response: bool = True, ) -> Callable[[ToolFunction[...]], FunctionTool]: """Overload for usage as @function_tool(...).""" ... @@ -1740,6 +1751,7 @@ def function_tool( timeout_behavior: ToolTimeoutBehavior = "error_as_result", timeout_error_function: ToolErrorFunction | None = None, defer_loading: bool = False, + start_response: bool = True, ) -> FunctionTool | Callable[[ToolFunction[...]], FunctionTool]: """ Decorator to create a FunctionTool from a function. By default, we will: @@ -1785,6 +1797,10 @@ def function_tool( timeout_behavior="error_as_result". defer_loading: Whether to hide this tool definition until Responses API tool search explicitly loads it. + start_response: Whether the model should automatically generate a follow-up response + after this tool's output is sent back. Currently only honored by Realtime sessions. + Set to False when a tool completes a side-effect and you do not want the agent to + speak again immediately afterwards. Defaults to True. """ def _create_function_tool(the_func: ToolFunction[...]) -> FunctionTool: @@ -1855,6 +1871,7 @@ async def _on_invoke_tool_impl(ctx: ToolContext[Any], input: str) -> Any: timeout_behavior=timeout_behavior, timeout_error_function=timeout_error_function, defer_loading=defer_loading, + start_response=start_response, sync_invoker=is_sync_function_tool, ) return function_tool diff --git a/tests/realtime/test_session.py b/tests/realtime/test_session.py index c1c919a866..3f5ced1deb 100644 --- a/tests/realtime/test_session.py +++ b/tests/realtime/test_session.py @@ -315,6 +315,7 @@ def _set_default_timeout_fields(tool: Mock) -> Mock: tool.timeout_seconds = None tool.timeout_behavior = "error_as_result" tool.timeout_error_function = None + tool.start_response = True return tool @@ -1549,6 +1550,58 @@ async def test_mixed_tool_types_filtering(self, mock_model, mock_agent): sent_call, sent_output, _ = mock_model.sent_tool_outputs[0] assert sent_output == "result2" + @pytest.mark.asyncio + async def test_function_tool_start_response_false_skips_response_create( + self, mock_model, mock_agent + ): + """A FunctionTool with start_response=False should propagate False to the transport. + + The realtime transport already honors RealtimeModelSendToolOutput.start_response. + Tool authors should be able to opt out of the automatic response.create at the + FunctionTool level, e.g. for side-effect tools that do not need the agent to + speak again immediately afterwards. + """ + silent_tool = _set_default_timeout_fields(Mock(spec=FunctionTool)) + silent_tool.name = "silent_tool" + silent_tool.on_invoke_tool = AsyncMock(return_value="logged") + silent_tool.needs_approval = False + silent_tool.start_response = False + mock_agent.get_all_tools.return_value = [silent_tool] + + session = RealtimeSession(mock_model, mock_agent, None) + tool_call_event = RealtimeModelToolCallEvent( + name="silent_tool", call_id="call_silent", arguments="{}" + ) + + await session._handle_tool_call(tool_call_event) + + assert len(mock_model.sent_tool_outputs) == 1 + _sent_call, sent_output, start_response = mock_model.sent_tool_outputs[0] + assert sent_output == "logged" + assert start_response is False + + @pytest.mark.asyncio + async def test_function_tool_default_start_response_remains_true( + self, mock_model, mock_agent, mock_function_tool + ): + """Tools without an explicit start_response continue to trigger response.create. + + Backward compatibility check: existing tools (default start_response=True) must + keep auto-triggering the model's follow-up response after their output is sent. + """ + mock_agent.get_all_tools.return_value = [mock_function_tool] + + session = RealtimeSession(mock_model, mock_agent, None) + tool_call_event = RealtimeModelToolCallEvent( + name="test_function", call_id="call_default", arguments="{}" + ) + + await session._handle_tool_call(tool_call_event) + + assert len(mock_model.sent_tool_outputs) == 1 + _sent_call, _sent_output, start_response = mock_model.sent_tool_outputs[0] + assert start_response is True + class TestGuardrailFunctionality: """Test suite for output guardrail functionality in RealtimeSession""" diff --git a/tests/test_function_tool.py b/tests/test_function_tool.py index 300d1ab3b9..e4489775b7 100644 --- a/tests/test_function_tool.py +++ b/tests/test_function_tool.py @@ -951,3 +951,48 @@ def test_function_tool_timeout_error_function_must_be_callable() -> None: on_invoke_tool=_noop_on_invoke_tool, timeout_error_function=cast(Any, "not-callable"), ) + + +def test_function_tool_start_response_defaults_to_true() -> None: + """FunctionTool should default `start_response` to True for backward compatibility.""" + tool = FunctionTool( + name="default_start_response", + description="default", + params_json_schema={}, + on_invoke_tool=_noop_on_invoke_tool, + ) + assert tool.start_response is True + + +def test_function_tool_start_response_can_be_set_to_false() -> None: + """FunctionTool should accept `start_response=False` as a kw-only field.""" + tool = FunctionTool( + name="silent_tool", + description="no follow-up response", + params_json_schema={}, + on_invoke_tool=_noop_on_invoke_tool, + start_response=False, + ) + assert tool.start_response is False + + +def test_function_tool_decorator_threads_start_response() -> None: + """The @function_tool(start_response=False) kwarg should reach the produced FunctionTool.""" + + @function_tool(start_response=False) + def silent_side_effect_tool() -> str: + """Run a side effect and stay silent afterwards.""" + return "logged" + + assert silent_side_effect_tool.start_response is False + + +def test_function_tool_decorator_default_start_response_is_true() -> None: + """Plain @function_tool usage keeps the existing auto-response behavior.""" + + @function_tool + def speaking_tool() -> str: + """Tool that should still auto-trigger a follow-up response by default.""" + return "ok" + + assert speaking_tool.start_response is True