Skip to content

Commit 5620d8f

Browse files
wuliang229copybara-github
authored andcommitted
feat(live): Run non-blocking tools in a background task
This change introduces logic to identify and execute tools that are non-blocking in a separate asyncio task. The function now returns immediately for these tools, and the tool's response is sent back through the LiveRequestQueue when the background task completes. Co-authored-by: Liang Wu <wuliang@google.com> PiperOrigin-RevId: 945327698
1 parent 99ea228 commit 5620d8f

6 files changed

Lines changed: 345 additions & 16 deletions

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# Live Non-Blocking Tool Agent Sample
2+
3+
## Overview
4+
5+
This sample provides a minimal agent to demonstrate non-blocking tool execution in ADK Live mode (`adk web` / `run_live`).
6+
7+
When a tool declaration is configured with `response_scheduling` set to `WHEN_IDLE`, `SILENT`, or `INTERRUPT`, it indicates to the model that response handling can occur asynchronously.
8+
9+
## Sample Inputs
10+
11+
- `Please start a slow background task for data processing, and then let's keep talking.`
12+
13+
*Triggers `slow_background_task` which sleeps for 10 seconds. While it runs, continue speaking to the agent.*
14+
15+
## Reproduction Instructions
16+
17+
1. Run the sample via `adk web`:
18+
```bash
19+
uv run adk web contributing/samples/live/live_non_blocking_tool_agent
20+
```
21+
1. Open the ADK web interface and start a Live Session with the agent.
22+
1. Trigger the tool by saying: *"Please start a slow background task and keep talking with me."*
23+
1. Continue speaking to the agent while the background task runs in console (`[Tool] Starting slow background task...`).
24+
25+
### Expected Behavior
26+
27+
The model should continue conversing and generating audio/transcription responses immediately while the tool executes in the background. The tool result is delivered later per the `response_scheduling` mode.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from . import agent
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import asyncio
16+
from typing import Any
17+
from typing import Dict
18+
19+
from google.adk.agents.llm_agent import Agent
20+
from google.adk.tools.function_tool import FunctionTool
21+
from google.genai import types
22+
23+
24+
async def slow_background_task(task_description: str) -> Dict[str, Any]:
25+
"""Performs a long-running background computation or data retrieval.
26+
27+
Args:
28+
task_description: Description of the task to run in the background.
29+
30+
Returns:
31+
A dictionary containing the completion status and task result.
32+
"""
33+
print(f"[Tool] Starting slow background task: {task_description}")
34+
# Simulate a 10-second non-blocking background operation
35+
await asyncio.sleep(10)
36+
print(f"[Tool] Completed slow background task: {task_description}")
37+
return {
38+
"status": "completed",
39+
"task": task_description,
40+
"result": "Background task finished successfully after 10 seconds.",
41+
}
42+
43+
44+
# Create a FunctionTool wrapping the long-running async function
45+
non_blocking_tool = FunctionTool(slow_background_task)
46+
47+
# Configure response_scheduling to indicate non-blocking behavior for Live mode.
48+
# Options: WHEN_IDLE, SILENT, or INTERRUPT.
49+
non_blocking_tool.response_scheduling = (
50+
types.FunctionResponseScheduling.WHEN_IDLE
51+
)
52+
53+
54+
root_agent = Agent(
55+
model="gemini-live-2.5-flash-native-audio",
56+
name="non_blocking_tool_agent",
57+
description=(
58+
"Agent demonstrating non-blocking tool execution in ADK Live mode."
59+
),
60+
instruction="""
61+
You are a helpful assistant for testing live mode non-blocking tool execution.
62+
63+
You have access to a tool `slow_background_task` which is configured with
64+
NON_BLOCKING response scheduling (WHEN_IDLE).
65+
66+
When the user asks you to run a long-running or background task, call the `slow_background_task` tool.
67+
Inform the user that the task has started and continue conversing with them normally while the task runs in the background.
68+
""",
69+
tools=[
70+
non_blocking_tool,
71+
],
72+
)

src/google/adk/agents/invocation_context.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,9 @@ class InvocationContext(BaseModel):
210210
active_streaming_tools: Optional[dict[str, ActiveStreamingTool]] = None
211211
"""The running streaming tools of this invocation."""
212212

213+
active_non_blocking_tool_tasks: Optional[dict[str, asyncio.Task[Any]]] = None
214+
"""The running non-blocking tool tasks of this invocation (Live only)."""
215+
213216
transcription_cache: Optional[list[TranscriptionEntry]] = None
214217
"""Caches necessary data, audio or contents, that are needed by transcription."""
215218

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

Lines changed: 72 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -678,8 +678,8 @@ async def handle_function_calls_live(
678678
if not function_calls:
679679
return None
680680

681-
# Create async lock for active_streaming_tools modifications
682-
streaming_lock = asyncio.Lock()
681+
# Create async lock for active_streaming_tools and active_non_blocking_tool_tasks modifications
682+
active_tools_lock = asyncio.Lock()
683683

684684
# Create tasks for parallel execution
685685
tasks = [
@@ -689,7 +689,7 @@ async def handle_function_calls_live(
689689
function_call,
690690
tools_dict,
691691
agent,
692-
streaming_lock,
692+
active_tools_lock,
693693
)
694694
)
695695
for function_call in function_calls
@@ -737,7 +737,7 @@ async def _execute_single_function_call_live(
737737
function_call: types.FunctionCall,
738738
tools_dict: dict[str, BaseTool],
739739
agent: LlmAgent,
740-
streaming_lock: asyncio.Lock,
740+
active_tools_lock: asyncio.Lock,
741741
) -> Optional[Event]:
742742
"""Execute a single function call for live mode with thread safety."""
743743

@@ -800,7 +800,16 @@ async def _run_on_tool_error_callbacks(
800800
)
801801
raise tool_error
802802

803-
async def _run_with_trace():
803+
async def _run_with_trace() -> Optional[Event]:
804+
"""Executes the tool with full lifecycle management and telemetry.
805+
806+
This function orchestrates the tool execution pipeline, including:
807+
1. Running plugin and canonical before-tool callbacks.
808+
2. Executing the actual tool logic.
809+
3. Running plugin and canonical after-tool callbacks.
810+
4. Detecting error types for telemetry.
811+
5. Building the final FunctionResponse Event to be returned.
812+
"""
804813
nonlocal function_args, detected_error_type
805814

806815
# Do not use "args" as the variable name, because it is a reserved keyword
@@ -837,7 +846,7 @@ async def _run_with_trace():
837846
function_call,
838847
function_args,
839848
invocation_context,
840-
streaming_lock,
849+
active_tools_lock,
841850
)
842851
except Exception as tool_error:
843852
error_response = await _run_on_tool_error_callbacks(
@@ -904,12 +913,59 @@ async def _run_with_trace():
904913
)
905914
return function_response_event
906915

907-
async with _instrumentation.record_tool_execution(
908-
tool, agent, function_args, invocation_context=invocation_context
909-
) as tel_ctx:
910-
tel_ctx.function_response_event = await _run_with_trace()
911-
tel_ctx.error_type = detected_error_type
912-
return tel_ctx.function_response_event
916+
is_streaming = hasattr(tool, 'func') and inspect.isasyncgenfunction(tool.func)
917+
is_non_blocking = not is_streaming and tool.response_scheduling is not None
918+
if is_non_blocking:
919+
task_key = f'{tool.name}_{function_call.id}'
920+
921+
async def _background_task() -> None:
922+
try:
923+
async with _instrumentation.record_tool_execution(
924+
tool, agent, function_args, invocation_context=invocation_context
925+
) as tel_ctx:
926+
function_response_event = await _run_with_trace()
927+
tel_ctx.function_response_event = function_response_event
928+
tel_ctx.error_type = detected_error_type
929+
930+
if function_response_event:
931+
if (
932+
invocation_context.session_service
933+
and invocation_context.session
934+
):
935+
await invocation_context.session_service.append_event(
936+
session=invocation_context.session,
937+
event=function_response_event,
938+
)
939+
if (
940+
invocation_context.live_request_queue
941+
and function_response_event.content
942+
):
943+
invocation_context.live_request_queue.send_content(
944+
function_response_event.content
945+
)
946+
except Exception:
947+
logger.exception('Error running non-blocking tool %s', tool.name)
948+
finally:
949+
async with active_tools_lock:
950+
if (
951+
invocation_context.active_non_blocking_tool_tasks
952+
and task_key in invocation_context.active_non_blocking_tool_tasks
953+
):
954+
del invocation_context.active_non_blocking_tool_tasks[task_key]
955+
956+
task = asyncio.create_task(_background_task())
957+
async with active_tools_lock:
958+
if invocation_context.active_non_blocking_tool_tasks is None:
959+
invocation_context.active_non_blocking_tool_tasks = {}
960+
invocation_context.active_non_blocking_tool_tasks[task_key] = task
961+
return None
962+
else:
963+
async with _instrumentation.record_tool_execution(
964+
tool, agent, function_args, invocation_context=invocation_context
965+
) as tel_ctx:
966+
tel_ctx.function_response_event = await _run_with_trace()
967+
tel_ctx.error_type = detected_error_type
968+
return tel_ctx.function_response_event
913969

914970

915971
async def _process_function_live_helper(
@@ -918,7 +974,7 @@ async def _process_function_live_helper(
918974
function_call,
919975
function_args,
920976
invocation_context,
921-
streaming_lock: asyncio.Lock,
977+
active_tools_lock: asyncio.Lock,
922978
):
923979
function_response = None
924980
# Check if this is a stop_streaming function call
@@ -928,7 +984,7 @@ async def _process_function_live_helper(
928984
):
929985
function_name = function_args['function_name']
930986
# Thread-safe access to active_streaming_tools
931-
async with streaming_lock:
987+
async with active_tools_lock:
932988
active_tasks = invocation_context.active_streaming_tools
933989
if (
934990
active_tasks
@@ -961,7 +1017,7 @@ async def _process_function_live_helper(
9611017
}
9621018
if not function_response:
9631019
# Clean up the reference under lock
964-
async with streaming_lock:
1020+
async with active_tools_lock:
9651021
if (
9661022
invocation_context.active_streaming_tools
9671023
and function_name in invocation_context.active_streaming_tools
@@ -1005,7 +1061,7 @@ async def run_tool_and_update_queue(tool, function_args, tool_context):
10051061
run_tool_and_update_queue(tool, function_args, tool_context)
10061062
)
10071063

1008-
async with streaming_lock:
1064+
async with active_tools_lock:
10091065

10101066
if invocation_context.active_streaming_tools is None:
10111067
invocation_context.active_streaming_tools = {}

0 commit comments

Comments
 (0)