Skip to content

FunctionTool fails to invoke callables with positional-only arguments聽#7382

Closed

Description

@Moashehri

馃敶 Required Information

Describe the Bug:
FunctionTool raises a TypeError when invoking callables that have positional-only arguments (either via Python's / syntax such as def add(x: int, /, y: int) or C-builtins such as math.pow).

While FunctionTool correctly parses and exposes positional-only parameters by name in its generated function declaration schema (_function_tool_declarations.py), runtime execution in FunctionTool.run_async forwards model-generated arguments exclusively via keyword unpacking (**args_to_call). Python raises a TypeError because positional-only arguments cannot be passed by keyword:

  • For Python callables: TypeError: add() got some positional-only arguments passed as keyword arguments: 'x'
  • For built-ins: TypeError: math.pow() takes no keyword arguments

Because FunctionTool._invoke_callable only catches invocation TypeError when not self._spec.has_signature, this unhandled exception terminates the entire agent run.

Steps to Reproduce:

  1. Wrap a callable containing positional-only parameters with FunctionTool:
    import asyncio, math
    from unittest.mock import MagicMock
    from google.adk.tools import FunctionTool
    
    def add_pos_only(x: int, /, y: int) -> int:
        return x + y
    
    async def main():
        tool = FunctionTool(add_pos_only)
        # Simulated tool call with arguments matching declared schema:
        await tool.run_async(args={"x": 10, "y": 20}, tool_context=MagicMock())
    
    asyncio.run(main())
  2. Run the script.
  3. Observe the crash: TypeError: add_pos_only() got some positional-only arguments passed as keyword arguments: 'x'.
  4. Similarly, FunctionTool(math.pow).run_async(args={"x": 2.0, "y": 3.0}, tool_context=MagicMock()) raises TypeError: math.pow() takes no keyword arguments.

Expected Behavior:
Model-generated arguments matching declared positional-only parameter names should be bound to positional arguments at invocation time, allowing callables using / syntax and built-in functions to execute successfully.

Observed Behavior:
Python raises:

TypeError: add_pos_only() got some positional-only arguments passed as keyword arguments: 'x'

or

TypeError: math.pow() takes no keyword arguments

Environment Details:

  • ADK Library Version: 2.11.0 (tested on main at commit 539a0d071c4fe6b8bd3ae9bda3a53e14ea80aeb8)
  • Desktop OS: macOS / Linux
  • Python Version: 3.10, 3.11, 3.12, 3.13, 3.14

Model Information:

  • Are you using LiteLLM: N/A (occurs during tool invocation in FunctionTool.run_async)
  • Which model is being used: N/A (reproducible with mock/in-memory runner and any LLM)

馃煛 Optional Information

Regression:
No, positional-only parameter support in declaration schema generation was added in commit 51a638b6b, but runtime argument binding was never updated to map declared positional-only parameters positionally.

Minimal Reproduction Code:

import asyncio
from unittest.mock import MagicMock
from google.adk.tools import FunctionTool

def add(x: int, /, y: int) -> int:
    return x + y

async def main():
    tool = FunctionTool(add)
    result = await tool.run_async(args={"x": 1, "y": 2}, tool_context=MagicMock())
    print("Result:", result)

asyncio.run(main())

How often has this issue occurred?:

  • Always (100%)

Additional Context:
We have an already-tested minimal fix and regression suite (covering sync callables, async callables, built-ins, default values, callable class instances, thread-pool runner execution, injected tool context, and confirmation predicates) validated across py310 through py314 and all 14 pre-commit hooks. We would be happy to submit a pull request for this if maintainers agree.

Activity

  1. Big-Marvel commented on Oct 3, 2026

    @Big-Marvel

    Hi, is this issue still open for contributions? If so, I would love to work on this
    @GWeale

  2. Moashehri commented on Oct 3, 2026

    @Moashehri
    Author

    Hi, is this issue still open for contributions? If so, I would love to work on this @GWeale

    Thanks for the interest! I already have an open PR for this issue: #7383. The fix is implemented and tested; the PR just needs to be updated against the latest main because of recent conflicts. Happy to coordinate if the maintainers prefer a different approach.

  3. added theissue type on Oct 5, 2026
  4. surajksharma07 commented on Oct 5, 2026

    @surajksharma07
    Collaborator

    Confirmed on 2.11.0 and main @Moashehri. Both add(x, /, y) and math.pow raise the TypeError and it ends the whole run. Seeing the same thing back to 1.19 so it's been there a while, not a regression.

    Until #7383 lands a workaround worth trying: register a thin wrapper that takes the args as keywords and passes the positional-only ones positionally, e.g. def power(x: float, y: float): return math.pow(x, y). The tool schema stays the same so the model side doesn't change. Could you check whether that works for your real tools?

    For the PR: generator tools on main now run through FunctionNode (workflow/_function_node.py which also calls func(**kwargs)) so def count(n, /): yield ... still fails there. Worth covering that path too with a test and rebasing before review. @Big-Marvel this one is already covered by #7383.

  5. Moashehri commented on Oct 6, 2026

    @Moashehri
    Author

    Thanks for confirming and for catching the FunctionNode path. I鈥檒l update #7383 against latest main, add coverage for generator tools, and make it ready for review. @surajksharma07

  6. surajksharma07 commented on Oct 6, 2026

    @surajksharma07
    Collaborator

    @Moashehri Ran the current #7383 (e124a06) locally: add, math.pow and the defaults case all work and a missing positional-only arg falls back to the normal "mandatory parameters" error so that part looks solid.

    For the rebase: main now conflicts in function_tool.py and the test file. After 0ee891a/71d5ed773 generator tools go through _get_generator_node() which builds FunctionNode(func=self.func, ...) so your wrapper never gets used there. Passing self._compatible_func there might be enough since functools.wraps keeps the generator detection and the signature but please verify that on your side.

    Could you add a test for def count(n: int, /): yield ... through a Runner not only run_async and run the thread pool tests again since 71d5ed7 now sends sync FunctionNode calls through the same runner? Once that passes will ping @xuanyang15 for review.

  7. Moashehri commented on Oct 6, 2026

    @Moashehri
    Author

    @surajksharma07
    Updated #7383: rebased on latest main, added Runner coverage for sync/async generator tools with positional-only args, reran FunctionNode/thread-pool/tools tests, and everything is green. Ready for review.

  8. surajksharma07 commented on Oct 7, 2026

    @surajksharma07
    Collaborator

    Went through 30201bd @Moashehri. Passing _compatible_func into FunctionNode is the right fix and sync_gen_wrapper/async_gen_wrapper keep generator detection working. The Runner tests for count(n, /), the async version and the default case cover the path I was worried about. Live streaming tools also go through run_async -> _invoke_callable so they get the wrapper too.

    One small non-blocking thing: _compatible_func is built once in init but _invoke_callable swaps it in whenever target is self.func and the generator cache key now uses _compatible_func instead of self.func. If anything reassigns tool.func after construction the old function would still run. Building the wrapper lazily from self.func or keeping self.func in the cache key would avoid that.

    Nothing else from my side. @xuanyang15 this is ready for your review and the 2 workflows on the PR still need maintainer approval to run.

  9. Moashehri commented on Oct 17, 2026

    @Moashehri
    Author

    Went through 30201bd @Moashehri. Passing _compatible_func into FunctionNode is the right fix and sync_gen_wrapper/async_gen_wrapper keep generator detection working. The Runner tests for count(n, /), the async version and the default case cover the path I was worried about. Live streaming tools also go through run_async -> _invoke_callable so they get the wrapper too.

    One small non-blocking thing: _compatible_func is built once in init but _invoke_callable swaps it in whenever target is self.func and the generator cache key now uses _compatible_func instead of self.func. If anything reassigns tool.func after construction the old function would still run. Building the wrapper lazily from self.func or keeping self.func in the cache key would avoid that.

    Nothing else from my side. @xuanyang15 this is ready for your review and the 2 workflows on the PR still need maintainer approval to run.

    Any updates?

  10. added a commit that references this issue on Oct 20, 2026
    12726f7
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

tools[Component] This issue is related to tools

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions