Skip to content

fix(flows): prevent double-execution of FunctionTools returning None in thread pool#5286

Open
Koushik-Salammagari wants to merge 2 commits intogoogle:mainfrom
Koushik-Salammagari:fix/thread-pool-none-sentinel
Open

fix(flows): prevent double-execution of FunctionTools returning None in thread pool#5286
Koushik-Salammagari wants to merge 2 commits intogoogle:mainfrom
Koushik-Salammagari:fix/thread-pool-none-sentinel

Conversation

@Koushik-Salammagari
Copy link
Copy Markdown

@Koushik-Salammagari Koushik-Salammagari commented Apr 11, 2026

Link to Issue or Description of Change

Closes #5284

Description

When RunConfig.tool_thread_pool_config is enabled, _call_tool_in_thread_pool used None as a sentinel to distinguish two cases inside run_sync_tool():

  1. FunctionTool ran successfully in thread pool → return the result
  2. Non-FunctionTool sync tool (e.g. SetModelResponseTool) → can't run in thread pool, fall back to tool.run_async()

The problem: None is also a valid return value from any FunctionTool whose underlying function has no explicit return statement (implicit None). Any side-effect-only tool (e.g. writing to state, logging, sending a notification) falls into this category.

Before this fix:

result = await loop.run_in_executor(executor, lambda: ctx.run(run_sync_tool))
if result is not None:   # ← False when FunctionTool returns None
    return result
# Falls through to:
return await tool.run_async(args=args, tool_context=tool_context)  # ← second execution!

After this fix:

# Module-level sentinel — can never be confused with a legitimate tool return value
_SYNC_TOOL_RESULT_UNSET = object()

# Inside run_sync_tool():
#   FunctionTool path  → return tool.func(**args)   (may be None)
#   Non-FunctionTool   → return _SYNC_TOOL_RESULT_UNSET

result = await loop.run_in_executor(executor, lambda: ctx.run(run_sync_tool))
if result is not _SYNC_TOOL_RESULT_UNSET:   # ← True even when result is None
    return result

Changes

  • src/google/adk/flows/llm_flows/functions.py: introduce _SYNC_TOOL_RESULT_UNSET sentinel; update run_sync_tool and the guard check.
  • tests/unittests/flows/llm_flows/test_functions_thread_pool.py: consolidate regression tests into a single @pytest.mark.parametrize test covering 6 return values.

Testing Plan

  • test_sync_tool_executes_exactly_once — parametrized over None (implicit), None (explicit), 0, "", {}, and False; verifies each executes exactly once and the sentinel is never confused with a valid return value.
  • All 30 thread pool tests pass.
  • Only triggered when RunConfig.tool_thread_pool_config is set (non-default); zero impact on the default execution path.

@google-cla
Copy link
Copy Markdown

google-cla bot commented Apr 11, 2026

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.

@adk-bot
Copy link
Copy Markdown
Collaborator

adk-bot commented Apr 11, 2026

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!

@Koushik-Salammagari
Copy link
Copy Markdown
Author

@googlebot I signed it!

2 similar comments
@Koushik-Salammagari
Copy link
Copy Markdown
Author

@googlebot I signed it!

@Koushik-Salammagari
Copy link
Copy Markdown
Author

@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
@Koushik-Salammagari Koushik-Salammagari force-pushed the fix/thread-pool-none-sentinel branch from e170ce6 to 869b1e7 Compare April 11, 2026 21:23
)

@pytest.mark.asyncio
async def test_sync_tool_returning_explicit_none_executes_exactly_once(self):
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.'
)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@adk-bot adk-bot added the core [Component] This issue is related to the core interface and implementation label Apr 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

3 participants