Skip to content

Commit f29bae8

Browse files
authored
Python: run sync tools off the event loop (#5773)
* fix: run sync tools off event loop * chore: silence harness tool marker type check
1 parent c3901a4 commit f29bae8

3 files changed

Lines changed: 59 additions & 4 deletions

File tree

python/packages/core/agent_framework/_harness/_background_agents.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,8 @@ def background_agents_start_task(agent_name: str, input: str, description: str)
349349
_save_provider_state(session, provider_state, source_id=source_id)
350350
return f"Background task {task_id} started on agent '{agent_name}'."
351351

352+
background_agents_start_task._invoke_sync_on_event_loop = True # pyright: ignore[reportPrivateUsage]
353+
352354
@tool(name="background_agents_wait_for_first_completion", approval_mode="never_require")
353355
async def background_agents_wait_for_first_completion(task_ids: list[int]) -> str:
354356
"""Block until the first of the specified background tasks completes. Returns the completed task's ID."""
@@ -471,6 +473,8 @@ def background_agents_continue_task(task_id: int, text: str) -> str:
471473
_save_provider_state(session, provider_state, source_id=source_id)
472474
return f"Task {task_id} continued with new input."
473475

476+
background_agents_continue_task._invoke_sync_on_event_loop = True # pyright: ignore[reportPrivateUsage]
477+
474478
@tool(name="background_agents_clear_completed_task", approval_mode="never_require")
475479
def background_agents_clear_completed_task(task_id: int) -> str:
476480
"""Remove a completed or failed task and release its session to free memory."""

python/packages/core/agent_framework/_tools.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,7 @@ class WeatherArgs(BaseModel):
292292
"_cached_parameters",
293293
"_input_schema",
294294
"_schema_supplied",
295+
"_invoke_sync_on_event_loop",
295296
}
296297

297298
def __init__(
@@ -366,6 +367,7 @@ def __init__(
366367
self.description = description
367368
self.kind = kind
368369
self.additional_properties = additional_properties
370+
self._invoke_sync_on_event_loop = False
369371
for key, value in kwargs.items():
370372
setattr(self, key, value)
371373

@@ -537,6 +539,16 @@ def __call__(self, *args: Any, **kwargs: Any) -> Any:
537539
self.invocation_exception_count += 1
538540
raise
539541

542+
async def _invoke_function(self, call_kwargs: Mapping[str, Any]) -> Any:
543+
"""Run sync tools off the event loop during async invocation."""
544+
func = self.func.func if isinstance(self.func, FunctionTool) else self.func
545+
if inspect.iscoroutinefunction(func) or getattr(self, "_invoke_sync_on_event_loop", False):
546+
res = self.__call__(**call_kwargs)
547+
return await res if inspect.isawaitable(res) else res
548+
549+
res = await asyncio.to_thread(self.__call__, **call_kwargs)
550+
return await res if inspect.isawaitable(res) else res
551+
540552
@overload
541553
async def invoke(
542554
self,
@@ -679,8 +691,7 @@ async def invoke(
679691
if not OBSERVABILITY_SETTINGS.ENABLED: # type: ignore[name-defined]
680692
logger.info(f"Function name: {self.name}")
681693
logger.debug(f"Function arguments: {observable_kwargs}")
682-
res = self.__call__(**call_kwargs)
683-
result = await res if inspect.isawaitable(res) else res
694+
result = await self._invoke_function(call_kwargs)
684695
if skip_parsing:
685696
logger.info(f"Function {self.name} succeeded.")
686697
logger.debug(f"Function result: {type(result).__name__}")
@@ -730,8 +741,7 @@ async def invoke(
730741
start_time_stamp = perf_counter()
731742
end_time_stamp: float | None = None
732743
try:
733-
res = self.__call__(**call_kwargs)
734-
result = await res if inspect.isawaitable(res) else res
744+
result = await self._invoke_function(call_kwargs)
735745
end_time_stamp = perf_counter()
736746
except Exception as exception:
737747
end_time_stamp = perf_counter()

python/packages/core/tests/core/test_tools.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
# Copyright (c) Microsoft. All rights reserved.
2+
import asyncio
3+
import threading
24
from typing import Annotated, Any, Literal, get_args, get_origin
35
from unittest.mock import Mock
46

@@ -1346,6 +1348,45 @@ async def slow(x: int) -> int:
13461348
assert raw == 42
13471349

13481350

1351+
async def test_invoke_sync_tool_does_not_block_event_loop() -> None:
1352+
release_tool = threading.Event()
1353+
tool_thread_ids: list[int] = []
1354+
event_loop_thread_id = threading.get_ident()
1355+
1356+
@tool
1357+
def wait_for_release() -> str:
1358+
tool_thread_ids.append(threading.get_ident())
1359+
return "released" if release_tool.wait(timeout=0.2) else "timed out"
1360+
1361+
async def release_soon() -> None:
1362+
await asyncio.sleep(0.01)
1363+
release_tool.set()
1364+
1365+
tool_task = asyncio.create_task(wait_for_release.invoke(skip_parsing=True))
1366+
release_task = asyncio.create_task(release_soon())
1367+
1368+
assert await asyncio.wait_for(tool_task, timeout=1) == "released"
1369+
await release_task
1370+
assert tool_thread_ids
1371+
assert tool_thread_ids[0] != event_loop_thread_id
1372+
1373+
1374+
async def test_invoke_sync_tool_can_stay_on_event_loop() -> None:
1375+
event_loop_thread_id = threading.get_ident()
1376+
tool_thread_ids: list[int] = []
1377+
1378+
@tool
1379+
def needs_event_loop() -> str:
1380+
tool_thread_ids.append(threading.get_ident())
1381+
asyncio.get_running_loop()
1382+
return "ok"
1383+
1384+
needs_event_loop._invoke_sync_on_event_loop = True
1385+
1386+
assert await needs_event_loop.invoke(skip_parsing=True) == "ok"
1387+
assert tool_thread_ids == [event_loop_thread_id]
1388+
1389+
13491390
async def test_invoke_skip_parsing_bypasses_configured_result_parser() -> None:
13501391
"""The tool's own result_parser is bypassed when skip_parsing=True is requested."""
13511392
parser_calls: list[Any] = []

0 commit comments

Comments
 (0)