Skip to content

Commit 7443bfa

Browse files
allen-stephencopybara-github
authored andcommitted
feat(tools): add response_scheduling to control Live function response behavior
Merge #6077 **Please ensure you have read the [contribution guide](https://github.com/google/adk-python/blob/main/CONTRIBUTING.md) before creating a pull request.** ### Link to Issue or Description of Change Adds support for Live API function response scheduling (https://docs.cloud.google.com/gemini-enterprise-agent-platform/models/live-api/asynchronous-function-calling) ### Testing Plan **Unit Tests:** - [X] I have added or updated unit tests for my change. - [X] All unit tests pass locally. tests/unittests/tools/ tests/unittests/flows/llm_flows/ -> 438 passed **Manual End-to-End (E2E) Tests:** Set tool.response_scheduling = types.FunctionResponseScheduling.SILENT on a tool in a Live agent and confirmed the model updates state without narrating the tool result; with None (default), behavior is unchanged. ### Checklist - [X] I have read the [CONTRIBUTING.md](https://github.com/google/adk-python/blob/main/CONTRIBUTING.md) document. - [X] I have performed a self-review of my own code. - [X] I have commented my code, particularly in hard-to-understand areas. - [X] I have added tests that prove my fix is effective or that my feature works. - [X] New and existing unit tests pass locally with my changes. - [X] I have manually tested my changes end-to-end. - [X] Any dependent changes have been merged and published in downstream modules. COPYBARA_INTEGRATE_REVIEW=#6077 from allen-stephen:feat/live-function-response-scheduling 62a711c PiperOrigin-RevId: 934013812
1 parent 66221c4 commit 7443bfa

6 files changed

Lines changed: 313 additions & 17 deletions

File tree

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -479,6 +479,29 @@ async def _process_agent_tools(
479479
tool_context=tool_context, llm_request=llm_request
480480
)
481481

482+
if invocation_context.live_request_queue is not None:
483+
_mark_live_async_tools_non_blocking(llm_request)
484+
485+
486+
def _mark_live_async_tools_non_blocking(llm_request: LlmRequest) -> None:
487+
"""Marks live streaming and response-scheduling tools as NON_BLOCKING.
488+
489+
These tools emit asynchronous FunctionResponses, which the Live API only
490+
accepts for NON_BLOCKING declarations.
491+
"""
492+
if not llm_request.config.tools:
493+
return
494+
for gemini_tool in llm_request.config.tools:
495+
for declaration in gemini_tool.function_declarations or []:
496+
tool = llm_request.tools_dict.get(declaration.name)
497+
if tool is None:
498+
continue
499+
is_streaming_tool = hasattr(tool, 'func') and inspect.isasyncgenfunction(
500+
tool.func
501+
)
502+
if tool.response_scheduling is not None or is_streaming_tool:
503+
declaration.behavior = types.Behavior.NON_BLOCKING
504+
482505

483506
class BaseLlmFlow(ABC):
484507
"""A basic flow that calls the LLM in a loop until a final response is generated.

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

Lines changed: 32 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -977,13 +977,8 @@ async def run_tool_and_update_queue(tool, function_args, tool_context):
977977
)
978978
) as agen:
979979
async for result in agen:
980-
updated_content = types.Content(
981-
role='user',
982-
parts=[
983-
types.Part.from_text(
984-
text=f'Function {tool.name} returned: {result}'
985-
)
986-
],
980+
updated_content = _build_function_response_content(
981+
tool, result, tool_context.function_call_id
987982
)
988983
invocation_context.live_request_queue.send_content(updated_content)
989984
except asyncio.CancelledError:
@@ -1182,16 +1177,11 @@ def __build_response_event(
11821177
tool, function_result
11831178
)
11841179

1185-
part_function_response = types.Part.from_function_response(
1186-
name=tool.name,
1187-
response=function_result,
1188-
parts=function_response_parts,
1189-
)
1190-
part_function_response.function_response.id = tool_context.function_call_id
1191-
1192-
content = types.Content(
1193-
role='user',
1194-
parts=[part_function_response],
1180+
content = _build_function_response_content(
1181+
tool,
1182+
function_result,
1183+
tool_context.function_call_id,
1184+
function_response_parts,
11951185
)
11961186

11971187
function_response_event = Event(
@@ -1205,6 +1195,31 @@ def __build_response_event(
12051195
return function_response_event
12061196

12071197

1198+
def _build_function_response_content(
1199+
tool: BaseTool,
1200+
function_result: object,
1201+
function_call_id: Optional[str],
1202+
function_response_parts: Optional[list[types.FunctionResponsePart]] = None,
1203+
) -> types.Content:
1204+
"""Builds the content carrying a tool result as a FunctionResponse."""
1205+
# Specs requires the result to be a dict.
1206+
if not isinstance(function_result, dict):
1207+
function_result = {'result': function_result}
1208+
1209+
part_function_response = types.Part.from_function_response(
1210+
name=tool.name,
1211+
response=function_result,
1212+
parts=function_response_parts,
1213+
)
1214+
part_function_response.function_response.id = function_call_id
1215+
if tool.response_scheduling is not None:
1216+
part_function_response.function_response.scheduling = (
1217+
tool.response_scheduling
1218+
)
1219+
1220+
return types.Content(role='user', parts=[part_function_response])
1221+
1222+
12081223
def deep_merge_dicts(d1: dict, d2: dict) -> dict:
12091224
"""Recursively merges d2 into d1."""
12101225
for key, value in d2.items():

src/google/adk/tools/base_tool.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,19 +90,33 @@ class BaseTool(ABC):
9090
NOTE: the entire dict must be JSON serializable.
9191
"""
9292

93+
response_scheduling: Optional[types.FunctionResponseScheduling] = None
94+
"""Controls when the model reacts to the tool's response (Live API only).
95+
96+
Applied to the emitted ``FunctionResponse`` for asynchronous function calling:
97+
- ``SILENT``: feeds the response back without triggering a model turn.
98+
- ``WHEN_IDLE``: defers the reaction until the model is idle.
99+
- ``INTERRUPT``: reacts immediately.
100+
101+
Ignored by models that don't support asynchronous function calling. ``None``
102+
preserves the default behavior.
103+
"""
104+
93105
def __init__(
94106
self,
95107
*,
96108
name,
97109
description,
98110
is_long_running: bool = False,
99111
custom_metadata: Optional[dict[str, Any]] = None,
112+
response_scheduling: Optional[types.FunctionResponseScheduling] = None,
100113
):
101114
self.name = name
102115
self.description = description
103116
self.is_long_running = is_long_running
104117
self._defers_response = False
105118
self.custom_metadata = custom_metadata
119+
self.response_scheduling = response_scheduling
106120

107121
def _get_declaration(self) -> Optional[types.FunctionDeclaration]:
108122
"""Gets the OpenAPI specification of this tool in the form of a FunctionDeclaration.

tests/unittests/flows/llm_flows/test_base_llm_flow.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,89 @@ def fn_fast():
357357
assert process_call_order == ['slow_tool', 'fast_tool']
358358

359359

360+
async def _preprocess(agent, *, is_live: bool) -> LlmRequest:
361+
invocation_context = await testing_utils.create_invocation_context(
362+
agent=agent, user_content='test message'
363+
)
364+
if is_live:
365+
invocation_context.live_request_queue = LiveRequestQueue()
366+
flow = BaseLlmFlowForTesting()
367+
llm_request = LlmRequest()
368+
async for _ in flow._preprocess_async(invocation_context, llm_request):
369+
pass
370+
return llm_request
371+
372+
373+
def _declarations(llm_request: LlmRequest) -> dict:
374+
return {
375+
decl.name: decl
376+
for decl in llm_request.config.tools[0].function_declarations
377+
}
378+
379+
380+
async def _streaming_tool(query: str):
381+
"""A streaming tool."""
382+
yield f'streaming: {query}'
383+
384+
385+
def _scheduled_tool(query: str) -> str:
386+
"""A scheduled tool."""
387+
return f'scheduled: {query}'
388+
389+
390+
@pytest.mark.asyncio
391+
async def test_process_agent_tools_marks_streaming_tool_non_blocking_for_live():
392+
"""Live streaming async-generator tools are marked NON_BLOCKING."""
393+
agent = Agent(name='test_agent', tools=[_streaming_tool])
394+
395+
llm_request = await _preprocess(agent, is_live=True)
396+
397+
declaration = llm_request.config.tools[0].function_declarations[0]
398+
assert declaration.behavior is types.Behavior.NON_BLOCKING
399+
400+
401+
@pytest.mark.asyncio
402+
async def test_process_agent_tools_marks_scheduled_tool_non_blocking_for_live():
403+
"""Live response-scheduling tools are marked NON_BLOCKING."""
404+
from google.adk.tools.function_tool import FunctionTool
405+
406+
tool = FunctionTool(func=_scheduled_tool)
407+
tool.response_scheduling = types.FunctionResponseScheduling.SILENT
408+
agent = Agent(name='test_agent', tools=[tool])
409+
410+
llm_request = await _preprocess(agent, is_live=True)
411+
412+
declaration = llm_request.config.tools[0].function_declarations[0]
413+
assert declaration.behavior is types.Behavior.NON_BLOCKING
414+
415+
416+
@pytest.mark.asyncio
417+
async def test_process_agent_tools_does_not_mark_non_blocking_for_non_live():
418+
"""Non-live requests never set behavior, even for streaming tools."""
419+
from google.adk.tools.function_tool import FunctionTool
420+
421+
scheduled = FunctionTool(func=_scheduled_tool)
422+
scheduled.response_scheduling = types.FunctionResponseScheduling.SILENT
423+
agent = Agent(name='test_agent', tools=[_streaming_tool, scheduled])
424+
425+
llm_request = await _preprocess(agent, is_live=False)
426+
427+
declarations = _declarations(llm_request)
428+
assert declarations['_streaming_tool'].behavior is None
429+
assert declarations['_scheduled_tool'].behavior is None
430+
431+
432+
@pytest.mark.asyncio
433+
async def test_process_agent_tools_leaves_regular_tool_behavior_unset_for_live():
434+
"""Regular (non-streaming, non-scheduled) live tools are left untouched."""
435+
agent = Agent(name='test_agent', tools=[_scheduled_tool])
436+
437+
llm_request = await _preprocess(agent, is_live=True)
438+
439+
declaration = llm_request.config.tools[0].function_declarations[0]
440+
assert declaration.behavior is None
441+
442+
360443
class _AsyncProcessLlmRequestTool:
361444
"""Minimal stand-in for a BaseTool that records process_llm_request calls."""
362445

tests/unittests/flows/llm_flows/test_functions_simple.py

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from unittest import mock
1919

2020
from fastapi.openapi.models import HTTPBearer
21+
from google.adk.agents.live_request_queue import LiveRequestQueue
2122
from google.adk.agents.llm_agent import Agent
2223
from google.adk.auth.auth_tool import AuthConfig
2324
from google.adk.events.event import Event
@@ -1559,3 +1560,151 @@ async def test_detection_exception_does_not_break_tool_call(
15591560
assert len(recorded_calls) == 1
15601561
assert recorded_calls[0]['error_type'] is None
15611562
assert recorded_calls[0]['error'] is None
1563+
1564+
1565+
@pytest.mark.asyncio
1566+
async def test_response_scheduling_applied_to_function_response():
1567+
"""response_scheduling on a tool is stamped onto the FunctionResponse part."""
1568+
1569+
def simple_fn(**kwargs) -> dict:
1570+
return {'result': 'test'}
1571+
1572+
tool = FunctionTool(simple_fn)
1573+
tool.response_scheduling = types.FunctionResponseScheduling.SILENT
1574+
model = testing_utils.MockModel.create(responses=[])
1575+
agent = Agent(name='test_agent', model=model, tools=[tool])
1576+
invocation_context = await testing_utils.create_invocation_context(
1577+
agent=agent, user_content=''
1578+
)
1579+
1580+
function_call = types.FunctionCall(name=tool.name, args={}, id='fc_test')
1581+
event = Event(
1582+
invocation_id=invocation_context.invocation_id,
1583+
author=agent.name,
1584+
content=types.Content(parts=[types.Part(function_call=function_call)]),
1585+
)
1586+
1587+
result_event = await handle_function_calls_async(
1588+
invocation_context, event, {tool.name: tool}
1589+
)
1590+
1591+
assert result_event is not None
1592+
function_response = result_event.content.parts[0].function_response
1593+
assert function_response.scheduling is types.FunctionResponseScheduling.SILENT
1594+
1595+
1596+
@pytest.mark.asyncio
1597+
async def test_response_scheduling_unset_by_default():
1598+
"""Without response_scheduling, the FunctionResponse part leaves it unset."""
1599+
1600+
def simple_fn(**kwargs) -> dict:
1601+
return {'result': 'test'}
1602+
1603+
tool = FunctionTool(simple_fn)
1604+
model = testing_utils.MockModel.create(responses=[])
1605+
agent = Agent(name='test_agent', model=model, tools=[tool])
1606+
invocation_context = await testing_utils.create_invocation_context(
1607+
agent=agent, user_content=''
1608+
)
1609+
1610+
function_call = types.FunctionCall(name=tool.name, args={}, id='fc_test')
1611+
event = Event(
1612+
invocation_id=invocation_context.invocation_id,
1613+
author=agent.name,
1614+
content=types.Content(parts=[types.Part(function_call=function_call)]),
1615+
)
1616+
1617+
result_event = await handle_function_calls_async(
1618+
invocation_context, event, {tool.name: tool}
1619+
)
1620+
1621+
assert result_event is not None
1622+
function_response = result_event.content.parts[0].function_response
1623+
assert function_response.scheduling is None
1624+
1625+
1626+
async def _drain_live_function_responses(
1627+
live_request_queue: LiveRequestQueue,
1628+
count: int,
1629+
) -> list[types.Content]:
1630+
"""Drains ``count`` contents from a live request queue, ignoring acks."""
1631+
contents = []
1632+
while len(contents) < count:
1633+
request = await asyncio.wait_for(live_request_queue._queue.get(), timeout=5)
1634+
if request.content is not None:
1635+
contents.append(request.content)
1636+
return contents
1637+
1638+
1639+
@pytest.mark.asyncio
1640+
async def test_streaming_tool_with_scheduling_emits_function_response():
1641+
"""A streaming tool with response_scheduling relays yields as FunctionResponses."""
1642+
1643+
async def streaming_fn(**kwargs):
1644+
yield 'first'
1645+
yield 'second'
1646+
1647+
tool = FunctionTool(streaming_fn)
1648+
tool.response_scheduling = types.FunctionResponseScheduling.SILENT
1649+
model = testing_utils.MockModel.create(responses=[])
1650+
agent = Agent(name='test_agent', model=model, tools=[tool])
1651+
invocation_context = await testing_utils.create_invocation_context(
1652+
agent=agent, user_content=''
1653+
)
1654+
invocation_context.live_request_queue = LiveRequestQueue()
1655+
1656+
function_call = types.FunctionCall(name=tool.name, args={}, id='fc_stream')
1657+
event = Event(
1658+
invocation_id=invocation_context.invocation_id,
1659+
author=agent.name,
1660+
content=types.Content(parts=[types.Part(function_call=function_call)]),
1661+
)
1662+
1663+
await handle_function_calls_live(invocation_context, event, {tool.name: tool})
1664+
contents = await _drain_live_function_responses(
1665+
invocation_context.live_request_queue, count=2
1666+
)
1667+
1668+
responses = [content.parts[0].function_response for content in contents]
1669+
assert [r.response for r in responses] == [
1670+
{'result': 'first'},
1671+
{'result': 'second'},
1672+
]
1673+
assert all(r.id == 'fc_stream' for r in responses)
1674+
assert all(
1675+
r.scheduling is types.FunctionResponseScheduling.SILENT for r in responses
1676+
)
1677+
1678+
1679+
@pytest.mark.asyncio
1680+
async def test_streaming_tool_without_scheduling_emits_function_response():
1681+
"""A streaming tool without response_scheduling still relays yields as
1682+
FunctionResponses, leaving scheduling unset."""
1683+
1684+
async def streaming_fn(**kwargs):
1685+
yield 'hello'
1686+
1687+
tool = FunctionTool(streaming_fn)
1688+
model = testing_utils.MockModel.create(responses=[])
1689+
agent = Agent(name='test_agent', model=model, tools=[tool])
1690+
invocation_context = await testing_utils.create_invocation_context(
1691+
agent=agent, user_content=''
1692+
)
1693+
invocation_context.live_request_queue = LiveRequestQueue()
1694+
1695+
function_call = types.FunctionCall(name=tool.name, args={}, id='fc_stream')
1696+
event = Event(
1697+
invocation_id=invocation_context.invocation_id,
1698+
author=agent.name,
1699+
content=types.Content(parts=[types.Part(function_call=function_call)]),
1700+
)
1701+
1702+
await handle_function_calls_live(invocation_context, event, {tool.name: tool})
1703+
contents = await _drain_live_function_responses(
1704+
invocation_context.live_request_queue, count=1
1705+
)
1706+
1707+
function_response = contents[0].parts[0].function_response
1708+
assert function_response.response == {'result': 'hello'}
1709+
assert function_response.id == 'fc_stream'
1710+
assert function_response.scheduling is None

tests/unittests/tools/test_base_tool.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,3 +155,15 @@ async def run_async(self, **kwargs):
155155
t2 = SimpleTool(name='test2', description='desc')
156156
t2._defers_response = True
157157
assert t2._defers_response is True
158+
159+
160+
def test_response_scheduling_defaults_to_none():
161+
"""response_scheduling defaults to None, preserving existing behavior."""
162+
163+
class SimpleTool(BaseTool):
164+
165+
async def run_async(self, **kwargs):
166+
pass
167+
168+
t = SimpleTool(name='test', description='desc')
169+
assert t.response_scheduling is None

0 commit comments

Comments
 (0)