Skip to content
Closed
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
12 changes: 12 additions & 0 deletions docs/realtime/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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()`.
Expand Down
4 changes: 2 additions & 2 deletions src/agents/realtime/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
)
)

Expand Down Expand Up @@ -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)),
)
)

Expand Down
17 changes: 17 additions & 0 deletions src/agents/tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
),
Expand Down Expand Up @@ -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)."""
...
Expand All @@ -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(...)."""
...
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
53 changes: 53 additions & 0 deletions tests/realtime/test_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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"""
Expand Down
45 changes: 45 additions & 0 deletions tests/test_function_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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