Skip to content

Commit 3e45d49

Browse files
Merge branch 'main' into fix/harden-set-model-response-fallback
2 parents 67307bf + 1104523 commit 3e45d49

File tree

14 files changed

+560
-312
lines changed

14 files changed

+560
-312
lines changed
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# Simple Live (Bidi-Streaming) Agent with Parallel Tools
2+
This project provides a basic example of a live, [bidirectional streaming](https://google.github.io/adk-docs/streaming/) agent that demonstrates parallel tool execution.
3+
4+
## Getting Started
5+
6+
Follow these steps to get the agent up and running:
7+
8+
1. **Start the ADK Web Server**
9+
Open your terminal, navigate to the root directory that contains the
10+
`live_bidi_streaming_parallel_tools_agent` folder, and execute the following
11+
command:
12+
```bash
13+
adk web
14+
```
15+
16+
2. **Access the ADK Web UI**
17+
Once the server is running, open your web browser and navigate to the URL
18+
provided in the terminal (it will typically be `http://localhost:8000`).
19+
20+
3. **Select the Agent**
21+
In the top-left corner of the ADK Web UI, use the dropdown menu to select
22+
this agent (`live_bidi_streaming_parallel_tools_agent`).
23+
24+
4. **Start Streaming**
25+
Click on the **Audio** icon located near the chat input
26+
box to begin the streaming session.
27+
28+
5. **Interact with the Agent**
29+
You can now begin talking to the agent, and it will respond in real-time.
30+
Try asking it to perform multiple actions at once, for example: "Turn on the
31+
lights and the TV at the same time." The agent will be able to invoke both
32+
`turn_on_lights` and `turn_on_tv` tools in parallel.
33+
34+
## Usage Notes
35+
36+
* You only need to click the **Audio** button once to initiate the
37+
stream. The current version does not support stopping and restarting the stream
38+
by clicking the button again during a session.
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: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
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+
16+
from google.adk.agents.llm_agent import Agent
17+
18+
19+
def turn_on_lights():
20+
"""Turn on the lights."""
21+
print("turn_on_lights")
22+
return {"status": "OK"}
23+
24+
25+
def turn_on_tv():
26+
"""Turn on the tv."""
27+
print("turn_on_tv")
28+
return {"status": "OK"}
29+
30+
31+
root_agent = Agent(
32+
model="gemini-live-2.5-flash-native-audio",
33+
name="Home_helper",
34+
instruction="Be polite and answer all user's questions.",
35+
tools=[turn_on_lights, turn_on_tv],
36+
)
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
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 .secret_client import SecretManagerClient
16+
17+
__all__ = [
18+
'SecretManagerClient',
19+
]
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
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 __future__ import annotations
16+
17+
import json
18+
from typing import cast
19+
from typing import Optional
20+
21+
import google.auth
22+
from google.auth import default as default_service_credential
23+
import google.auth.transport.requests
24+
from google.cloud import secretmanager
25+
from google.oauth2 import service_account
26+
27+
28+
class SecretManagerClient:
29+
"""A client for interacting with Google Cloud Secret Manager.
30+
31+
This class provides a simplified interface for retrieving secrets from
32+
Secret Manager, handling authentication using either a service account
33+
JSON keyfile (passed as a string) or a preexisting authorization token.
34+
35+
Attributes:
36+
_credentials: Google Cloud credentials object (ServiceAccountCredentials
37+
or Credentials).
38+
_client: Secret Manager client instance.
39+
"""
40+
41+
def __init__(
42+
self,
43+
service_account_json: Optional[str] = None,
44+
auth_token: Optional[str] = None,
45+
):
46+
"""Initializes the SecretManagerClient.
47+
48+
Args:
49+
service_account_json: The content of a service account JSON keyfile (as
50+
a string), not the file path. Must be valid JSON.
51+
auth_token: An existing Google Cloud authorization token.
52+
53+
Raises:
54+
ValueError: If neither `service_account_json` nor `auth_token` is
55+
provided,
56+
or if both are provided. Also raised if the service_account_json
57+
is not valid JSON.
58+
google.auth.exceptions.GoogleAuthError: If authentication fails.
59+
"""
60+
if service_account_json:
61+
try:
62+
credentials = service_account.Credentials.from_service_account_info(
63+
json.loads(service_account_json)
64+
)
65+
except json.JSONDecodeError as e:
66+
raise ValueError(f"Invalid service account JSON: {e}") from e
67+
elif auth_token:
68+
credentials = google.auth.credentials.Credentials(
69+
token=auth_token,
70+
refresh_token=None,
71+
token_uri=None,
72+
client_id=None,
73+
client_secret=None,
74+
)
75+
request = google.auth.transport.requests.Request()
76+
credentials.refresh(request)
77+
else:
78+
try:
79+
credentials, _ = default_service_credential(
80+
scopes=["https://www.googleapis.com/auth/cloud-platform"]
81+
)
82+
except Exception as e:
83+
raise ValueError(
84+
"'service_account_json' or 'auth_token' are both missing, and"
85+
f" error occurred while trying to use default credentials: {e}"
86+
) from e
87+
88+
if not credentials:
89+
raise ValueError(
90+
"Must provide either 'service_account_json' or 'auth_token', not both"
91+
" or neither."
92+
)
93+
94+
self._credentials = credentials
95+
self._client = secretmanager.SecretManagerServiceClient(
96+
credentials=self._credentials
97+
)
98+
99+
def get_secret(self, resource_name: str) -> str:
100+
"""Retrieves a secret from Google Cloud Secret Manager.
101+
102+
Args:
103+
resource_name: The full resource name of the secret, in the format
104+
"projects/*/secrets/*/versions/*". Usually you want the "latest"
105+
version, e.g.,
106+
"projects/my-project/secrets/my-secret/versions/latest".
107+
108+
Returns:
109+
The secret payload as a string.
110+
111+
Raises:
112+
google.api_core.exceptions.GoogleAPIError: If the Secret Manager API
113+
returns an error (e.g., secret not found, permission denied).
114+
Exception: For other unexpected errors.
115+
"""
116+
try:
117+
response = self._client.access_secret_version(name=resource_name)
118+
return cast(str, response.payload.data.decode("UTF-8"))
119+
except Exception as e:
120+
raise e # Re-raise the exception to allow for handling by the caller
121+
# Consider logging the exception here before re-raising.

src/google/adk/models/gemini_llm_connection.py

Lines changed: 61 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020

2121
from google.genai import types
2222

23+
from ..utils import model_name_utils
2324
from ..utils.content_utils import filter_audio_parts
2425
from ..utils.context_utils import Aclosing
2526
from ..utils.variant_utils import GoogleLLMVariant
@@ -99,7 +100,6 @@ async def send_content(self, content: types.Content):
99100
Args:
100101
content: The content to send to the model.
101102
"""
102-
103103
assert content.parts
104104
if content.parts[0].function_response:
105105
# All parts have to be function responses.
@@ -112,12 +112,30 @@ async def send_content(self, content: types.Content):
112112
)
113113
else:
114114
logger.debug('Sending LLM new content %s', content)
115-
await self._gemini_session.send(
116-
input=types.LiveClientContent(
117-
turns=[content],
118-
turn_complete=True,
119-
)
115+
is_gemini_31 = model_name_utils.is_gemini_3_1_flash_live(
116+
self._model_version
120117
)
118+
is_gemini_api = self._api_backend == GoogleLLMVariant.GEMINI_API
119+
120+
# As of now, Gemini 3.1 Flash Live is only available in Gemini API, not
121+
# Vertex AI.
122+
if (
123+
is_gemini_31
124+
and is_gemini_api
125+
and len(content.parts) == 1
126+
and content.parts[0].text
127+
):
128+
logger.debug('Using send_realtime_input for Gemini 3.1 text input')
129+
await self._gemini_session.send_realtime_input(
130+
text=content.parts[0].text
131+
)
132+
else:
133+
await self._gemini_session.send(
134+
input=types.LiveClientContent(
135+
turns=[content],
136+
turn_complete=True,
137+
)
138+
)
121139

122140
async def send_realtime(self, input: RealtimeInput):
123141
"""Sends a chunk of audio or a frame of video to the model in realtime.
@@ -128,7 +146,26 @@ async def send_realtime(self, input: RealtimeInput):
128146
if isinstance(input, types.Blob):
129147
# The blob is binary and is very large. So let's not log it.
130148
logger.debug('Sending LLM Blob.')
131-
await self._gemini_session.send_realtime_input(media=input)
149+
is_gemini_31 = model_name_utils.is_gemini_3_1_flash_live(
150+
self._model_version
151+
)
152+
is_gemini_api = self._api_backend == GoogleLLMVariant.GEMINI_API
153+
154+
# As of now, Gemini 3.1 Flash Live is only available in Gemini API, not
155+
# Vertex AI.
156+
if is_gemini_31 and is_gemini_api:
157+
if input.mime_type and input.mime_type.startswith('audio/'):
158+
await self._gemini_session.send_realtime_input(audio=input)
159+
elif input.mime_type and input.mime_type.startswith('image/'):
160+
await self._gemini_session.send_realtime_input(video=input)
161+
else:
162+
logger.warning(
163+
'Blob not sent. Unknown or empty mime type for'
164+
' send_realtime_input: %s',
165+
input.mime_type,
166+
)
167+
else:
168+
await self._gemini_session.send_realtime_input(media=input)
132169

133170
elif isinstance(input, types.ActivityStart):
134171
logger.debug('Sending LLM activity start signal.')
@@ -166,6 +203,7 @@ async def receive(self) -> AsyncGenerator[LlmResponse, None]:
166203
"""
167204

168205
text = ''
206+
tool_call_parts = []
169207
async with Aclosing(self._gemini_session.receive()) as agen:
170208
# TODO(b/440101573): Reuse StreamingResponseAggregator to accumulate
171209
# partial content and emit responses as needed.
@@ -295,6 +333,13 @@ async def receive(self) -> AsyncGenerator[LlmResponse, None]:
295333
if text:
296334
yield self.__build_full_text_response(text)
297335
text = ''
336+
if tool_call_parts:
337+
logger.debug('Returning aggregated tool_call_parts')
338+
yield LlmResponse(
339+
content=types.Content(role='model', parts=tool_call_parts),
340+
model_version=self._model_version,
341+
)
342+
tool_call_parts = []
298343
yield LlmResponse(
299344
turn_complete=True,
300345
interrupted=message.server_content.interrupted,
@@ -316,17 +361,14 @@ async def receive(self) -> AsyncGenerator[LlmResponse, None]:
316361
model_version=self._model_version,
317362
)
318363
if message.tool_call:
364+
logger.debug('Received tool call: %s', message.tool_call)
319365
if text:
320366
yield self.__build_full_text_response(text)
321367
text = ''
322-
parts = [
368+
tool_call_parts.extend([
323369
types.Part(function_call=function_call)
324370
for function_call in message.tool_call.function_calls
325-
]
326-
yield LlmResponse(
327-
content=types.Content(role='model', parts=parts),
328-
model_version=self._model_version,
329-
)
371+
])
330372
if message.session_resumption_update:
331373
logger.debug('Received session resumption message: %s', message)
332374
yield (
@@ -335,6 +377,12 @@ async def receive(self) -> AsyncGenerator[LlmResponse, None]:
335377
model_version=self._model_version,
336378
)
337379
)
380+
if tool_call_parts:
381+
logger.debug('Exited loop with pending tool_call_parts')
382+
yield LlmResponse(
383+
content=types.Content(role='model', parts=tool_call_parts),
384+
model_version=self._model_version,
385+
)
338386

339387
async def close(self):
340388
"""Closes the llm server connection."""

0 commit comments

Comments
 (0)