Skip to content

Commit e805ffa

Browse files
committed
Add timeout for tool calls
Signed-off-by: tamohannes <hovhannes.tamoyan@gmail.com>
1 parent b620e79 commit e805ffa

4 files changed

Lines changed: 57 additions & 1 deletion

File tree

nemo_skills/inference/generate.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,7 @@ class GenerationTaskConfig:
202202
schema_overrides: dict | None = field(default_factory=dict)
203203

204204
max_tool_calls: int = -1 # If >= 0, will limit the number of tool calls executed during generation to this number
205+
tool_call_timeout_s: float | None = 300.0 # Wall-clock timeout for each individual tool call
205206

206207
# if True, will move full generation to _full_generation key and keep cfg.generation_key without thinking tokens
207208
# IMPORTANT: do not set this for non-reasoning models as it will make the generations empty!
@@ -482,6 +483,7 @@ def setup_llm(self):
482483
tool_overrides=self.cfg.tool_overrides,
483484
schema_overrides=self.cfg.schema_overrides,
484485
max_tool_calls=self.cfg.max_tool_calls,
486+
tool_call_timeout_s=self.cfg.tool_call_timeout_s,
485487
tokenizer=self.tokenizer,
486488
require_tokenizer=self.cfg.inference.tokens_to_generate is not None,
487489
additional_config={"sandbox": self.cfg.sandbox},

nemo_skills/inference/model/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ def get_tool_calling_model(
136136
tool_overrides: dict | None = None,
137137
schema_overrides: dict | None = None,
138138
max_tool_calls: int = -1,
139+
tool_call_timeout_s: float | None = 300.0,
139140
**kwargs,
140141
):
141142
if isinstance(model, str):
@@ -147,6 +148,7 @@ def get_tool_calling_model(
147148
additional_config=additional_config,
148149
schema_overrides=schema_overrides,
149150
max_tool_calls=max_tool_calls,
151+
tool_call_timeout_s=tool_call_timeout_s,
150152
)
151153

152154

nemo_skills/inference/model/tool_call.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
import asyncio
1516
import copy
1617
import json
1718
import logging
@@ -49,6 +50,7 @@ def __init__(
4950
additional_config: dict | None = None,
5051
schema_overrides: dict | None = None,
5152
max_tool_calls: int = -1,
53+
tool_call_timeout_s: float | None = 300.0,
5254
):
5355
self.model = model
5456
additional_config = additional_config or {}
@@ -63,6 +65,7 @@ def __init__(
6365
self.schema_overrides = load_schema_overrides(schema_overrides)
6466
self.schema_mappings = {} # Built when tools are listed
6567
self.max_tool_calls = max_tool_calls
68+
self.tool_call_timeout_s = tool_call_timeout_s
6669

6770
async def _execute_tool_call(self, tool_call, request_id: str, endpoint_type: EndpointType):
6871
## TODO(sanyamk): The correct key format needs to be cohesive with other formatters.
@@ -85,9 +88,16 @@ async def _execute_tool_call(self, tool_call, request_id: str, endpoint_type: En
8588

8689
try:
8790
# Allow providers to specify extra_args behavior internally if needed in the future
88-
result = await self.tool_manager.execute_tool(
91+
tool_future = self.tool_manager.execute_tool(
8992
original_tool_name, tool_args, extra_args={"request_id": request_id}
9093
)
94+
if self.tool_call_timeout_s is None:
95+
result = await tool_future
96+
else:
97+
result = await asyncio.wait_for(tool_future, timeout=self.tool_call_timeout_s)
98+
except asyncio.TimeoutError:
99+
LOG.error("Tool execution timed out after %s seconds: %s", self.tool_call_timeout_s, original_tool_name)
100+
return {"error": f"Tool execution timed out after {self.tool_call_timeout_s} seconds."}
91101
except FatalToolError:
92102
# Fatal errors should propagate up and stop the process
93103
raise

tests/test_mcp_clients.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,14 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
import asyncio
1516
import types
1617

1718
import pytest
1819

1920
# Dummy client to exercise MCPClientMeta behavior without real I/O
21+
from nemo_skills.inference.model.base import EndpointType
22+
from nemo_skills.inference.model.tool_call import ToolCallingWrapper
2023
from nemo_skills.mcp.clients import MCPClient, MCPStdioClient, MCPStreamableHttpClient
2124
from nemo_skills.mcp.tool_manager import Tool, ToolManager
2225

@@ -219,6 +222,27 @@ async def execute(self, tool_name: str, arguments: dict, extra_args: dict | None
219222
return {"unknown": tool_name, "args": arguments}
220223

221224

225+
class SlowTool(Tool):
226+
def default_config(self):
227+
return {}
228+
229+
def configure(self, overrides=None, context=None):
230+
return None
231+
232+
async def list_tools(self):
233+
return [
234+
{
235+
"name": "sleep",
236+
"description": "Sleep long enough to exercise timeout handling",
237+
"input_schema": {"type": "object", "properties": {}},
238+
}
239+
]
240+
241+
async def execute(self, tool_name: str, arguments: dict, extra_args: dict | None = None):
242+
await asyncio.sleep(10)
243+
return "too late"
244+
245+
222246
# Helper class for test_tool_manager_cache_and_duplicate_detection
223247
# Defined at module level so it can be imported via locate()
224248
class CountingTool(DummyTool):
@@ -255,6 +279,24 @@ async def test_tool_manager_list_and_execute_with_class_locator():
255279
assert result == {"ran": True, "code": "x=1"}
256280

257281

282+
@pytest.mark.asyncio
283+
async def test_tool_calling_wrapper_times_out_slow_tool_call():
284+
wrapper = ToolCallingWrapper(
285+
model=object(),
286+
tool_modules=[f"{__name__}::SlowTool"],
287+
tool_call_timeout_s=0.01,
288+
)
289+
await wrapper.tool_manager.list_all_tools(use_cache=False)
290+
291+
result = await wrapper._execute_tool_call(
292+
{"id": "call-1", "function": {"name": "sleep", "arguments": "{}"}},
293+
request_id="req-1",
294+
endpoint_type=EndpointType.chat,
295+
)
296+
297+
assert result["error"].startswith("Tool execution timed out")
298+
299+
258300
@pytest.mark.asyncio
259301
async def test_tool_manager_cache_and_duplicate_detection():
260302
import sys

0 commit comments

Comments
 (0)