diff --git a/src/anthropic/lib/tools/_tool_dispatch.py b/src/anthropic/lib/tools/_tool_dispatch.py index 80517eab2..fc45ca868 100644 --- a/src/anthropic/lib/tools/_tool_dispatch.py +++ b/src/anthropic/lib/tools/_tool_dispatch.py @@ -15,6 +15,8 @@ from typing import Union, TypeVar, Iterable, Awaitable from typing_extensions import Protocol +from anyio.to_thread import run_sync + from ._beta_functions import ToolError, BetaFunctionToolResultType from ...types.beta.beta_message_param import BetaMessageParam from ...types.beta.beta_content_block_param import BetaContentBlockParam @@ -132,13 +134,18 @@ def tool_error_content(exc: BaseException) -> BetaFunctionToolResultType: async def run_runnable_tool(tool: _CallableTool, input: dict[str, object]) -> BetaFunctionToolResultType: - """Call ``tool`` with ``input``, awaiting the result if the tool is async. + """Call ``tool`` without letting synchronous work block the event loop. - Bridges the sync (:class:`~anthropic.lib.tools.BetaFunctionTool`) and async - (:class:`~anthropic.lib.tools.BetaAsyncFunctionTool`) runnable-tool shapes - behind a single ``await``. + The sessions runner accepts both sync and async runnable tools. Native async + ``call`` methods execute on the event loop as usual; synchronous methods run + in AnyIO's worker thread. A synchronous wrapper that returns an awaitable is + still supported — the wrapper runs off-loop and its result is then awaited. """ - result = tool.call(input) + if inspect.iscoroutinefunction(tool.call): + result = tool.call(input) + else: + result = await run_sync(tool.call, input) + if inspect.isawaitable(result): return await result return result diff --git a/tests/lib/tools/test_tool_dispatch_async_execution.py b/tests/lib/tools/test_tool_dispatch_async_execution.py new file mode 100644 index 000000000..d79daf77a --- /dev/null +++ b/tests/lib/tools/test_tool_dispatch_async_execution.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +import threading +from typing import Any, cast + +import pytest + +from anthropic.lib.tools._tool_dispatch import run_runnable_tool + + +class _SyncTool: + def __init__(self) -> None: + self.call_thread: int | None = None + + def call(self, input: object) -> str: + assert input == {"value": 1} + self.call_thread = threading.get_ident() + return "sync-result" + + +class _AsyncTool: + def __init__(self) -> None: + self.call_thread: int | None = None + + async def call(self, input: object) -> str: + assert input == {"value": 2} + self.call_thread = threading.get_ident() + return "async-result" + + +class _SyncAwaitableTool: + def __init__(self) -> None: + self.call_thread: int | None = None + self.await_thread: int | None = None + + def call(self, input: object) -> Any: + assert input == {"value": 3} + self.call_thread = threading.get_ident() + + async def finish() -> str: + self.await_thread = threading.get_ident() + return "awaitable-result" + + return finish() + + +@pytest.mark.asyncio +async def test_sync_tool_runs_in_worker_thread() -> None: + event_loop_thread = threading.get_ident() + tool = _SyncTool() + + result = await run_runnable_tool(cast(Any, tool), {"value": 1}) + + assert result == "sync-result" + assert tool.call_thread is not None + assert tool.call_thread != event_loop_thread + + +@pytest.mark.asyncio +async def test_native_async_tool_stays_on_event_loop_thread() -> None: + event_loop_thread = threading.get_ident() + tool = _AsyncTool() + + result = await run_runnable_tool(cast(Any, tool), {"value": 2}) + + assert result == "async-result" + assert tool.call_thread == event_loop_thread + + +@pytest.mark.asyncio +async def test_sync_wrapper_returning_awaitable_is_still_supported() -> None: + event_loop_thread = threading.get_ident() + tool = _SyncAwaitableTool() + + result = await run_runnable_tool(cast(Any, tool), {"value": 3}) + + assert result == "awaitable-result" + assert tool.call_thread is not None + assert tool.call_thread != event_loop_thread + assert tool.await_thread == event_loop_thread