Skip to content

Commit 069f126

Browse files
authored
feat: #2346 add configurable timeout handling for function tools (#2479)
1 parent 5510bda commit 069f126

File tree

10 files changed

+444
-13
lines changed

10 files changed

+444
-13
lines changed

src/agents/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
RunErrorDetails,
2727
ToolInputGuardrailTripwireTriggered,
2828
ToolOutputGuardrailTripwireTriggered,
29+
ToolTimeoutError,
2930
UserError,
3031
)
3132
from .guardrail import (
@@ -285,6 +286,7 @@ def enable_verbose_stdout_logging():
285286
"Prompt",
286287
"MaxTurnsExceeded",
287288
"ModelBehaviorError",
289+
"ToolTimeoutError",
288290
"UserError",
289291
"InputGuardrail",
290292
"InputGuardrailResult",

src/agents/exceptions.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,18 @@ def __init__(self, message: str):
7575
super().__init__(message)
7676

7777

78+
class ToolTimeoutError(AgentsException):
79+
"""Exception raised when a function tool invocation exceeds its timeout."""
80+
81+
tool_name: str
82+
timeout_seconds: float
83+
84+
def __init__(self, tool_name: str, timeout_seconds: float):
85+
self.tool_name = tool_name
86+
self.timeout_seconds = timeout_seconds
87+
super().__init__(f"Tool '{tool_name}' timed out after {timeout_seconds:g} seconds.")
88+
89+
7890
class InputGuardrailTripwireTriggered(AgentsException):
7991
"""Exception raised when a guardrail tripwire is triggered."""
8092

src/agents/realtime/session.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from ..logger import logger
1616
from ..run_config import ToolErrorFormatterArgs
1717
from ..run_context import RunContextWrapper, TContext
18-
from ..tool import DEFAULT_APPROVAL_REJECTION_MESSAGE, FunctionTool
18+
from ..tool import DEFAULT_APPROVAL_REJECTION_MESSAGE, FunctionTool, invoke_function_tool
1919
from ..tool_context import ToolContext
2020
from ..util._approvals import evaluate_needs_approval_setting
2121
from .agent import RealtimeAgent
@@ -602,7 +602,11 @@ async def _handle_tool_call(
602602
tool_arguments=event.arguments,
603603
agent=agent,
604604
)
605-
result = await func_tool.on_invoke_tool(tool_context, event.arguments)
605+
result = await invoke_function_tool(
606+
function_tool=func_tool,
607+
context=tool_context,
608+
arguments=event.arguments,
609+
)
606610

607611
await self._model.send_event(
608612
RealtimeModelSendToolOutput(

src/agents/run_internal/tool_execution.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
ShellCallOutcome,
5353
ShellCommandOutput,
5454
Tool,
55+
invoke_function_tool,
5556
resolve_computer,
5657
)
5758
from ..tool_context import ToolContext
@@ -897,7 +898,11 @@ async def run_single_tool(func_tool: FunctionTool, tool_call: ResponseFunctionTo
897898
else _coro.noop_coroutine()
898899
),
899900
)
900-
real_result = await func_tool.on_invoke_tool(tool_context, tool_call.arguments)
901+
real_result = await invoke_function_tool(
902+
function_tool=func_tool,
903+
context=tool_context,
904+
arguments=tool_call.arguments,
905+
)
901906

902907
final_result = await _execute_tool_output_guardrails(
903908
func_tool=func_tool,

src/agents/tool.py

Lines changed: 122 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import asyncio
44
import inspect
55
import json
6+
import math
67
import weakref
78
from collections.abc import Awaitable, Mapping
89
from dataclasses import dataclass, field
@@ -35,7 +36,7 @@
3536
from . import _debug
3637
from .computer import AsyncComputer, Computer
3738
from .editor import ApplyPatchEditor, ApplyPatchOperation
38-
from .exceptions import ModelBehaviorError, UserError
39+
from .exceptions import ModelBehaviorError, ToolTimeoutError, UserError
3940
from .function_schema import DocstringStyle, function_schema
4041
from .logger import logger
4142
from .run_context import RunContextWrapper
@@ -64,6 +65,9 @@
6465
]
6566

6667
DEFAULT_APPROVAL_REJECTION_MESSAGE = "Tool execution was not approved."
68+
ToolTimeoutBehavior = Literal["error_as_result", "raise_exception"]
69+
ToolErrorFunction = Callable[[RunContextWrapper[Any], Exception], MaybeAwaitable[str]]
70+
_SYNC_FUNCTION_TOOL_MARKER = "__agents_sync_function_tool__"
6771

6872

6973
class ToolOutputText(BaseModel):
@@ -259,6 +263,20 @@ class FunctionTool:
259263
function that takes (run_context, tool_parameters, call_id) and returns whether this
260264
specific call needs approval."""
261265

266+
# Keep timeout fields after needs_approval to preserve positional constructor compatibility.
267+
timeout_seconds: float | None = None
268+
"""Optional timeout (seconds) for each tool invocation."""
269+
270+
timeout_behavior: ToolTimeoutBehavior = "error_as_result"
271+
"""How to handle timeout events.
272+
273+
- "error_as_result": return a model-visible timeout error string.
274+
- "raise_exception": raise a ToolTimeoutError and fail the run.
275+
"""
276+
277+
timeout_error_function: ToolErrorFunction | None = None
278+
"""Optional formatter for timeout errors when timeout_behavior is "error_as_result"."""
279+
262280
_is_agent_tool: bool = field(default=False, init=False, repr=False)
263281
"""Internal flag indicating if this tool is an agent-as-tool."""
264282

@@ -271,6 +289,7 @@ class FunctionTool:
271289
def __post_init__(self):
272290
if self.strict_json_schema:
273291
self.params_json_schema = ensure_strict_json_schema(self.params_json_schema)
292+
_validate_function_tool_timeout_config(self)
274293

275294

276295
@dataclass
@@ -905,8 +924,59 @@ def default_tool_error_function(ctx: RunContextWrapper[Any], error: Exception) -
905924
return f"An error occurred while running the tool. Please try again. Error: {str(error)}"
906925

907926

908-
ToolErrorFunction = Callable[[RunContextWrapper[Any], Exception], MaybeAwaitable[str]]
909927
_UNSET_FAILURE_ERROR_FUNCTION = object()
928+
_FUNCTION_TOOL_TIMEOUT_BEHAVIORS: tuple[ToolTimeoutBehavior, ...] = (
929+
"error_as_result",
930+
"raise_exception",
931+
)
932+
933+
934+
def default_tool_timeout_error_message(*, tool_name: str, timeout_seconds: float) -> str:
935+
"""Build the default message returned to the model when a tool times out."""
936+
return f"Tool '{tool_name}' timed out after {timeout_seconds:g} seconds."
937+
938+
939+
async def invoke_function_tool(
940+
*,
941+
function_tool: FunctionTool,
942+
context: ToolContext[Any],
943+
arguments: str,
944+
) -> Any:
945+
"""Invoke a function tool, enforcing timeout configuration when provided."""
946+
timeout_seconds = function_tool.timeout_seconds
947+
if timeout_seconds is None:
948+
return await function_tool.on_invoke_tool(context, arguments)
949+
950+
tool_task: asyncio.Future[Any] = asyncio.ensure_future(
951+
function_tool.on_invoke_tool(context, arguments)
952+
)
953+
try:
954+
return await asyncio.wait_for(tool_task, timeout=timeout_seconds)
955+
except asyncio.TimeoutError as exc:
956+
if tool_task.done() and not tool_task.cancelled():
957+
tool_exception = tool_task.exception()
958+
if tool_exception is None:
959+
return tool_task.result()
960+
raise tool_exception from None
961+
962+
timeout_error = ToolTimeoutError(
963+
tool_name=function_tool.name,
964+
timeout_seconds=timeout_seconds,
965+
)
966+
if function_tool.timeout_behavior == "raise_exception":
967+
raise timeout_error from exc
968+
969+
timeout_error_function = function_tool.timeout_error_function
970+
if timeout_error_function is None:
971+
return default_tool_timeout_error_message(
972+
tool_name=function_tool.name,
973+
timeout_seconds=timeout_seconds,
974+
)
975+
976+
timeout_result = timeout_error_function(context, timeout_error)
977+
if inspect.isawaitable(timeout_result):
978+
return await timeout_result
979+
return timeout_result
910980

911981

912982
@overload
@@ -924,6 +994,9 @@ def function_tool(
924994
| Callable[[RunContextWrapper[Any], dict[str, Any], str], Awaitable[bool]] = False,
925995
tool_input_guardrails: list[ToolInputGuardrail[Any]] | None = None,
926996
tool_output_guardrails: list[ToolOutputGuardrail[Any]] | None = None,
997+
timeout: float | None = None,
998+
timeout_behavior: ToolTimeoutBehavior = "error_as_result",
999+
timeout_error_function: ToolErrorFunction | None = None,
9271000
) -> FunctionTool:
9281001
"""Overload for usage as @function_tool (no parentheses)."""
9291002
...
@@ -943,6 +1016,9 @@ def function_tool(
9431016
| Callable[[RunContextWrapper[Any], dict[str, Any], str], Awaitable[bool]] = False,
9441017
tool_input_guardrails: list[ToolInputGuardrail[Any]] | None = None,
9451018
tool_output_guardrails: list[ToolOutputGuardrail[Any]] | None = None,
1019+
timeout: float | None = None,
1020+
timeout_behavior: ToolTimeoutBehavior = "error_as_result",
1021+
timeout_error_function: ToolErrorFunction | None = None,
9461022
) -> Callable[[ToolFunction[...]], FunctionTool]:
9471023
"""Overload for usage as @function_tool(...)."""
9481024
...
@@ -962,6 +1038,9 @@ def function_tool(
9621038
| Callable[[RunContextWrapper[Any], dict[str, Any], str], Awaitable[bool]] = False,
9631039
tool_input_guardrails: list[ToolInputGuardrail[Any]] | None = None,
9641040
tool_output_guardrails: list[ToolOutputGuardrail[Any]] | None = None,
1041+
timeout: float | None = None,
1042+
timeout_behavior: ToolTimeoutBehavior = "error_as_result",
1043+
timeout_error_function: ToolErrorFunction | None = None,
9651044
) -> FunctionTool | Callable[[ToolFunction[...]], FunctionTool]:
9661045
"""
9671046
Decorator to create a FunctionTool from a function. By default, we will:
@@ -1000,9 +1079,15 @@ def function_tool(
10001079
whether this specific call needs approval.
10011080
tool_input_guardrails: Optional list of guardrails to run before invoking the tool.
10021081
tool_output_guardrails: Optional list of guardrails to run after the tool returns.
1082+
timeout: Optional timeout in seconds for each tool call.
1083+
timeout_behavior: Timeout handling mode. "error_as_result" returns a model-visible message,
1084+
while "raise_exception" raises ToolTimeoutError and fails the run.
1085+
timeout_error_function: Optional formatter used for timeout messages when
1086+
timeout_behavior="error_as_result".
10031087
"""
10041088

10051089
def _create_function_tool(the_func: ToolFunction[...]) -> FunctionTool:
1090+
is_sync_function_tool = not inspect.iscoroutinefunction(the_func)
10061091
schema = function_schema(
10071092
func=the_func,
10081093
name_override=name_override,
@@ -1043,7 +1128,7 @@ async def _on_invoke_tool_impl(ctx: ToolContext[Any], input: str) -> Any:
10431128
if not _debug.DONT_LOG_TOOL_DATA:
10441129
logger.debug(f"Tool call args: {args}, kwargs: {kwargs_dict}")
10451130

1046-
if inspect.iscoroutinefunction(the_func):
1131+
if not is_sync_function_tool:
10471132
if schema.takes_context:
10481133
result = await the_func(ctx, *args, **kwargs_dict)
10491134
else:
@@ -1106,6 +1191,9 @@ async def _on_invoke_tool(ctx: ToolContext[Any], input: str) -> Any:
11061191
)
11071192
return result
11081193

1194+
if is_sync_function_tool:
1195+
setattr(_on_invoke_tool, _SYNC_FUNCTION_TOOL_MARKER, True)
1196+
11091197
return FunctionTool(
11101198
name=schema.name,
11111199
description=schema.description or "",
@@ -1116,6 +1204,9 @@ async def _on_invoke_tool(ctx: ToolContext[Any], input: str) -> Any:
11161204
needs_approval=needs_approval,
11171205
tool_input_guardrails=tool_input_guardrails,
11181206
tool_output_guardrails=tool_output_guardrails,
1207+
timeout_seconds=timeout,
1208+
timeout_behavior=timeout_behavior,
1209+
timeout_error_function=timeout_error_function,
11191210
)
11201211

11211212
# If func is actually a callable, we were used as @function_tool with no parentheses
@@ -1140,6 +1231,34 @@ def _is_computer_provider(candidate: object) -> bool:
11401231
)
11411232

11421233

1234+
def _validate_function_tool_timeout_config(tool: FunctionTool) -> None:
1235+
timeout_seconds = tool.timeout_seconds
1236+
if timeout_seconds is not None:
1237+
if isinstance(timeout_seconds, bool) or not isinstance(timeout_seconds, (int, float)):
1238+
raise TypeError(
1239+
"FunctionTool timeout_seconds must be a positive number in seconds or None."
1240+
)
1241+
timeout_seconds = float(timeout_seconds)
1242+
if not math.isfinite(timeout_seconds):
1243+
raise ValueError("FunctionTool timeout_seconds must be a finite number.")
1244+
if timeout_seconds <= 0:
1245+
raise ValueError("FunctionTool timeout_seconds must be greater than 0.")
1246+
if getattr(tool.on_invoke_tool, _SYNC_FUNCTION_TOOL_MARKER, False):
1247+
raise ValueError(
1248+
"FunctionTool timeout_seconds is only supported for async @function_tool handlers."
1249+
)
1250+
tool.timeout_seconds = timeout_seconds
1251+
1252+
if tool.timeout_behavior not in _FUNCTION_TOOL_TIMEOUT_BEHAVIORS:
1253+
raise ValueError(
1254+
"FunctionTool timeout_behavior must be one of: "
1255+
+ ", ".join(_FUNCTION_TOOL_TIMEOUT_BEHAVIORS)
1256+
)
1257+
1258+
if tool.timeout_error_function is not None and not callable(tool.timeout_error_function):
1259+
raise TypeError("FunctionTool timeout_error_function must be callable or None.")
1260+
1261+
11431262
def _store_computer_initializer(tool: ComputerTool[Any]) -> None:
11441263
config = tool.computer
11451264
if callable(config) or _is_computer_provider(config):

tests/realtime/test_session.py

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -307,9 +307,16 @@ def mock_model():
307307
return MockRealtimeModel()
308308

309309

310+
def _set_default_timeout_fields(tool: Mock) -> Mock:
311+
tool.timeout_seconds = None
312+
tool.timeout_behavior = "error_as_result"
313+
tool.timeout_error_function = None
314+
return tool
315+
316+
310317
@pytest.fixture
311318
def mock_function_tool():
312-
tool = Mock(spec=FunctionTool)
319+
tool = _set_default_timeout_fields(Mock(spec=FunctionTool))
313320
tool.name = "test_function"
314321
tool.on_invoke_tool = AsyncMock(return_value="function_result")
315322
tool.needs_approval = False
@@ -1017,16 +1024,46 @@ async def test_function_tool_execution_success(
10171024
assert tool_end_event.agent == mock_agent
10181025
assert tool_end_event.arguments == '{"param": "value"}'
10191026

1027+
@pytest.mark.asyncio
1028+
async def test_function_tool_timeout_returns_result_message(self, mock_model, mock_agent):
1029+
async def invoke_slow_tool(_ctx: ToolContext[Any], _arguments: str) -> str:
1030+
await asyncio.sleep(0.2)
1031+
return "done"
1032+
1033+
timeout_tool = FunctionTool(
1034+
name="slow_tool",
1035+
description="slow",
1036+
params_json_schema={"type": "object", "properties": {}},
1037+
on_invoke_tool=invoke_slow_tool,
1038+
timeout_seconds=0.01,
1039+
)
1040+
mock_agent.get_all_tools.return_value = [timeout_tool]
1041+
1042+
session = RealtimeSession(mock_model, mock_agent, None)
1043+
tool_call_event = RealtimeModelToolCallEvent(
1044+
name="slow_tool",
1045+
call_id="call_timeout",
1046+
arguments="{}",
1047+
)
1048+
1049+
await session._handle_tool_call(tool_call_event)
1050+
1051+
assert len(mock_model.sent_tool_outputs) == 1
1052+
sent_call, sent_output, start_response = mock_model.sent_tool_outputs[0]
1053+
assert sent_call == tool_call_event
1054+
assert start_response is True
1055+
assert "timed out" in sent_output.lower()
1056+
10201057
@pytest.mark.asyncio
10211058
async def test_function_tool_with_multiple_tools_available(self, mock_model, mock_agent):
10221059
"""Test function tool execution when multiple tools are available"""
10231060
# Create multiple mock tools
1024-
tool1 = Mock(spec=FunctionTool)
1061+
tool1 = _set_default_timeout_fields(Mock(spec=FunctionTool))
10251062
tool1.name = "tool_one"
10261063
tool1.on_invoke_tool = AsyncMock(return_value="result_one")
10271064
tool1.needs_approval = False
10281065

1029-
tool2 = Mock(spec=FunctionTool)
1066+
tool2 = _set_default_timeout_fields(Mock(spec=FunctionTool))
10301067
tool2.name = "tool_two"
10311068
tool2.on_invoke_tool = AsyncMock(return_value="result_two")
10321069
tool2.needs_approval = False
@@ -1329,7 +1366,7 @@ async def test_tool_call_with_custom_call_id(self, mock_model, mock_agent, mock_
13291366
async def test_tool_result_conversion_to_string(self, mock_model, mock_agent):
13301367
"""Test that tool results are converted to strings for model output"""
13311368
# Create tool that returns non-string result
1332-
tool = Mock(spec=FunctionTool)
1369+
tool = _set_default_timeout_fields(Mock(spec=FunctionTool))
13331370
tool.name = "test_function"
13341371
tool.on_invoke_tool = AsyncMock(return_value={"result": "data", "count": 42})
13351372
tool.needs_approval = False
@@ -1353,15 +1390,15 @@ async def test_tool_result_conversion_to_string(self, mock_model, mock_agent):
13531390
async def test_mixed_tool_types_filtering(self, mock_model, mock_agent):
13541391
"""Test that function tools and handoffs are properly separated"""
13551392
# Create mixed tools
1356-
func_tool1 = Mock(spec=FunctionTool)
1393+
func_tool1 = _set_default_timeout_fields(Mock(spec=FunctionTool))
13571394
func_tool1.name = "func1"
13581395
func_tool1.on_invoke_tool = AsyncMock(return_value="result1")
13591396
func_tool1.needs_approval = False
13601397

13611398
handoff1 = Mock(spec=Handoff)
13621399
handoff1.name = "handoff1"
13631400

1364-
func_tool2 = Mock(spec=FunctionTool)
1401+
func_tool2 = _set_default_timeout_fields(Mock(spec=FunctionTool))
13651402
func_tool2.name = "func2"
13661403
func_tool2.on_invoke_tool = AsyncMock(return_value="result2")
13671404
func_tool2.needs_approval = False

0 commit comments

Comments
 (0)