Skip to content

fix(tools): run async tools that are wrapped by sync decorators - #7377

Open
techi101 wants to merge 2 commits into
google:mainfrom
techi101:fix/await-sync-wrapped-async-tools
Open

techi101 wants to merge 2 commits into
google:mainfrom
techi101:fix/await-sync-wrapped-async-tools

Conversation

@techi101

@techi101 techi101 commented Oct 2, 2026

Copy link
Copy Markdown

Link to Issue or Description of Change

2. Or, if no issue exists, describe the change:

Problem:

FunctionTool._invoke_callable decides whether to await a tool with inspect.iscoroutinefunction(). That check does not see through a plain sync decorator, which is a common pattern for logging, retries or rate limiting:

def logged(fn):
  @functools.wraps(fn)
  def wrapper(*args, **kwargs):
    return fn(*args, **kwargs)
  return wrapper

@logged
async def get_price(item: str) -> dict:
  return {"item": item, "price": 3}

The declaration is still built correctly (inspect.signature follows __wrapped__), so the model calls the tool normally. But the sync branch returns the coroutine without awaiting it, so the tool body never runs and the model receives:

{'result': <coroutine object get_price at 0x...>}

Solution:

On the sync path (both the direct call and the bound sync-callable runner), await the result if it is awaitable. This matches how ADK already treats user callables elsewhere, e.g. before/after tool callbacks in utils/_callback_pipeline.py. Regular sync and async tools are unaffected.

Related: #7012 handles an awaitable returned by a require_confirmation predicate by failing closed. With this change _invoke_callable awaits such a result, so a sync wrapper around an async predicate returns its real bool.

Testing Plan

Unit Tests:

  • I have added or updated unit tests for my change.
  • All unit tests pass locally.

Added to tests/unittests/tools/test_function_tool.py:

  • test_run_async_awaits_async_function_behind_sync_wrapper
  • test_run_async_awaits_async_function_behind_sync_wrapper_with_runner (same case with a thread-pool sync callable runner bound)

Both fail on main with assert <coroutine object ...> == {'item': 'apple', 'price': 3} and pass with this change.

pytest tests/unittests/tools/test_function_tool.py
61 passed

pytest tests/unittests/tools tests/unittests/flows tests/unittests/agents tests/unittests/workflow tests/unittests/test_runners.py
5625 passed, 2 skipped, 7 xfailed, 3 failed

The 3 failures are tests/unittests/tools/test_skill_toolset.py::test_integration_shell_*, which also fail on main on my machine (Windows, no /bin/bash), so they are unrelated.

Manual End-to-End (E2E) Tests:

The agent above, run through InMemoryRunner with a mock model that calls get_price(item="apple"):

mock = testing_utils.MockModel.create(responses=[
    [types.Part.from_function_call(name="get_price", args={"item": "apple"})],
    "done",
])
runner = testing_utils.InMemoryRunner(LlmAgent(name="root", model=mock, tools=[get_price]))
for event in runner.run("price of an apple?"):
  ...  # print function_response parts

Before (main):

function_response: {'result': <coroutine object get_price at 0x00000200178D96C0>}

After:

function_response: {'item': 'apple', 'price': 3}

Checklist

  • I have read the CONTRIBUTING.md document.
  • I have performed a self-review of my own code.
  • I have commented my code, particularly in hard-to-understand areas.
  • I have added tests that prove my fix is effective or that my feature works.
  • New and existing unit tests pass locally with my changes.
  • I have manually tested my changes end-to-end.
  • Any dependent changes have been merged and published in downstream modules.

FunctionTool only awaits a callable when inspect.iscoroutinefunction() is
true, and that check does not see through a plain sync decorator such as
`@functools.wraps(fn) def wrapper(*a, **k): return fn(*a, **k)`. The sync
branch then returned the coroutine unawaited, so the tool body never ran
and the model received `{'result': <coroutine object ...>}`.

Await the result of the sync path when it is awaitable, matching how
before/after tool callbacks already handle user callables.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants