Skip to content

Low Prio Bug: _call_tool_in_thread_pool double-executes sync FunctionTools that return None #5284

@ruidazeng

Description

@ruidazeng

In _call_tool_in_thread_pool() (src/google/adk/flows/llm_flows/functions.py, lines 148-183), None is used as a sentinel to distinguish "FunctionTool ran successfully in thread pool" from "non-FunctionTool sync tool, needs async fallback" (e.g. SetModelResponseTool). But None is also a valid return value, and I believe that any side-effect-only tool with no explicit return statement implicitly returns None.

When a sync FunctionTool's underlying function returns None, the sentinel check on line 168 (if result is not None) fails, and execution falls through to line 183 (tool.run_async()), which re-runs the full run_async pipeline and invokes the underlying function a second time silently.

Note: this only triggers when RunConfig.tool_thread_pool_config is enabled (defaults to None/off).

Steps to Reproduce:

  1. Create a sync FunctionTool whose function returns None (or has no return statement)
  2. Configure RunConfig with tool_thread_pool_config=ToolThreadPoolConfig()
  3. Run the agent and trigger a tool call
  4. Observe the tool function executes twice

Expected Behavior:
The tool function should execute exactly once. A None return value should be treated as a valid result.

Observed Behavior:
The tool function executes twice:

  • First via tool.func(**args_to_call) on line 160 (inside run_sync_tool, in the thread pool)
  • Second via tool.run_async() on line 183 (the fallback path for non-FunctionTool sync tools), which calls _invoke_callable(self.func, ...) internally

The root cause is on lines 160-168. run_sync_tool() returns tool.func(**args) for FunctionTools (line 160) and None for non-FunctionTools (line 163). The check if result is not None on line 168 cannot distinguish between the two cases when a FunctionTool's function legitimately returns None:

def run_sync_tool():
    if isinstance(tool, FunctionTool):
        # ... preprocessing ...
        return tool.func(**args_to_call)  # line 160: could return None
    else:
        return None  # line 163: sentinel for "didn't run"

Possible fix: use a dedicated sentinel so None is no longer ambiguous.

Metadata

Metadata

Assignees

Labels

core[Component] This issue is related to the core interface and implementation

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions