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:
- Create a sync
FunctionTool whose function returns None (or has no return statement)
- Configure
RunConfig with tool_thread_pool_config=ToolThreadPoolConfig()
- Run the agent and trigger a tool call
- 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.
In
_call_tool_in_thread_pool()(src/google/adk/flows/llm_flows/functions.py, lines 148-183),Noneis used as a sentinel to distinguish "FunctionTool ran successfully in thread pool" from "non-FunctionTool sync tool, needs async fallback" (e.g.SetModelResponseTool). ButNoneis also a valid return value, and I believe that any side-effect-only tool with no explicit return statement implicitly returnsNone.When a sync
FunctionTool's underlying function returnsNone, 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 fullrun_asyncpipeline and invokes the underlying function a second time silently.Note: this only triggers when
RunConfig.tool_thread_pool_configis enabled (defaults toNone/off).Steps to Reproduce:
FunctionToolwhose function returnsNone(or has no return statement)RunConfigwithtool_thread_pool_config=ToolThreadPoolConfig()Expected Behavior:
The tool function should execute exactly once. A
Nonereturn value should be treated as a valid result.Observed Behavior:
The tool function executes twice:
tool.func(**args_to_call)on line 160 (insiderun_sync_tool, in the thread pool)tool.run_async()on line 183 (the fallback path for non-FunctionTool sync tools), which calls_invoke_callable(self.func, ...)internallyThe root cause is on lines 160-168.
run_sync_tool()returnstool.func(**args)for FunctionTools (line 160) andNonefor non-FunctionTools (line 163). The checkif result is not Noneon line 168 cannot distinguish between the two cases when a FunctionTool's function legitimately returnsNone:Possible fix: use a dedicated sentinel so
Noneis no longer ambiguous.