fix(flows): prevent double-execution of FunctionTools returning None in thread pool#5286
Conversation
|
Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA). View this failed invocation of the CLA check for more information. For the most up to date status, view the checks section at the bottom of the pull request. |
|
Response from ADK Triaging Agent Hi @Koushik-Salammagari, thank you for your contribution! It looks like the Contributor License Agreement (CLA) check has failed. Please sign the CLA at cla.developers.google.com so we can proceed with the review. Thanks! |
|
@googlebot I signed it! |
…in thread pool When RunConfig.tool_thread_pool_config is enabled, _call_tool_in_thread_pool used None as a sentinel to distinguish "FunctionTool ran in thread pool" from "non-FunctionTool sync tool, needs async fallback". Because None is also a valid return value from any FunctionTool whose underlying function has no explicit return statement (implicit None), the sentinel check failed and execution fell through to tool.run_async(), invoking the function a second time silently. Replace the None sentinel with a dedicated _SYNC_TOOL_RESULT_UNSET object so that a legitimate None result from a FunctionTool is correctly returned on the first execution, without triggering the async fallback path. Fixes google#5284
e170ce6 to
869b1e7
Compare
| ) | ||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_sync_tool_returning_explicit_none_executes_exactly_once(self): |
There was a problem hiding this comment.
The two tests are nearly identical. Can we collapse them into one test or use @pytest.mark.parametrize? I don't have a strong opinion on this.
There was a problem hiding this comment.
Done — collapsed both tests into a single @pytest.mark.parametrize test covering 6 cases: implicit None, explicit None, 0, "", {}, and False. All 30 tests pass.
| assert call_count == 1, ( | ||
| f'Tool function executed {call_count} time(s); expected exactly 1.' | ||
| ) | ||
|
|
There was a problem hiding this comment.
Consider adding a falsy-but-not-None test. The bug was specifically about None. A test like this would prove the sentinel doesn't break tools returning 0, "", {}, or False.
There was a problem hiding this comment.
Added falsy-but-not-None cases (0, "", {}, False) as part of the parametrized test. These confirm the sentinel is identity-based (is not _SYNC_TOOL_RESULT_UNSET) and correctly treats all falsy return values as valid results without triggering a second execution.
…ases
Per reviewer feedback: collapse the two near-identical None tests into a
single @pytest.mark.parametrize test, and add falsy-but-not-None cases
(0, '', {}, False) to prove the sentinel is identity-based and does not
mishandle any falsy return value from a FunctionTool.
Link to Issue or Description of Change
Closes #5284
Description
When
RunConfig.tool_thread_pool_configis enabled,_call_tool_in_thread_poolusedNoneas a sentinel to distinguish two cases insiderun_sync_tool():SetModelResponseTool) → can't run in thread pool, fall back totool.run_async()The problem:
Noneis also a valid return value from anyFunctionToolwhose underlying function has no explicitreturnstatement (implicitNone). Any side-effect-only tool (e.g. writing to state, logging, sending a notification) falls into this category.Before this fix:
After this fix:
Changes
src/google/adk/flows/llm_flows/functions.py: introduce_SYNC_TOOL_RESULT_UNSETsentinel; updaterun_sync_tooland the guard check.tests/unittests/flows/llm_flows/test_functions_thread_pool.py: consolidate regression tests into a single@pytest.mark.parametrizetest covering 6 return values.Testing Plan
test_sync_tool_executes_exactly_once— parametrized overNone(implicit),None(explicit),0,"",{}, andFalse; verifies each executes exactly once and the sentinel is never confused with a valid return value.RunConfig.tool_thread_pool_configis set (non-default); zero impact on the default execution path.