Skip to content

Commit bb8374e

Browse files
authored
Merge branch 'main' into main
2 parents c15166b + 714c3ad commit bb8374e

File tree

3 files changed

+554
-3
lines changed

3 files changed

+554
-3
lines changed

src/google/adk/agents/run_config.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,24 @@
3131
logger = logging.getLogger('google_adk.' + __name__)
3232

3333

34+
class ToolThreadPoolConfig(BaseModel):
35+
"""Configuration for the tool thread pool executor.
36+
37+
Attributes:
38+
max_workers: Maximum number of worker threads in the pool. Defaults to 4.
39+
"""
40+
41+
model_config = ConfigDict(
42+
extra='forbid',
43+
)
44+
45+
max_workers: int = Field(
46+
default=4,
47+
description='Maximum number of worker threads in the pool.',
48+
ge=1,
49+
)
50+
51+
3452
class StreamingMode(Enum):
3553
"""Streaming modes for agent execution.
3654
@@ -232,6 +250,53 @@ class RunConfig(BaseModel):
232250
save_live_blob: bool = False
233251
"""Saves live video and audio data to session and artifact service."""
234252

253+
tool_thread_pool_config: Optional[ToolThreadPoolConfig] = None
254+
"""Configuration for running tools in a thread pool for live mode.
255+
256+
When set, tool executions will run in a separate thread pool executor
257+
instead of the main event loop. When None (default), tools run in the
258+
main event loop.
259+
260+
This helps keep the event loop responsive for:
261+
- User interruptions to be processed immediately
262+
- Model responses to continue being received
263+
264+
Both sync and async tools are supported. Async tools are run in a new event
265+
loop within the background thread, which helps catch blocking I/O mistakenly
266+
used inside async functions.
267+
268+
IMPORTANT - GIL (Global Interpreter Lock) Considerations:
269+
270+
Thread pool HELPS with (GIL is released):
271+
- Blocking I/O: time.sleep(), network calls, file I/O, database queries
272+
- C extensions: numpy, hashlib, image processing libraries
273+
- Async functions containing blocking I/O (common user mistake)
274+
275+
Thread pool does NOT help with (GIL is held):
276+
- Pure Python CPU-bound code: loops, calculations, recursive algorithms
277+
- The GIL prevents true parallel execution for Python bytecode
278+
279+
For CPU-intensive Python code, consider alternatives:
280+
- Use C extensions that release the GIL
281+
- Break work into chunks with periodic `await asyncio.sleep(0)`
282+
- Use multiprocessing (ProcessPoolExecutor) for true parallelism
283+
284+
Example:
285+
```python
286+
from google.adk.agents.run_config import RunConfig, ToolThreadPoolConfig
287+
288+
# Enable thread pool with default settings
289+
run_config = RunConfig(
290+
tool_thread_pool_config=ToolThreadPoolConfig(),
291+
)
292+
293+
# Enable thread pool with custom max_workers
294+
run_config = RunConfig(
295+
tool_thread_pool_config=ToolThreadPoolConfig(max_workers=8),
296+
)
297+
```
298+
"""
299+
235300
save_live_audio: bool = Field(
236301
default=False,
237302
deprecated=True,

src/google/adk/flows/llm_flows/functions.py

Lines changed: 118 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@
1717
from __future__ import annotations
1818

1919
import asyncio
20+
from concurrent.futures import ThreadPoolExecutor
2021
import copy
22+
import functools
2123
import inspect
2224
import logging
2325
import threading
@@ -53,6 +55,109 @@
5355

5456
logger = logging.getLogger('google_adk.' + __name__)
5557

58+
# Global thread pool executors for running tools in background threads.
59+
# This prevents blocking tools from blocking the event loop in Live API mode.
60+
# Key is max_workers, value is the executor.
61+
_TOOL_THREAD_POOLS: dict[int, ThreadPoolExecutor] = {}
62+
_TOOL_THREAD_POOL_LOCK = threading.Lock()
63+
64+
65+
def _get_tool_thread_pool(max_workers: int = 4) -> ThreadPoolExecutor:
66+
"""Gets or creates a thread pool executor for tool execution.
67+
68+
Args:
69+
max_workers: Maximum number of worker threads in the pool.
70+
71+
Returns:
72+
A ThreadPoolExecutor with the specified max_workers.
73+
"""
74+
if max_workers not in _TOOL_THREAD_POOLS:
75+
with _TOOL_THREAD_POOL_LOCK:
76+
if max_workers not in _TOOL_THREAD_POOLS:
77+
_TOOL_THREAD_POOLS[max_workers] = ThreadPoolExecutor(
78+
max_workers=max_workers, thread_name_prefix='adk_tool_executor'
79+
)
80+
return _TOOL_THREAD_POOLS[max_workers]
81+
82+
83+
def _is_sync_tool(tool: BaseTool) -> bool:
84+
"""Checks if a tool's underlying function is synchronous."""
85+
if not hasattr(tool, 'func'):
86+
return False
87+
func = tool.func
88+
return not (
89+
inspect.iscoroutinefunction(func)
90+
or inspect.isasyncgenfunction(func)
91+
or (
92+
hasattr(func, '__call__')
93+
and inspect.iscoroutinefunction(func.__call__)
94+
)
95+
)
96+
97+
98+
async def _call_tool_in_thread_pool(
99+
tool: BaseTool,
100+
args: dict[str, Any],
101+
tool_context: ToolContext,
102+
max_workers: int = 4,
103+
) -> Any:
104+
"""Runs a tool in a thread pool to avoid blocking the event loop.
105+
106+
For sync tools, this runs the tool's function directly in a background thread.
107+
For async tools, this creates a new event loop in the background thread and
108+
runs the async function there. This helps catch blocking I/O (like time.sleep,
109+
network calls, file I/O) that was mistakenly used inside async functions.
110+
111+
Note: Due to Python's GIL, this does NOT help with pure Python CPU-bound code.
112+
Thread pool only helps when the GIL is released (blocking I/O, C extensions).
113+
114+
Args:
115+
tool: The tool to execute.
116+
args: Arguments to pass to the tool.
117+
tool_context: The tool context.
118+
max_workers: Maximum number of worker threads in the pool.
119+
120+
Returns:
121+
The result of running the tool.
122+
"""
123+
from ...tools.function_tool import FunctionTool
124+
125+
loop = asyncio.get_running_loop()
126+
executor = _get_tool_thread_pool(max_workers)
127+
128+
if _is_sync_tool(tool):
129+
# For sync FunctionTool, call the underlying function directly
130+
def run_sync_tool():
131+
if isinstance(tool, FunctionTool):
132+
args_to_call = tool._preprocess_args(args)
133+
signature = inspect.signature(tool.func)
134+
valid_params = {param for param in signature.parameters}
135+
if 'tool_context' in valid_params:
136+
args_to_call['tool_context'] = tool_context
137+
args_to_call = {
138+
k: v for k, v in args_to_call.items() if k in valid_params
139+
}
140+
return tool.func(**args_to_call)
141+
else:
142+
# For other sync tool types, we can't easily run them in thread pool
143+
return None
144+
145+
result = await loop.run_in_executor(executor, run_sync_tool)
146+
if result is not None:
147+
return result
148+
else:
149+
# For async tools, run them in a new event loop in a background thread.
150+
# This helps when async functions contain blocking I/O (common user mistake)
151+
# that would otherwise block the main event loop.
152+
def run_async_tool_in_new_loop():
153+
# Create a new event loop for this thread
154+
return asyncio.run(tool.run_async(args=args, tool_context=tool_context))
155+
156+
return await loop.run_in_executor(executor, run_async_tool_in_new_loop)
157+
158+
# Fall back to normal async execution for non-FunctionTool sync tools
159+
return await tool.run_async(args=args, tool_context=tool_context)
160+
56161

57162
def generate_client_function_call_id() -> str:
58163
return f'{AF_FUNCTION_CALL_ID_PREFIX}{uuid.uuid4()}'
@@ -706,9 +811,19 @@ async def run_tool_and_update_queue(tool, function_args, tool_context):
706811
)
707812
}
708813
else:
709-
function_response = await __call_tool_async(
710-
tool, args=function_args, tool_context=tool_context
711-
)
814+
# Check if we should run tools in thread pool to avoid blocking event loop
815+
thread_pool_config = invocation_context.run_config.tool_thread_pool_config
816+
if thread_pool_config is not None:
817+
function_response = await _call_tool_in_thread_pool(
818+
tool,
819+
args=function_args,
820+
tool_context=tool_context,
821+
max_workers=thread_pool_config.max_workers,
822+
)
823+
else:
824+
function_response = await __call_tool_async(
825+
tool, args=function_args, tool_context=tool_context
826+
)
712827
return function_response
713828

714829

0 commit comments

Comments
 (0)