You've already forked adk-python
mirror of
https://github.com/encounter/adk-python.git
synced 2026-07-09 18:19:28 -07:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7e00c52074 | |||
| 58204fb421 | |||
| 565a0e1ecb | |||
| 49da199c02 | |||
| 88a246f308 | |||
| fd46b7a66b |
@@ -7,34 +7,49 @@ assignees: ''
|
||||
|
||||
---
|
||||
|
||||
** Please make sure you read the contribution guide and file the issues in the right place. **
|
||||
[Contribution guide.](https://google.github.io/adk-docs/contributing-guide/)
|
||||
## đź”´ Required Information
|
||||
*Please ensure all items in this section are completed to allow for efficient triaging. Requests without complete information may be rejected / deprioritized. If an item is not applicable to you - please mark it as N/A*
|
||||
|
||||
**Describe the bug**
|
||||
**Describe the Bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Please share a minimal code and data to reproduce your problem.
|
||||
Steps to reproduce the behavior:
|
||||
**Steps to Reproduce**
|
||||
Please provide a numbered list of steps to reproduce the behavior:
|
||||
1. Install '...'
|
||||
2. Run '....'
|
||||
3. Open '....'
|
||||
4. Provide error or stacktrace
|
||||
|
||||
**Expected behavior**
|
||||
**Expected Behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
**Observed Behavior**
|
||||
What actually happened? Include error messages or crash stack traces here.
|
||||
|
||||
**Desktop (please complete the following information):**
|
||||
- OS: [e.g. macOS, Linux, Windows]
|
||||
- Python version(python -V):
|
||||
- ADK version(pip show google-adk):
|
||||
**Environment Details**
|
||||
* **ADK Library Version:** (e.g., 2.0.1)
|
||||
* **Desktop OS:** (e.g., macOS, Linux, Windows)
|
||||
* **Python Version:**
|
||||
|
||||
**Model Information:**
|
||||
- Are you using LiteLLM: Yes/No
|
||||
- Which model is being used(e.g. gemini-2.5-pro)
|
||||
**Model Information**
|
||||
* **Are you using LiteLLM:** Yes/No
|
||||
* **Which model is being used:** (e.g., gemini-2.5-pro)
|
||||
|
||||
**Additional context**
|
||||
---
|
||||
|
||||
## 🟡 Optional Information
|
||||
*Providing this information greatly speeds up the resolution process.*
|
||||
|
||||
**Regression**
|
||||
Did this work in a previous version of ADK? If so, which one?
|
||||
|
||||
**Logs**
|
||||
Please attach relevant logs. Wrap them in code blocks (```) or attach a text file.
|
||||
```text
|
||||
// Paste logs here
|
||||
```
|
||||
**Screenshots / Video**
|
||||
If applicable, add screenshots or screen recordings to help explain your problem.
|
||||
|
||||
**Additional Context**
|
||||
Add any other context about the problem here.
|
||||
|
||||
@@ -1,88 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## [1.23.0](https://github.com/google/adk-python/compare/v1.22.1...v1.23.0) (2026-01-22)
|
||||
|
||||
### âš BREAKING CHANGES
|
||||
|
||||
* Breaking: Use OpenTelemetry for BigQuery plugin tracing, replacing custom `ContextVar` implementation ([ab89d12](https://github.com/google/adk-python/commit/ab89d1283430041afb303834749869e9ee331721))
|
||||
* Add support to automatically create a session if one does not exist ([8e69a58](https://github.com/google/adk-python/commit/8e69a58df4eadeccbb100b7264bb518a46b61fd7))
|
||||
|
||||
### Features
|
||||
|
||||
* **[Core]**
|
||||
* Remove `@experimental` decorator from `AgentEngineSandboxCodeExecutor` ([135f763](https://github.com/google/adk-python/commit/135f7633253f6a415302142abc3579b664601d5b))
|
||||
* Add `--disable_features` CLI option to override default feature enable state ([53b67ce](https://github.com/google/adk-python/commit/53b67ce6340f3f3f8c3d732f9f7811e445c76359))
|
||||
* Add `otel_to_cloud` flag to `adk deploy agent_engine` command ([21f63f6](https://github.com/google/adk-python/commit/21f63f66ee424501d9a70806277463ef718ae843))
|
||||
* Add `is_computer_use` field to agent information in `adk-web` server ([5923da7](https://github.com/google/adk-python/commit/5923da786eb1aaef6f0bcbc6adc906cbc8bf9b36))
|
||||
* Allow `thinking_config` in `generate_content_config` ([e162bb8](https://github.com/google/adk-python/commit/e162bb8832a806e2380048e39165bf837455f88c))
|
||||
* Convert A2UI messages between A2A `DataPart` metadata and ADK events ([1133ce2](https://github.com/google/adk-python/commit/1133ce219c5a7a9a85222b03e348ba6b13830c8f))
|
||||
* Add `--enable_features` CLI option to override default feature enable state ([79fcddb](https://github.com/google/adk-python/commit/79fcddb39f71a4c1342e63b4d67832b3eccb2652))
|
||||
|
||||
* **[Tools]**
|
||||
* Add flush mechanism to `BigQueryAgentAnalyticsPlugin` to ensure pending log events are written to BigQuery ([9579bea](https://github.com/google/adk-python/commit/9579bea05d946b3d8b4bfec35e510725dd371224))
|
||||
* Allow Google Search tool to set a different model ([b57a3d4](https://github.com/google/adk-python/commit/b57a3d43e4656f5a3c5db53addff02b67d1fde26))
|
||||
* Support authentication for MCP tool listing ([e3d542a](https://github.com/google/adk-python/commit/e3d542a5ba3d357407f8cd29cfdd722f583c8564) [19315fe](https://github.com/google/adk-python/commit/19315fe557039fa8bf446525a4830b1c9f40cba9))
|
||||
* Use JSON schema for `base_retrieval_tool`, `load_artifacts_tool`, and `load_memory_tool` declarations when the feature is enabled ([69ad605](https://github.com/google/adk-python/commit/69ad605bc4bbe9a4f018127fd3625169ee70488e))
|
||||
* Use JSON schema for `IntegrationConnectorTool` declaration when the feature is enabled ([2ed6865](https://github.com/google/adk-python/commit/2ed686527ac75ff64128ce7d9b1a3befc2b37c64))
|
||||
* Start and close `ClientSession` in a single task in `McpSessionManager` ([cce430d](https://github.com/google/adk-python/commit/cce430da799766686e65f6cae02ba64e916d5c8a))
|
||||
* Use JSON schema for `RestApiTool` declaration when the feature is enabled ([a5f0d33](https://github.com/google/adk-python/commit/a5f0d333d7f26f2966ed511d5d9def7a1933f0c2))
|
||||
|
||||
* **[Evals]**
|
||||
* Update `adk eval` CLI to consume custom metrics by adding `CustomMetricEvaluator` ([ea0934b](https://github.com/google/adk-python/commit/ea0934b9934c1fefd129a1026d6af369f126870e))
|
||||
* Update `EvalConfig` and `EvalMetric` data models to support custom metrics ([6d2f33a](https://github.com/google/adk-python/commit/6d2f33a59cfba358dd758378290125fc2701c411))
|
||||
|
||||
* **[Observability]**
|
||||
* Add minimal `generate_content {model.name}` spans and logs for non-Gemini inference and when `opentelemetry-inference-google-genai` dependency is missing ([935c279](https://github.com/google/adk-python/commit/935c279f8281bde99224f03d936b8abe51cbabfc))
|
||||
|
||||
* **[Integrations]**
|
||||
* Enhance `TraceManager` asynchronous safety, enrich BigQuery plugin logging, and fix serialization ([a4116a6](https://github.com/google/adk-python/commit/a4116a6cbfadc161982af5dabd55a711d79348b7))
|
||||
|
||||
* **[Live]**
|
||||
* Persist user input content to session in live mode ([a04828d](https://github.com/google/adk-python/commit/a04828dd8a848482acbd48acc7da432d0d2cb0aa))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Recursively extract input/output schema for AgentTool ([bf2b56d](https://github.com/google/adk-python/commit/bf2b56de6d0052e40b6d871b2d22c56e9225e145))
|
||||
* Yield buffered `function_call` and `function_response` events during live streaming ([7b25b8f](https://github.com/google/adk-python/commit/7b25b8fb1daf54d7694bf405d545d46d2c012d2b))
|
||||
* Update `authlib` and `mcp` dependency versions ([7955177](https://github.com/google/adk-python/commit/7955177fb28b8e5dc19aae8be94015a7b5d9882a))
|
||||
* Set `LITELLM_MODE` to `PRODUCTION` before importing LiteLLM to prevent implicit `.env` file loading ([215c2f5](https://github.com/google/adk-python/commit/215c2f506e21a3d8c39551b80f6356943ecae320))
|
||||
* Redact sensitive information from URIs in logs ([5257869](https://github.com/google/adk-python/commit/5257869d91a77ebd1381538a85e7fdc3a600da90))
|
||||
* Handle asynchronous driver URLs in the migration tool ([4b29d15](https://github.com/google/adk-python/commit/4b29d15b3e5df65f3503daffa6bc7af85159507b))
|
||||
* Remove custom metadata from A2A response events ([81eaeb5](https://github.com/google/adk-python/commit/81eaeb5eba6d40cde0cf6147d96921ed1bf7bb31))
|
||||
* Handle `None` inferences in eval results ([7d4326c](https://github.com/google/adk-python/commit/7d4326c3606a7ff2ba3c0fdef08d4f6af52ee71e))
|
||||
* Mark all parts of a thought event as thought ([f92d4e3](https://github.com/google/adk-python/commit/f92d4e397f37445fe9032a95ce26646a3a69300b))
|
||||
* Use `json.dumps` for error messages in SSE events ([6ad18cc](https://github.com/google/adk-python/commit/6ad18cc2fc3a3315a0fc240cb51b3283b53116b4))
|
||||
* Use the correct path for config-based agents when deploying to AgentEngine ([83d7bb6](https://github.com/google/adk-python/commit/83d7bb6ef0d952ad04c5d9a61aaf202672c7e17d))
|
||||
* Support Generator and Async Generator tool declarations in JSON schema ([19555e7](https://github.com/google/adk-python/commit/19555e7dce6d60c3b960ca0bc2f928c138ac3cc0) [7c28297](https://github.com/google/adk-python/commit/7c282973ea193841fee79f90b8a91c5e02627ccc))
|
||||
* Prevent stopping event processing on events with `None` content ([ed2c3eb](https://github.com/google/adk-python/commit/ed2c3ebde9cafbb5e2bf375f44db1e77cee9fb24))
|
||||
* Fix `'NoneType'` object is not iterable error ([7db3ce9](https://github.com/google/adk-python/commit/7db3ce9613b1c2c97e6ca3cd8115736516dc1556))
|
||||
* Use canonical tools to find streaming tools and register them by `tool.name` ([ec6abf4](https://github.com/google/adk-python/commit/ec6abf401019c39e8e1a8d1b2c7d5cf5e8c7ac56))
|
||||
* Initialize `self._auth_config` inside `BaseAuthenticatedTool` to access authentication headers in `McpTool` ([d4da1bb](https://github.com/google/adk-python/commit/d4da1bb7330cdb87c1dcbe0b9023148357a6bd07))
|
||||
* Only filter out audio content when sending history ([712b5a3](https://github.com/google/adk-python/commit/712b5a393d44e7b5ce35fc459da98361bae4bb16))
|
||||
* Add finish reason mapping and remove custom file URI handling in LiteLLM ([89bed43](https://github.com/google/adk-python/commit/89bed43f5e0c5ad12dd31c716d372145b7e33e78))
|
||||
* Convert unsupported inline artifact MIME types to text in `LoadArtifactsTool` ([fdc98d5](https://github.com/google/adk-python/commit/fdc98d5c927bfef021e87cf72103892e4c2ac12a))
|
||||
* Pass `log_level` to `uvicorn` in `web` and `api_server` commands ([38d52b2](https://github.com/google/adk-python/commit/38d52b247600fb45a2beeb041c4698e90c00d705))
|
||||
* Use the agent name as the author of the audio event ([ab62b1b](https://github.com/google/adk-python/commit/ab62b1bffd7ad2df5809d430ad1823872b8bd67a))
|
||||
* Handle `NOT_FOUND` error when fetching Vertex AI sessions ([75231a3](https://github.com/google/adk-python/commit/75231a30f1857d930804769caf88bcc20839dd08))
|
||||
* Fix `httpx` client closure during event pagination ([b725045](https://github.com/google/adk-python/commit/b725045e5a1192bc9fd5190cbd2758ab6ff02590))
|
||||
|
||||
### Improvements
|
||||
|
||||
* Add new conversational analytics API toolset ([82fa10b](https://github.com/google/adk-python/commit/82fa10b71e037b565cb407c82e9e908432dab0ff))
|
||||
* Filter out `adk_request_input` event from content list ([295b345](https://github.com/google/adk-python/commit/295b34558774d1f64022009980e3edd8eb79527b))
|
||||
* Always skip executing partial function calls ([d62f9c8](https://github.com/google/adk-python/commit/d62f9c896c301aba3a781e868735e16f946a8862))
|
||||
* Update comments of request confirmation preprocessor ([1699b09](https://github.com/google/adk-python/commit/1699b090edc9e5b13c34f461c8e664187157c5c0))
|
||||
* Fix various typos ([a8f2ddd](https://github.com/google/adk-python/commit/a8f2ddd943301bbf53f49b3a23300ece45803cc0))
|
||||
* Update sample live streaming tools agent to use latest live models ([3dd7e3f](https://github.com/google/adk-python/commit/3dd7e3f1b9be05c28adb061864d84c4202a2d922))
|
||||
* Make the regex to catch CLI reference strict by adding word boundary anchor ([c222a45](https://github.com/google/adk-python/commit/c222a45ef74f7b55c48dc151ba98cd8c30a15c57))
|
||||
* Migrate `ToolboxToolset` to use `toolbox-adk` and align validation ([7dc6adf](https://github.com/google/adk-python/commit/7dc6adf4e563330a09e4cf28d2b1994c24b007d1) [277084e](https://github.com/google/adk-python/commit/277084e31368302e6338b69d456affd35d5fedfe))
|
||||
* Always log API backend when connecting to live model ([7b035aa](https://github.com/google/adk-python/commit/7b035aa9fc43a43489aeffea8f877cd7eaa09f35))
|
||||
* Add a sample BigQuery agent using BigQuery MCP tools ([672b57f](https://github.com/google/adk-python/commit/672b57f1b76580023d1f348de76227291a9c1012))
|
||||
* Add a `DebugLoggingPlugin` to record human-readable debugging logs ([8973618](https://github.com/google/adk-python/commit/8973618b0b0e90c513873e22af272c147efb4904))
|
||||
* Upgrade the sample BigQuery agent model version to `gemini-2.5-flash` ([fd2c0f5](https://github.com/google/adk-python/commit/fd2c0f556b786417a9f6add744827b07e7a06b7d))
|
||||
* Import `migration_runner` lazily within the migrate command ([905604f](https://github.com/google/adk-python/commit/905604faac82aca8ae0935eebea288f82985e9c5))
|
||||
|
||||
|
||||
|
||||
## [1.22.1](https://github.com/google/adk-python/compare/v1.22.0...v1.22.1) (2026-01-09)
|
||||
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
# Data Agent Sample
|
||||
|
||||
This sample agent demonstrates ADK's first-party tools for interacting with
|
||||
Data Agents powered by [Conversational Analytics API](https://docs.cloud.google.com/gemini/docs/conversational-analytics-api/overview).
|
||||
These tools are distributed via
|
||||
the `google.adk.tools.data_agent` module and allow you to list,
|
||||
inspect, and
|
||||
chat with Data Agents using natural language.
|
||||
|
||||
These tools leverage stateful conversations, meaning you can ask follow-up
|
||||
questions in the same session, and the agent will maintain context.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. An active Google Cloud project with BigQuery and Gemini APIs enabled.
|
||||
2. Google Cloud authentication configured for Application Default Credentials:
|
||||
```bash
|
||||
gcloud auth application-default login
|
||||
```
|
||||
3. At least one Data Agent created. You could create data agents via
|
||||
[Conversational API](https://docs.cloud.google.com/gemini/docs/conversational-analytics-api/overview),
|
||||
its
|
||||
[Python SDK](https://docs.cloud.google.com/gemini/docs/conversational-analytics-api/build-agent-sdk),
|
||||
or for BigQuery data
|
||||
[BigQuery Studio](https://docs.cloud.google.com/bigquery/docs/create-data-agents#create_a_data_agent).
|
||||
These agents are created and configured in the Google Cloud console and
|
||||
point to your BigQuery tables or other data sources.
|
||||
4. Follow the official
|
||||
[Setup and prerequisites](https://docs.cloud.google.com/gemini/docs/conversational-analytics-api/overview#setup)
|
||||
guide to enable the API and configure IAM permissions and authentication for
|
||||
your data sources.
|
||||
|
||||
## Tools Used
|
||||
|
||||
* `list_accessible_data_agents`: Lists Data Agents you have permission to
|
||||
access in the configured GCP project.
|
||||
* `get_data_agent_info`: Retrieves details about a specific Data Agent given
|
||||
its full resource name.
|
||||
* `ask_data_agent`: Chats with a specific Data Agent using natural language.
|
||||
This tool maintains conversation state: if you ask multiple
|
||||
questions to the same agent in one session, it will use the same
|
||||
conversation, allowing for follow-ups. If you switch agents, a new
|
||||
conversation will be started for the new agent.
|
||||
|
||||
## How to Run
|
||||
|
||||
1. Navigate to the root of the ADK repository.
|
||||
2. Run the agent using the ADK CLI:
|
||||
```bash
|
||||
adk run --agent-path contributing/samples/data_agent
|
||||
```
|
||||
3. The CLI will prompt you for input. You can ask questions like the examples
|
||||
below.
|
||||
|
||||
## Sample prompts
|
||||
|
||||
* "List accessible data agents."
|
||||
* "Using agent
|
||||
`projects/my-project/locations/global/dataAgents/sales-agent-123`, who were
|
||||
my top 3 customers last quarter?"
|
||||
* "How does that compare to the quarter before?"
|
||||
@@ -1,15 +0,0 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from . import agent
|
||||
@@ -1,84 +0,0 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import os
|
||||
|
||||
from google.adk.agents import Agent
|
||||
from google.adk.auth.auth_credential import AuthCredentialTypes
|
||||
from google.adk.tools.data_agent.config import DataAgentToolConfig
|
||||
from google.adk.tools.data_agent.credentials import DataAgentCredentialsConfig
|
||||
from google.adk.tools.data_agent.data_agent_toolset import DataAgentToolset
|
||||
import google.auth
|
||||
import google.auth.transport.requests
|
||||
|
||||
# Define the desired credential type.
|
||||
# By default use Application Default Credentials (ADC) from the local
|
||||
# environment, which can be set up by following
|
||||
# https://cloud.google.com/docs/authentication/provide-credentials-adc.
|
||||
CREDENTIALS_TYPE = None
|
||||
|
||||
if CREDENTIALS_TYPE == AuthCredentialTypes.OAUTH2:
|
||||
# Initiaze the tools to do interactive OAuth
|
||||
# The environment variables OAUTH_CLIENT_ID and OAUTH_CLIENT_SECRET
|
||||
# must be set
|
||||
credentials_config = DataAgentCredentialsConfig(
|
||||
client_id=os.getenv("OAUTH_CLIENT_ID"),
|
||||
client_secret=os.getenv("OAUTH_CLIENT_SECRET"),
|
||||
)
|
||||
elif CREDENTIALS_TYPE == AuthCredentialTypes.SERVICE_ACCOUNT:
|
||||
# Initialize the tools to use the credentials in the service account key.
|
||||
# If this flow is enabled, make sure to replace the file path with your own
|
||||
# service account key file
|
||||
# https://cloud.google.com/iam/docs/service-account-creds#user-managed-keys
|
||||
creds, _ = google.auth.load_credentials_from_file(
|
||||
"service_account_key.json",
|
||||
scopes=["https://www.googleapis.com/auth/cloud-platform"],
|
||||
)
|
||||
creds.refresh(google.auth.transport.requests.Request())
|
||||
credentials_config = DataAgentCredentialsConfig(credentials=creds)
|
||||
else:
|
||||
# Initialize the tools to use the application default credentials.
|
||||
# https://cloud.google.com/docs/authentication/provide-credentials-adc
|
||||
application_default_credentials, _ = google.auth.default()
|
||||
credentials_config = DataAgentCredentialsConfig(
|
||||
credentials=application_default_credentials
|
||||
)
|
||||
|
||||
tool_config = DataAgentToolConfig(
|
||||
max_query_result_rows=100,
|
||||
)
|
||||
da_toolset = DataAgentToolset(
|
||||
credentials_config=credentials_config,
|
||||
data_agent_tool_config=tool_config,
|
||||
tool_filter=[
|
||||
"list_accessible_data_agents",
|
||||
"get_data_agent_info",
|
||||
"ask_data_agent",
|
||||
],
|
||||
)
|
||||
|
||||
root_agent = Agent(
|
||||
name="data_agent",
|
||||
model="gemini-2.0-flash",
|
||||
description="Agent to answer user questions using Data Agents.",
|
||||
instruction=(
|
||||
"## Persona\nYou are a helpful assistant that uses Data Agents"
|
||||
" to answer user questions about their data.\n\n## Tools\n- You can"
|
||||
" list available data agents using `list_accessible_data_agents`.\n-"
|
||||
" You can get information about a specific data agent using"
|
||||
" `get_data_agent_info`.\n- You can chat with a specific data"
|
||||
" agent using `ask_data_agent`.\n"
|
||||
),
|
||||
tools=[da_toolset],
|
||||
)
|
||||
@@ -126,7 +126,6 @@ test = [
|
||||
"litellm>=1.75.5, <1.80.17", # For LiteLLM tests
|
||||
"llama-index-readers-file>=0.4.0", # For retrieval tests
|
||||
"openai>=1.100.2", # For LiteLLM
|
||||
"opentelemetry-instrumentation-google-genai>=0.3b0, <1.0.0",
|
||||
"pytest-asyncio>=0.25.0",
|
||||
"pytest-mock>=3.14.0",
|
||||
"pytest-xdist>=3.6.1",
|
||||
|
||||
@@ -33,8 +33,6 @@ class FeatureName(str, Enum):
|
||||
BIGTABLE_TOOL_SETTINGS = "BIGTABLE_TOOL_SETTINGS"
|
||||
BIGTABLE_TOOLSET = "BIGTABLE_TOOLSET"
|
||||
COMPUTER_USE = "COMPUTER_USE"
|
||||
DATA_AGENT_TOOL_CONFIG = "DATA_AGENT_TOOL_CONFIG"
|
||||
DATA_AGENT_TOOLSET = "DATA_AGENT_TOOLSET"
|
||||
GOOGLE_CREDENTIALS_CONFIG = "GOOGLE_CREDENTIALS_CONFIG"
|
||||
GOOGLE_TOOL = "GOOGLE_TOOL"
|
||||
JSON_SCHEMA_FOR_FUNC_DECL = "JSON_SCHEMA_FOR_FUNC_DECL"
|
||||
@@ -99,12 +97,6 @@ _FEATURE_REGISTRY: dict[FeatureName, FeatureConfig] = {
|
||||
FeatureName.COMPUTER_USE: FeatureConfig(
|
||||
FeatureStage.EXPERIMENTAL, default_on=True
|
||||
),
|
||||
FeatureName.DATA_AGENT_TOOL_CONFIG: FeatureConfig(
|
||||
FeatureStage.EXPERIMENTAL, default_on=True
|
||||
),
|
||||
FeatureName.DATA_AGENT_TOOLSET: FeatureConfig(
|
||||
FeatureStage.EXPERIMENTAL, default_on=True
|
||||
),
|
||||
FeatureName.GOOGLE_CREDENTIALS_CONFIG: FeatureConfig(
|
||||
FeatureStage.EXPERIMENTAL, default_on=True
|
||||
),
|
||||
|
||||
@@ -41,7 +41,6 @@ from ...events.event import Event
|
||||
from ...models.base_llm_connection import BaseLlmConnection
|
||||
from ...models.llm_request import LlmRequest
|
||||
from ...models.llm_response import LlmResponse
|
||||
from ...telemetry import tracing
|
||||
from ...telemetry.tracing import trace_call_llm
|
||||
from ...telemetry.tracing import trace_send_data
|
||||
from ...telemetry.tracing import tracer
|
||||
@@ -772,7 +771,7 @@ class BaseLlmFlow(ABC):
|
||||
llm = self.__get_llm(invocation_context)
|
||||
|
||||
async def _call_llm_with_tracing() -> AsyncGenerator[LlmResponse, None]:
|
||||
with tracer.start_as_current_span('call_llm') as span:
|
||||
with tracer.start_as_current_span('call_llm'):
|
||||
if invocation_context.run_config.support_cfc:
|
||||
invocation_context.live_request_queue = LiveRequestQueue()
|
||||
responses_generator = self.run_live(invocation_context)
|
||||
@@ -823,7 +822,6 @@ class BaseLlmFlow(ABC):
|
||||
model_response_event.id,
|
||||
llm_request,
|
||||
llm_response,
|
||||
span,
|
||||
)
|
||||
# Runs after_model_callback if it exists.
|
||||
if altered_llm_response := await self._handle_after_model_callback(
|
||||
@@ -1052,12 +1050,8 @@ class BaseLlmFlow(ABC):
|
||||
|
||||
try:
|
||||
async with Aclosing(response_generator) as agen:
|
||||
with tracing.use_generate_content_span(
|
||||
llm_request, invocation_context, model_response_event
|
||||
) as span:
|
||||
async for llm_response in agen:
|
||||
tracing.trace_generate_content_result(span, llm_response)
|
||||
yield llm_response
|
||||
async for response in agen:
|
||||
yield response
|
||||
except Exception as model_error:
|
||||
callback_context = CallbackContext(
|
||||
invocation_context, event_actions=model_response_event.actions
|
||||
|
||||
@@ -82,7 +82,7 @@ class DebugLoggingPlugin(BasePlugin):
|
||||
Example:
|
||||
>>> debug_plugin = DebugLoggingPlugin(output_path="/tmp/adk_debug.yaml")
|
||||
>>> runner = Runner(
|
||||
... agent=my_agent,
|
||||
... agents=[my_agent],
|
||||
... plugins=[debug_plugin],
|
||||
... )
|
||||
|
||||
|
||||
@@ -23,59 +23,35 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from collections.abc import Mapping
|
||||
from contextlib import contextmanager
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Any
|
||||
from typing import Optional
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from google.genai import types
|
||||
from google.genai.models import Models
|
||||
from opentelemetry import _logs
|
||||
from opentelemetry import trace
|
||||
from opentelemetry._logs import LogRecord
|
||||
from opentelemetry.semconv._incubating.attributes.gen_ai_attributes import GEN_AI_AGENT_DESCRIPTION
|
||||
from opentelemetry.semconv._incubating.attributes.gen_ai_attributes import GEN_AI_AGENT_NAME
|
||||
from opentelemetry.semconv._incubating.attributes.gen_ai_attributes import GEN_AI_CONVERSATION_ID
|
||||
from opentelemetry.semconv._incubating.attributes.gen_ai_attributes import GEN_AI_OPERATION_NAME
|
||||
from opentelemetry.semconv._incubating.attributes.gen_ai_attributes import GEN_AI_REQUEST_MODEL
|
||||
from opentelemetry.semconv._incubating.attributes.gen_ai_attributes import GEN_AI_RESPONSE_FINISH_REASONS
|
||||
from opentelemetry.semconv._incubating.attributes.gen_ai_attributes import GEN_AI_SYSTEM
|
||||
from opentelemetry.semconv._incubating.attributes.gen_ai_attributes import GEN_AI_TOOL_CALL_ID
|
||||
from opentelemetry.semconv._incubating.attributes.gen_ai_attributes import GEN_AI_TOOL_DESCRIPTION
|
||||
from opentelemetry.semconv._incubating.attributes.gen_ai_attributes import GEN_AI_TOOL_NAME
|
||||
from opentelemetry.semconv._incubating.attributes.gen_ai_attributes import GEN_AI_TOOL_TYPE
|
||||
from opentelemetry.semconv._incubating.attributes.gen_ai_attributes import GEN_AI_USAGE_INPUT_TOKENS
|
||||
from opentelemetry.semconv._incubating.attributes.gen_ai_attributes import GEN_AI_USAGE_OUTPUT_TOKENS
|
||||
from opentelemetry.semconv._incubating.attributes.gen_ai_attributes import GenAiSystemValues
|
||||
from opentelemetry.semconv.schemas import Schemas
|
||||
from opentelemetry.trace import Span
|
||||
from opentelemetry.util.types import AnyValue
|
||||
from opentelemetry.util.types import AttributeValue
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .. import version
|
||||
from ..utils.model_name_utils import is_gemini_model
|
||||
from ..events.event import Event
|
||||
|
||||
# By default some ADK spans include attributes with potential PII data.
|
||||
# This env, when set to false, allows to disable populating those attributes.
|
||||
ADK_CAPTURE_MESSAGE_CONTENT_IN_SPANS = 'ADK_CAPTURE_MESSAGE_CONTENT_IN_SPANS'
|
||||
|
||||
# Standard OTEL env variable to enable logging of prompt/response content.
|
||||
OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT = (
|
||||
'OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT'
|
||||
)
|
||||
|
||||
USER_CONTENT_ELIDED = '<elided>'
|
||||
# TODO: Replace with constant from opentelemetry.semconv when it reaches version 1.37 in g3.
|
||||
GEN_AI_AGENT_DESCRIPTION = 'gen_ai.agent.description'
|
||||
GEN_AI_AGENT_NAME = 'gen_ai.agent.name'
|
||||
GEN_AI_CONVERSATION_ID = 'gen_ai.conversation.id'
|
||||
GEN_AI_OPERATION_NAME = 'gen_ai.operation.name'
|
||||
GEN_AI_TOOL_CALL_ID = 'gen_ai.tool.call.id'
|
||||
GEN_AI_TOOL_DESCRIPTION = 'gen_ai.tool.description'
|
||||
GEN_AI_TOOL_NAME = 'gen_ai.tool.name'
|
||||
GEN_AI_TOOL_TYPE = 'gen_ai.tool.type'
|
||||
|
||||
# Needed to avoid circular imports
|
||||
if TYPE_CHECKING:
|
||||
from ..agents.base_agent import BaseAgent
|
||||
from ..agents.invocation_context import InvocationContext
|
||||
from ..events.event import Event
|
||||
from ..models.llm_request import LlmRequest
|
||||
from ..models.llm_response import LlmResponse
|
||||
from ..tools.base_tool import BaseTool
|
||||
@@ -83,17 +59,10 @@ if TYPE_CHECKING:
|
||||
tracer = trace.get_tracer(
|
||||
instrumenting_module_name='gcp.vertex.agent',
|
||||
instrumenting_library_version=version.__version__,
|
||||
schema_url=Schemas.V1_36_0.value,
|
||||
# TODO: Replace with constant from opentelemetry.semconv when it reaches version 1.37 in g3.
|
||||
schema_url='https://opentelemetry.io/schemas/1.37.0',
|
||||
)
|
||||
|
||||
otel_logger = _logs.get_logger(
|
||||
instrumenting_module_name='gcp.vertex.agent',
|
||||
instrumenting_library_version=version.__version__,
|
||||
schema_url=Schemas.V1_36_0.value,
|
||||
)
|
||||
|
||||
logger = logging.getLogger('google_adk.' + __name__)
|
||||
|
||||
|
||||
def _safe_json_serialize(obj) -> str:
|
||||
"""Convert any Python object to a JSON-serializable type or string.
|
||||
@@ -150,7 +119,7 @@ def trace_agent_invocation(
|
||||
def trace_tool_call(
|
||||
tool: BaseTool,
|
||||
args: dict[str, Any],
|
||||
function_response_event: Event | None,
|
||||
function_response_event: Optional[Event],
|
||||
):
|
||||
"""Traces tool call.
|
||||
|
||||
@@ -265,7 +234,6 @@ def trace_call_llm(
|
||||
event_id: str,
|
||||
llm_request: LlmRequest,
|
||||
llm_response: LlmResponse,
|
||||
span: Span | None = None,
|
||||
):
|
||||
"""Traces a call to the LLM.
|
||||
|
||||
@@ -278,7 +246,7 @@ def trace_call_llm(
|
||||
llm_request: The LLM request object.
|
||||
llm_response: The LLM response object.
|
||||
"""
|
||||
span = span or trace.get_current_span()
|
||||
span = trace.get_current_span()
|
||||
# Special standard Open Telemetry GenaI attributes that indicate
|
||||
# that this is a span related to a Generative AI system.
|
||||
span.set_attribute('gen_ai.system', 'gcp.vertex.agent')
|
||||
@@ -422,167 +390,3 @@ def _should_add_request_response_to_spans() -> bool:
|
||||
ADK_CAPTURE_MESSAGE_CONTENT_IN_SPANS, 'true'
|
||||
).lower() in ('false', '0')
|
||||
return not disabled_via_env_var
|
||||
|
||||
|
||||
@contextmanager
|
||||
def use_generate_content_span(
|
||||
llm_request: LlmRequest,
|
||||
invocation_context: InvocationContext,
|
||||
model_response_event: Event,
|
||||
) -> Iterator[Span | None]:
|
||||
"""Context manager encompassing `generate_content {model.name}` span.
|
||||
|
||||
When an external library for inference instrumentation is installed (e.g. opentelemetry-instrumentation-google-genai),
|
||||
span creation is delegated to said library.
|
||||
"""
|
||||
|
||||
common_attributes = {
|
||||
GEN_AI_CONVERSATION_ID: invocation_context.session.id,
|
||||
'gcp.vertex.agent.event_id': model_response_event.id,
|
||||
}
|
||||
if (
|
||||
_is_gemini_agent(invocation_context.agent)
|
||||
and _instrumented_with_opentelemetry_instrumentation_google_genai()
|
||||
):
|
||||
yield None
|
||||
else:
|
||||
with _use_native_generate_content_span(
|
||||
llm_request=llm_request,
|
||||
common_attributes=common_attributes,
|
||||
) as span:
|
||||
yield span
|
||||
|
||||
|
||||
def _should_log_prompt_response_content() -> bool:
|
||||
return os.getenv(
|
||||
OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT, ''
|
||||
).lower() in ('1', 'true')
|
||||
|
||||
|
||||
def _serialize_content(content: types.ContentUnion) -> AnyValue:
|
||||
if isinstance(content, BaseModel):
|
||||
return content.model_dump()
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
return [_serialize_content(part) for part in content]
|
||||
return _safe_json_serialize(content)
|
||||
|
||||
|
||||
def _serialize_content_with_elision(
|
||||
content: types.ContentUnion | None,
|
||||
) -> AnyValue:
|
||||
if not _should_log_prompt_response_content():
|
||||
return USER_CONTENT_ELIDED
|
||||
if content is None:
|
||||
return None
|
||||
return _serialize_content(content)
|
||||
|
||||
|
||||
def _instrumented_with_opentelemetry_instrumentation_google_genai() -> bool:
|
||||
maybe_wrapped_function = Models.generate_content
|
||||
print(f'{Models.generate_content.__code__.co_filename=}')
|
||||
while wrapped := getattr(maybe_wrapped_function, '__wrapped__', None):
|
||||
if (
|
||||
'opentelemetry/instrumentation/google_genai'
|
||||
in maybe_wrapped_function.__code__.co_filename
|
||||
):
|
||||
return True
|
||||
maybe_wrapped_function = wrapped # pyright: ignore[reportAny]
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _is_gemini_agent(agent: BaseAgent) -> bool:
|
||||
from ..agents.llm_agent import LlmAgent
|
||||
|
||||
if not isinstance(agent, LlmAgent):
|
||||
return False
|
||||
|
||||
if isinstance(agent.model, str):
|
||||
return is_gemini_model(agent.model)
|
||||
|
||||
from ..models.google_llm import Gemini
|
||||
|
||||
return isinstance(agent.model, Gemini)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _use_native_generate_content_span(
|
||||
llm_request: LlmRequest,
|
||||
common_attributes: Mapping[str, AttributeValue],
|
||||
) -> Iterator[Span]:
|
||||
with tracer.start_as_current_span(
|
||||
f"generate_content {llm_request.model or ''}"
|
||||
) as span:
|
||||
span.set_attribute(GEN_AI_SYSTEM, _guess_gemini_system_name())
|
||||
span.set_attribute(GEN_AI_OPERATION_NAME, 'generate_content')
|
||||
span.set_attribute(GEN_AI_REQUEST_MODEL, llm_request.model or '')
|
||||
span.set_attributes(common_attributes)
|
||||
|
||||
otel_logger.emit(
|
||||
LogRecord(
|
||||
event_name='gen_ai.system.message',
|
||||
body={
|
||||
'content': _serialize_content_with_elision(
|
||||
llm_request.config.system_instruction
|
||||
)
|
||||
},
|
||||
attributes={GEN_AI_SYSTEM: _guess_gemini_system_name()},
|
||||
)
|
||||
)
|
||||
|
||||
for content in llm_request.contents:
|
||||
otel_logger.emit(
|
||||
LogRecord(
|
||||
event_name='gen_ai.user.message',
|
||||
body={'content': _serialize_content_with_elision(content)},
|
||||
attributes={GEN_AI_SYSTEM: _guess_gemini_system_name()},
|
||||
)
|
||||
)
|
||||
|
||||
yield span
|
||||
|
||||
|
||||
def trace_generate_content_result(span: Span | None, llm_response: LlmResponse):
|
||||
"""Trace result of the inference in generate_content span."""
|
||||
|
||||
if span is None:
|
||||
return
|
||||
|
||||
if llm_response.partial:
|
||||
return
|
||||
|
||||
if finish_reason := llm_response.finish_reason:
|
||||
span.set_attribute(GEN_AI_RESPONSE_FINISH_REASONS, [finish_reason.lower()])
|
||||
if usage_metadata := llm_response.usage_metadata:
|
||||
if usage_metadata.prompt_token_count is not None:
|
||||
span.set_attribute(
|
||||
GEN_AI_USAGE_INPUT_TOKENS, usage_metadata.prompt_token_count
|
||||
)
|
||||
if usage_metadata.candidates_token_count is not None:
|
||||
span.set_attribute(
|
||||
GEN_AI_USAGE_OUTPUT_TOKENS, usage_metadata.candidates_token_count
|
||||
)
|
||||
|
||||
otel_logger.emit(
|
||||
LogRecord(
|
||||
event_name='gen_ai.choice',
|
||||
body={
|
||||
'content': _serialize_content_with_elision(llm_response.content),
|
||||
'index': 0, # ADK always returns a single candidate
|
||||
}
|
||||
| {'finish_reason': llm_response.finish_reason.value}
|
||||
if llm_response.finish_reason is not None
|
||||
else {},
|
||||
attributes={GEN_AI_SYSTEM: _guess_gemini_system_name()},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _guess_gemini_system_name() -> str:
|
||||
return (
|
||||
GenAiSystemValues.VERTEX_AI.name.lower()
|
||||
if os.getenv('GOOGLE_GENAI_USE_VERTEXAI', '').lower() in ('true', '1')
|
||||
else GenAiSystemValues.GEMINI.name.lower()
|
||||
)
|
||||
|
||||
@@ -15,11 +15,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from typing import Optional
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from google.genai import types
|
||||
from pydantic import BaseModel
|
||||
from pydantic import model_validator
|
||||
from typing_extensions import override
|
||||
|
||||
@@ -39,56 +37,6 @@ if TYPE_CHECKING:
|
||||
from ..agents.base_agent import BaseAgent
|
||||
|
||||
|
||||
def _get_input_schema(agent: BaseAgent) -> Optional[type[BaseModel]]:
|
||||
"""Extracts the input_schema from an agent.
|
||||
|
||||
For LlmAgent, returns its input_schema directly.
|
||||
For agents with sub_agents, recursively searches the first sub-agent for an
|
||||
input_schema.
|
||||
|
||||
Args:
|
||||
agent: The agent to extract input_schema from.
|
||||
|
||||
Returns:
|
||||
The input_schema if found, None otherwise.
|
||||
"""
|
||||
from ..agents.llm_agent import LlmAgent
|
||||
|
||||
if isinstance(agent, LlmAgent):
|
||||
return agent.input_schema
|
||||
|
||||
# For composite agents, check the first sub-agent
|
||||
if agent.sub_agents:
|
||||
return _get_input_schema(agent.sub_agents[0])
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _get_output_schema(agent: BaseAgent) -> Optional[type[BaseModel]]:
|
||||
"""Extracts the output_schema from an agent.
|
||||
|
||||
For LlmAgent, returns its output_schema directly.
|
||||
For agents with sub_agents, recursively searches the last sub-agent for an
|
||||
output_schema.
|
||||
|
||||
Args:
|
||||
agent: The agent to extract output_schema from.
|
||||
|
||||
Returns:
|
||||
The output_schema if found, None otherwise.
|
||||
"""
|
||||
from ..agents.llm_agent import LlmAgent
|
||||
|
||||
if isinstance(agent, LlmAgent):
|
||||
return agent.output_schema
|
||||
|
||||
# For composite agents, check the last sub-agent
|
||||
if agent.sub_agents:
|
||||
return _get_output_schema(agent.sub_agents[-1])
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class AgentTool(BaseTool):
|
||||
"""A tool that wraps an agent.
|
||||
|
||||
@@ -126,14 +74,12 @@ class AgentTool(BaseTool):
|
||||
|
||||
@override
|
||||
def _get_declaration(self) -> types.FunctionDeclaration:
|
||||
from ..agents.llm_agent import LlmAgent
|
||||
from ..utils.variant_utils import GoogleLLMVariant
|
||||
|
||||
input_schema = _get_input_schema(self.agent)
|
||||
output_schema = _get_output_schema(self.agent)
|
||||
|
||||
if input_schema:
|
||||
if isinstance(self.agent, LlmAgent) and self.agent.input_schema:
|
||||
result = _automatic_function_calling_util.build_function_declaration(
|
||||
func=input_schema, variant=self._api_variant
|
||||
func=self.agent.input_schema, variant=self._api_variant
|
||||
)
|
||||
# Override the description with the agent's description
|
||||
result.description = self.agent.description
|
||||
@@ -168,7 +114,7 @@ class AgentTool(BaseTool):
|
||||
# Set response schema for non-GEMINI_API variants
|
||||
if self._api_variant != GoogleLLMVariant.GEMINI_API:
|
||||
# Determine response type based on agent's output schema
|
||||
if output_schema:
|
||||
if isinstance(self.agent, LlmAgent) and self.agent.output_schema:
|
||||
# Agent has structured output schema - response is an object
|
||||
if is_feature_enabled(FeatureName.JSON_SCHEMA_FOR_FUNC_DECL):
|
||||
result.response_json_schema = {'type': 'object'}
|
||||
@@ -191,15 +137,15 @@ class AgentTool(BaseTool):
|
||||
args: dict[str, Any],
|
||||
tool_context: ToolContext,
|
||||
) -> Any:
|
||||
from ..agents.llm_agent import LlmAgent
|
||||
from ..runners import Runner
|
||||
from ..sessions.in_memory_session_service import InMemorySessionService
|
||||
|
||||
if self.skip_summarization:
|
||||
tool_context.actions.skip_summarization = True
|
||||
|
||||
input_schema = _get_input_schema(self.agent)
|
||||
if input_schema:
|
||||
input_value = input_schema.model_validate(args)
|
||||
if isinstance(self.agent, LlmAgent) and self.agent.input_schema:
|
||||
input_value = self.agent.input_schema.model_validate(args)
|
||||
content = types.Content(
|
||||
role='user',
|
||||
parts=[
|
||||
@@ -266,11 +212,10 @@ class AgentTool(BaseTool):
|
||||
merged_text = '\n'.join(
|
||||
p.text for p in last_content.parts if p.text and not p.thought
|
||||
)
|
||||
output_schema = _get_output_schema(self.agent)
|
||||
if output_schema:
|
||||
tool_result = output_schema.model_validate_json(merged_text).model_dump(
|
||||
exclude_none=True
|
||||
)
|
||||
if isinstance(self.agent, LlmAgent) and self.agent.output_schema:
|
||||
tool_result = self.agent.output_schema.model_validate_json(
|
||||
merged_text
|
||||
).model_dump(exclude_none=True)
|
||||
else:
|
||||
tool_result = merged_text
|
||||
return tool_result
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Data Agent Tools."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .credentials import DataAgentCredentialsConfig
|
||||
from .data_agent_toolset import DataAgentToolset
|
||||
|
||||
__all__ = [
|
||||
"DataAgentCredentialsConfig",
|
||||
"DataAgentToolset",
|
||||
]
|
||||
@@ -1,35 +0,0 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import ConfigDict
|
||||
|
||||
from ...features import experimental
|
||||
from ...features import FeatureName
|
||||
|
||||
|
||||
@experimental(FeatureName.DATA_AGENT_TOOL_CONFIG)
|
||||
class DataAgentToolConfig(BaseModel):
|
||||
"""Configuration for Data Agent tools."""
|
||||
|
||||
# Forbid any fields not defined in the model
|
||||
model_config = ConfigDict(extra='forbid')
|
||||
|
||||
max_query_result_rows: int = 50
|
||||
"""Maximum number of rows to return from a query.
|
||||
|
||||
By default, the query result will be limited to 50 rows.
|
||||
"""
|
||||
@@ -1,36 +0,0 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .._google_credentials import BaseGoogleCredentialsConfig
|
||||
|
||||
DATA_AGENT_TOKEN_CACHE_KEY = "data_agent_token_cache"
|
||||
DATA_AGENT_DEFAULT_SCOPE = ["https://www.googleapis.com/auth/bigquery"]
|
||||
|
||||
|
||||
class DataAgentCredentialsConfig(BaseGoogleCredentialsConfig):
|
||||
"""Data Agent Credentials Configuration for Google API tools."""
|
||||
|
||||
def __post_init__(self) -> DataAgentCredentialsConfig:
|
||||
"""Populate default scope if scopes is None."""
|
||||
super().__post_init__()
|
||||
|
||||
if not self.scopes:
|
||||
self.scopes = DATA_AGENT_DEFAULT_SCOPE
|
||||
|
||||
# Set the token cache key
|
||||
self._token_cache_key = DATA_AGENT_TOKEN_CACHE_KEY
|
||||
|
||||
return self
|
||||
@@ -1,491 +0,0 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from google.auth.credentials import Credentials
|
||||
import requests
|
||||
|
||||
from ..tool_context import ToolContext
|
||||
from .config import DataAgentToolConfig
|
||||
|
||||
BASE_URL = "https://geminidataanalytics.googleapis.com/v1beta"
|
||||
|
||||
|
||||
def _get_http_headers(
|
||||
credentials: Credentials,
|
||||
) -> dict[str, str]:
|
||||
"""Prepares headers for HTTP requests."""
|
||||
if not credentials.token:
|
||||
error_details = (
|
||||
"The provided credentials object does not have a valid access"
|
||||
" token.\n\nThis is often because the credentials need to be"
|
||||
" refreshed or require specific API scopes. Please ensure the"
|
||||
" credentials are prepared correctly before calling this"
|
||||
" function.\n\nThere may be other underlying causes as well."
|
||||
)
|
||||
raise ValueError(error_details)
|
||||
return {
|
||||
"Authorization": f"Bearer {credentials.token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
|
||||
def list_accessible_data_agents(
|
||||
project_id: str,
|
||||
credentials: Credentials,
|
||||
) -> dict[str, Any]:
|
||||
"""Lists accessible data agents in a project.
|
||||
|
||||
Args:
|
||||
project_id: The project to list agents in.
|
||||
credentials: The credentials to use for the request.
|
||||
|
||||
Returns:
|
||||
A dictionary containing the status and a list of data agents with their
|
||||
detailed information, including name, display_name, description (if
|
||||
available), create_time, update_time, and data_analytics_agent context,
|
||||
or error details if the request fails.
|
||||
|
||||
Examples:
|
||||
>>> list_accessible_data_agents(
|
||||
... project_id="my-gcp-project",
|
||||
... credentials=credentials,
|
||||
... )
|
||||
{
|
||||
"status": "SUCCESS",
|
||||
"response": [
|
||||
{
|
||||
"name": "projects/my-project/locations/global/dataAgents/agent1",
|
||||
"displayName": "My Test Agent",
|
||||
"createTime": "2025-10-01T22:44:22.473927629Z",
|
||||
"updateTime": "2025-10-01T22:44:23.094541325Z",
|
||||
"dataAnalyticsAgent": {
|
||||
"publishedContext": {
|
||||
"datasourceReferences": [{
|
||||
"bq": {
|
||||
"tableReferences": [{
|
||||
"projectId": "my-project",
|
||||
"datasetId": "dataset1",
|
||||
"tableId": "table1"
|
||||
}]
|
||||
}
|
||||
}]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "projects/my-project/locations/global/dataAgents/agent2",
|
||||
"displayName": "",
|
||||
"description": "Description for Agent 2.",
|
||||
"createTime": "2025-06-23T20:23:48.650597312Z",
|
||||
"updateTime": "2025-06-23T20:23:49.437095391Z",
|
||||
"dataAnalyticsAgent": {
|
||||
"publishedContext": {
|
||||
"datasourceReferences": [{
|
||||
"bq": {
|
||||
"tableReferences": [{
|
||||
"projectId": "another-project",
|
||||
"datasetId": "dataset2",
|
||||
"tableId": "table2"
|
||||
}]
|
||||
}
|
||||
}],
|
||||
"systemInstruction": "You are a helpful assistant.",
|
||||
"options": {"analysis": {"python": {"enabled": True}}}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
try:
|
||||
headers = _get_http_headers(credentials)
|
||||
list_url = f"{BASE_URL}/projects/{project_id}/locations/global/dataAgents:listAccessible"
|
||||
resp = requests.get(
|
||||
list_url,
|
||||
headers=headers,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return {
|
||||
"status": "SUCCESS",
|
||||
"response": resp.json().get("dataAgents", []),
|
||||
}
|
||||
except Exception as ex: # pylint: disable=broad-except
|
||||
return {
|
||||
"status": "ERROR",
|
||||
"error_details": repr(ex),
|
||||
}
|
||||
|
||||
|
||||
def get_data_agent_info(
|
||||
data_agent_name: str,
|
||||
credentials: Credentials,
|
||||
) -> dict[str, Any]:
|
||||
"""Gets a data agent by name.
|
||||
|
||||
Args:
|
||||
data_agent_name: The name of the agent to get, in format
|
||||
projects/{project}/locations/{location}/dataAgents/{agent}.
|
||||
credentials: The credentials to use for the request.
|
||||
|
||||
Returns:
|
||||
A dictionary containing the status and details of a data agent,
|
||||
including name, display_name, description (if available),
|
||||
create_time, update_time, and data_analytics_agent context,
|
||||
or error details if the request fails.
|
||||
|
||||
Examples:
|
||||
>>> get_data_agent_info(
|
||||
...
|
||||
data_agent_name="projects/my-project/locations/global/dataAgents/agent-1",
|
||||
... credentials=credentials,
|
||||
... )
|
||||
{
|
||||
"status": "SUCCESS",
|
||||
"response": {
|
||||
"name": "projects/my-project/locations/global/dataAgents/agent-1",
|
||||
"description": "Description for Agent 1.",
|
||||
"createTime": "2025-06-23T20:23:48.650597312Z",
|
||||
"updateTime": "2025-06-23T20:23:49.437095391Z",
|
||||
"dataAnalyticsAgent": {
|
||||
"publishedContext": {
|
||||
"systemInstruction": "You are a helpful assistant.",
|
||||
"options": {"analysis": {"python": {"enabled": True}}},
|
||||
"datasourceReferences": {
|
||||
"bq": {
|
||||
"tableReferences": [{
|
||||
"projectId": "my-gcp-project",
|
||||
"datasetId": "dataset1",
|
||||
"tableId": "table1"
|
||||
}]
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
headers = _get_http_headers(credentials)
|
||||
get_url = f"{BASE_URL}/{data_agent_name}"
|
||||
resp = requests.get(
|
||||
get_url,
|
||||
headers=headers,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return {
|
||||
"status": "SUCCESS",
|
||||
"response": resp.json(),
|
||||
}
|
||||
except Exception as ex: # pylint: disable=broad-except
|
||||
return {
|
||||
"status": "ERROR",
|
||||
"error_details": repr(ex),
|
||||
}
|
||||
|
||||
|
||||
def ask_data_agent(
|
||||
data_agent_name: str,
|
||||
query: str,
|
||||
*,
|
||||
credentials: Credentials,
|
||||
settings: DataAgentToolConfig,
|
||||
tool_context: ToolContext,
|
||||
) -> dict[str, Any]:
|
||||
"""Asks a question to a data agent.
|
||||
|
||||
Args:
|
||||
data_agent_name: The resource name of an existing data agent to ask, in
|
||||
format projects/{project}/locations/{location}/dataAgents/{agent}.
|
||||
query: The question to ask the agent.
|
||||
credentials: The credentials to use for the request.
|
||||
tool_context: The context for the tool.
|
||||
|
||||
Returns:
|
||||
A dictionary with two keys:
|
||||
- 'status': A string indicating the final status (e.g., "SUCCESS").
|
||||
- 'response': A list of dictionaries, where each dictionary
|
||||
represents a step in the agent's execution process (e.g., SQL
|
||||
generation, data retrieval, final answer). Note that the 'Answer'
|
||||
step contains a text response which may summarize findings or refer
|
||||
to previous steps of agent execution, such as 'Data Retrieved', in
|
||||
which cases, the 'Answer' step does not include the result data.
|
||||
|
||||
Examples:
|
||||
A query to a data agent, showing the full return structure.
|
||||
The original question: "Which customer from New York spent the most last
|
||||
month?"
|
||||
|
||||
>>> ask_data_agent(
|
||||
...
|
||||
data_agent_name="projects/my-project/locations/global/dataAgents/sales-agent",
|
||||
... query="Which customer from New York spent the most last month?",
|
||||
... credentials=credentials,
|
||||
... tool_context=tool_context,
|
||||
... )
|
||||
{
|
||||
"status": "SUCCESS",
|
||||
"response": [
|
||||
{
|
||||
"Question": "Which customer from New York spent the most last
|
||||
month?"
|
||||
},
|
||||
{
|
||||
"Schema Resolved": [
|
||||
{
|
||||
"source_name": "my-gcp-project.sales_data.customers",
|
||||
"schema": {
|
||||
"headers": ["Column", "Type", "Description", "Mode"],
|
||||
"rows": [
|
||||
["customer_id", "INT64", "Customer ID", "REQUIRED"],
|
||||
["customer_name", "STRING", "Customer Name", "NULLABLE"],
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"Retrieval Query": {
|
||||
"Query Name": "top_spender",
|
||||
"Question": "Find top spending customer from New York in the last
|
||||
month."
|
||||
}
|
||||
},
|
||||
{
|
||||
"SQL Generated": "SELECT t1.customer_name, SUM(t2.order_total) ... "
|
||||
},
|
||||
{
|
||||
"Data Retrieved": {
|
||||
"headers": ["customer_name", "total_spent"],
|
||||
"rows": [["Jane Doe", 1234.56]],
|
||||
"summary": "Showing all 1 rows."
|
||||
}
|
||||
},
|
||||
{
|
||||
"Answer": "The customer who spent the most last month was Jane Doe."
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
try:
|
||||
headers = _get_http_headers(credentials)
|
||||
|
||||
agent_info = get_data_agent_info(data_agent_name, credentials)
|
||||
if agent_info.get("status") == "ERROR":
|
||||
return agent_info
|
||||
parent = data_agent_name.rsplit("/", 2)[0]
|
||||
chat_url = f"{BASE_URL}/{parent}:chat"
|
||||
chat_payload = {
|
||||
"messages": [{"userMessage": {"text": query}}],
|
||||
"dataAgentContext": {
|
||||
"dataAgent": data_agent_name,
|
||||
},
|
||||
"clientIdEnum": "GOOGLE_ADK",
|
||||
}
|
||||
resp = _get_stream(
|
||||
chat_url,
|
||||
chat_payload,
|
||||
headers=headers,
|
||||
max_query_result_rows=settings.max_query_result_rows,
|
||||
)
|
||||
return {"status": "SUCCESS", "response": resp}
|
||||
except Exception as ex: # pylint: disable=broad-except
|
||||
return {
|
||||
"status": "ERROR",
|
||||
"error_details": repr(ex),
|
||||
}
|
||||
|
||||
|
||||
def _get_stream(
|
||||
url: str,
|
||||
ca_payload: dict[str, Any],
|
||||
*,
|
||||
headers: dict[str, str],
|
||||
max_query_result_rows: int,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Sends a JSON request to a streaming API and returns a list of messages."""
|
||||
s = requests.Session()
|
||||
|
||||
accumulator = ""
|
||||
messages = []
|
||||
|
||||
with s.post(url, json=ca_payload, headers=headers, stream=True) as resp:
|
||||
for line in resp.iter_lines():
|
||||
if not line:
|
||||
continue
|
||||
|
||||
decoded_line = str(line, encoding="utf-8")
|
||||
|
||||
if decoded_line == "[{":
|
||||
accumulator = "{"
|
||||
elif decoded_line == "}]":
|
||||
accumulator += "}"
|
||||
elif decoded_line == ",":
|
||||
continue
|
||||
else:
|
||||
accumulator += decoded_line
|
||||
|
||||
try:
|
||||
data_json = json.loads(accumulator)
|
||||
except ValueError:
|
||||
continue
|
||||
if "systemMessage" not in data_json:
|
||||
if "error" in data_json:
|
||||
_append_message(
|
||||
messages,
|
||||
_handle_error(data_json["error"]),
|
||||
)
|
||||
continue
|
||||
|
||||
system_message = data_json["systemMessage"]
|
||||
if "text" in system_message:
|
||||
_append_message(
|
||||
messages,
|
||||
_handle_text_response(system_message["text"]),
|
||||
)
|
||||
elif "schema" in system_message:
|
||||
_append_message(
|
||||
messages,
|
||||
_handle_schema_response(system_message["schema"]),
|
||||
)
|
||||
elif "data" in system_message:
|
||||
_append_message(
|
||||
messages,
|
||||
_handle_data_response(
|
||||
system_message["data"], max_query_result_rows
|
||||
),
|
||||
)
|
||||
accumulator = ""
|
||||
return messages
|
||||
|
||||
|
||||
def _format_bq_table_ref(table_ref: dict[str, str]) -> str:
|
||||
"""Formats a BigQuery table reference dictionary into a string."""
|
||||
return f"{table_ref.get('projectId')}.{table_ref.get('datasetId')}.{table_ref.get('tableId')}"
|
||||
|
||||
|
||||
def _format_schema_as_dict(
|
||||
data: dict[str, Any],
|
||||
) -> dict[str, list[Any]]:
|
||||
"""Extracts schema fields into a dictionary."""
|
||||
fields = data.get("fields", [])
|
||||
if not fields:
|
||||
return {"columns": []}
|
||||
|
||||
column_details = []
|
||||
headers = ["Column", "Type", "Description", "Mode"]
|
||||
rows: list[list[str, str, str, str]] = []
|
||||
for field in fields:
|
||||
row_list = [
|
||||
field.get("name", ""),
|
||||
field.get("type", ""),
|
||||
field.get("description", ""),
|
||||
field.get("mode", ""),
|
||||
]
|
||||
rows.append(row_list)
|
||||
|
||||
return {"headers": headers, "rows": rows}
|
||||
|
||||
|
||||
def _format_datasource_as_dict(datasource: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Formats a full datasource object into a dictionary with its name and schema."""
|
||||
source_name = _format_bq_table_ref(datasource["bigqueryTableReference"])
|
||||
|
||||
schema = _format_schema_as_dict(datasource["schema"])
|
||||
return {"source_name": source_name, "schema": schema}
|
||||
|
||||
|
||||
def _handle_text_response(resp: dict[str, Any]) -> dict[str, str]:
|
||||
"""Formats a text response into a dictionary."""
|
||||
parts = resp.get("parts", [])
|
||||
return {"Answer": "".join(parts)}
|
||||
|
||||
|
||||
def _handle_schema_response(resp: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Formats a schema response into a dictionary."""
|
||||
if "query" in resp:
|
||||
return {"Question": resp["query"].get("question", "")}
|
||||
elif "result" in resp:
|
||||
datasources = resp["result"].get("datasources", [])
|
||||
# Format each datasource and join them with newlines
|
||||
formatted_sources = [_format_datasource_as_dict(ds) for ds in datasources]
|
||||
return {"Schema Resolved": formatted_sources}
|
||||
return {}
|
||||
|
||||
|
||||
def _handle_data_response(
|
||||
resp: dict[str, Any], max_query_result_rows: int
|
||||
) -> dict[str, Any]:
|
||||
"""Formats a data response into a dictionary."""
|
||||
if "query" in resp:
|
||||
query = resp["query"]
|
||||
return {
|
||||
"Retrieval Query": {
|
||||
"Query Name": query.get("name", "N/A"),
|
||||
"Question": query.get("question", "N/A"),
|
||||
}
|
||||
}
|
||||
elif "generatedSql" in resp:
|
||||
return {"SQL Generated": resp["generatedSql"]}
|
||||
elif "result" in resp:
|
||||
schema = resp["result"]["schema"]
|
||||
headers = [field.get("name") for field in schema.get("fields", [])]
|
||||
|
||||
all_rows = resp["result"].get("data", [])
|
||||
total_rows = len(all_rows)
|
||||
|
||||
compact_rows = []
|
||||
for row_dict in all_rows[:max_query_result_rows]:
|
||||
row_values = [row_dict.get(header) for header in headers]
|
||||
compact_rows.append(row_values)
|
||||
|
||||
summary_string = f"Showing all {total_rows} rows."
|
||||
if total_rows > max_query_result_rows:
|
||||
summary_string = (
|
||||
f"Showing the first {len(compact_rows)} of {total_rows} total rows."
|
||||
)
|
||||
|
||||
return {
|
||||
"Data Retrieved": {
|
||||
"headers": headers,
|
||||
"rows": compact_rows,
|
||||
"summary": summary_string,
|
||||
}
|
||||
}
|
||||
|
||||
return {}
|
||||
|
||||
|
||||
def _handle_error(resp: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
||||
"""Formats an error response into a dictionary."""
|
||||
return {
|
||||
"Error": {
|
||||
"Code": resp.get("code", "N/A"),
|
||||
"Message": resp.get("message", "No message provided."),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def _append_message(
|
||||
messages: list[dict[str, Any]],
|
||||
new_message: dict[str, Any],
|
||||
):
|
||||
"""Appends a message to the list."""
|
||||
if not new_message:
|
||||
return
|
||||
|
||||
messages.append(new_message)
|
||||
@@ -1,93 +0,0 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List
|
||||
from typing import Optional
|
||||
from typing import Union
|
||||
|
||||
from google.adk.agents.readonly_context import ReadonlyContext
|
||||
from typing_extensions import override
|
||||
|
||||
from . import data_agent_tool
|
||||
from ...features import experimental
|
||||
from ...features import FeatureName
|
||||
from ...tools.base_tool import BaseTool
|
||||
from ...tools.base_toolset import BaseToolset
|
||||
from ...tools.base_toolset import ToolPredicate
|
||||
from ...tools.google_tool import GoogleTool
|
||||
from .config import DataAgentToolConfig
|
||||
from .credentials import DataAgentCredentialsConfig
|
||||
|
||||
|
||||
@experimental(FeatureName.DATA_AGENT_TOOLSET)
|
||||
class DataAgentToolset(BaseToolset):
|
||||
"""Data Agent Toolset contains tools for interacting with data agents."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
tool_filter: Optional[Union[ToolPredicate, List[str]]] = None,
|
||||
credentials_config: Optional[DataAgentCredentialsConfig] = None,
|
||||
data_agent_tool_config: Optional[DataAgentToolConfig] = None,
|
||||
):
|
||||
super().__init__(tool_filter=tool_filter)
|
||||
self._credentials_config = credentials_config
|
||||
self._tool_settings = (
|
||||
data_agent_tool_config
|
||||
if data_agent_tool_config
|
||||
else DataAgentToolConfig()
|
||||
)
|
||||
|
||||
def _is_tool_selected(
|
||||
self, tool: BaseTool, readonly_context: ReadonlyContext
|
||||
) -> bool:
|
||||
if self.tool_filter is None:
|
||||
return True
|
||||
|
||||
if isinstance(self.tool_filter, ToolPredicate):
|
||||
return self.tool_filter(tool, readonly_context)
|
||||
|
||||
if isinstance(self.tool_filter, list):
|
||||
return tool.name in self.tool_filter
|
||||
|
||||
return False
|
||||
|
||||
@override
|
||||
async def get_tools(
|
||||
self, readonly_context: Optional[ReadonlyContext] = None
|
||||
) -> List[BaseTool]:
|
||||
all_tools = [
|
||||
GoogleTool(
|
||||
func=func,
|
||||
credentials_config=self._credentials_config,
|
||||
tool_settings=self._tool_settings,
|
||||
)
|
||||
for func in [
|
||||
data_agent_tool.list_accessible_data_agents,
|
||||
data_agent_tool.get_data_agent_info,
|
||||
data_agent_tool.ask_data_agent,
|
||||
]
|
||||
]
|
||||
|
||||
return [
|
||||
tool
|
||||
for tool in all_tools
|
||||
if self._is_tool_selected(tool, readonly_context)
|
||||
]
|
||||
|
||||
@override
|
||||
async def close(self):
|
||||
pass
|
||||
@@ -13,4 +13,4 @@
|
||||
# limitations under the License.
|
||||
|
||||
# version: major.minor.patch
|
||||
__version__ = "1.23.0"
|
||||
__version__ = "1.22.1"
|
||||
|
||||
@@ -12,17 +12,19 @@
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import asyncio
|
||||
import gc
|
||||
import sys
|
||||
|
||||
from google.adk.agents import base_agent
|
||||
from google.adk.agents.llm_agent import Agent
|
||||
from google.adk.models.base_llm import BaseLlm
|
||||
from google.adk.models.llm_response import LlmResponse
|
||||
from google.adk.telemetry import tracing
|
||||
from google.adk.tools import FunctionTool
|
||||
from google.adk.utils.context_utils import Aclosing
|
||||
from google.genai.types import Content
|
||||
from google.genai.types import Part
|
||||
from opentelemetry.instrumentation.google_genai import GoogleGenAiSdkInstrumentor
|
||||
from opentelemetry.sdk.trace import TracerProvider
|
||||
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
|
||||
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
|
||||
@@ -119,8 +121,6 @@ async def test_tracer_start_as_current_span(
|
||||
'call_llm',
|
||||
'call_llm',
|
||||
'execute_tool some_tool',
|
||||
'generate_content mock',
|
||||
'generate_content mock',
|
||||
'invocation',
|
||||
'invoke_agent some_root_agent',
|
||||
]
|
||||
@@ -162,50 +162,3 @@ async def test_exception_preserves_attributes(
|
||||
for span in spans
|
||||
if span.name != 'invocation' # not expected to have attributes
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_generate_content_for_gemini_model_when_already_instrumented(
|
||||
test_runner: TestInMemoryRunner,
|
||||
span_exporter: InMemorySpanExporter,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
"""Tests"""
|
||||
# Arrange
|
||||
monkeypatch.setattr(
|
||||
tracing,
|
||||
'_instrumented_with_opentelemetry_instrumentation_google_genai',
|
||||
lambda: True,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
tracing,
|
||||
'_is_gemini_agent',
|
||||
lambda _: True,
|
||||
)
|
||||
|
||||
# Act
|
||||
async with Aclosing(test_runner.run_async_with_new_session_agen('')) as agen:
|
||||
async for _ in agen:
|
||||
pass
|
||||
|
||||
# Assert
|
||||
spans = span_exporter.get_finished_spans()
|
||||
assert not any(span.name.startswith('generate_content') for span in spans)
|
||||
|
||||
|
||||
def test_instrumented_with_opentelemetry_instrumentation_google_genai():
|
||||
instrumentor = GoogleGenAiSdkInstrumentor()
|
||||
|
||||
assert (
|
||||
not tracing._instrumented_with_opentelemetry_instrumentation_google_genai()
|
||||
)
|
||||
try:
|
||||
instrumentor.instrument()
|
||||
assert (
|
||||
tracing._instrumented_with_opentelemetry_instrumentation_google_genai()
|
||||
)
|
||||
finally:
|
||||
instrumentor.uninstrument()
|
||||
assert (
|
||||
not tracing._instrumented_with_opentelemetry_instrumentation_google_genai()
|
||||
)
|
||||
|
||||
@@ -26,21 +26,11 @@ from google.adk.sessions.in_memory_session_service import InMemorySessionService
|
||||
from google.adk.telemetry.tracing import ADK_CAPTURE_MESSAGE_CONTENT_IN_SPANS
|
||||
from google.adk.telemetry.tracing import trace_agent_invocation
|
||||
from google.adk.telemetry.tracing import trace_call_llm
|
||||
from google.adk.telemetry.tracing import trace_generate_content_result
|
||||
from google.adk.telemetry.tracing import trace_merged_tool_calls
|
||||
from google.adk.telemetry.tracing import trace_send_data
|
||||
from google.adk.telemetry.tracing import trace_tool_call
|
||||
from google.adk.telemetry.tracing import use_generate_content_span
|
||||
from google.adk.tools.base_tool import BaseTool
|
||||
from google.genai import types
|
||||
from opentelemetry._logs import LogRecord
|
||||
from opentelemetry.semconv._incubating.attributes.gen_ai_attributes import GEN_AI_CONVERSATION_ID
|
||||
from opentelemetry.semconv._incubating.attributes.gen_ai_attributes import GEN_AI_OPERATION_NAME
|
||||
from opentelemetry.semconv._incubating.attributes.gen_ai_attributes import GEN_AI_REQUEST_MODEL
|
||||
from opentelemetry.semconv._incubating.attributes.gen_ai_attributes import GEN_AI_RESPONSE_FINISH_REASONS
|
||||
from opentelemetry.semconv._incubating.attributes.gen_ai_attributes import GEN_AI_SYSTEM
|
||||
from opentelemetry.semconv._incubating.attributes.gen_ai_attributes import GEN_AI_USAGE_INPUT_TOKENS
|
||||
from opentelemetry.semconv._incubating.attributes.gen_ai_attributes import GEN_AI_USAGE_OUTPUT_TOKENS
|
||||
import pytest
|
||||
|
||||
|
||||
@@ -622,142 +612,3 @@ async def test_trace_send_data_disabling_request_response_content(
|
||||
call_obj.args
|
||||
for call_obj in mock_span_fixture.set_attribute.call_args_list
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@mock.patch('google.adk.telemetry.tracing.otel_logger')
|
||||
@mock.patch('google.adk.telemetry.tracing.tracer')
|
||||
@mock.patch(
|
||||
'google.adk.telemetry.tracing._guess_gemini_system_name',
|
||||
return_value='test_system',
|
||||
)
|
||||
@pytest.mark.parametrize('capture_content', [True, False])
|
||||
async def test_generate_content_span(
|
||||
mock_guess_system_name,
|
||||
mock_tracer,
|
||||
mock_otel_logger,
|
||||
monkeypatch,
|
||||
capture_content,
|
||||
):
|
||||
"""Test native generate_content span creation with attributes and logs."""
|
||||
# Arrange
|
||||
monkeypatch.setenv(
|
||||
'OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT',
|
||||
str(capture_content).lower(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
'google.adk.telemetry.tracing._instrumented_with_opentelemetry_instrumentation_google_genai',
|
||||
lambda: False,
|
||||
)
|
||||
|
||||
agent = LlmAgent(name='test_agent', model='not-a-gemini-model')
|
||||
invocation_context = await _create_invocation_context(agent)
|
||||
|
||||
system_instruction = types.Content(
|
||||
parts=[types.Part.from_text(text='You are a helpful assistant.')],
|
||||
)
|
||||
user_content1 = types.Content(role='user', parts=[types.Part(text='Hello')])
|
||||
user_content2 = types.Content(role='user', parts=[types.Part(text='World')])
|
||||
|
||||
model_content = types.Content(
|
||||
role='model', parts=[types.Part(text='Response')]
|
||||
)
|
||||
|
||||
llm_request = LlmRequest(
|
||||
model='some-model',
|
||||
contents=[user_content1, user_content2],
|
||||
config=types.GenerateContentConfig(system_instruction=system_instruction),
|
||||
)
|
||||
llm_response = LlmResponse(
|
||||
content=model_content,
|
||||
finish_reason=types.FinishReason.STOP,
|
||||
usage_metadata=types.GenerateContentResponseUsageMetadata(
|
||||
prompt_token_count=10,
|
||||
candidates_token_count=20,
|
||||
),
|
||||
)
|
||||
|
||||
model_response_event = mock.MagicMock()
|
||||
model_response_event.id = 'event-123'
|
||||
|
||||
mock_span = (
|
||||
mock_tracer.start_as_current_span.return_value.__enter__.return_value
|
||||
)
|
||||
|
||||
# Act
|
||||
with use_generate_content_span(
|
||||
llm_request, invocation_context, model_response_event
|
||||
) as span:
|
||||
assert span is mock_span
|
||||
|
||||
trace_generate_content_result(span, llm_response)
|
||||
|
||||
# Assert Span
|
||||
mock_tracer.start_as_current_span.assert_called_once_with(
|
||||
'generate_content some-model'
|
||||
)
|
||||
|
||||
mock_span.set_attribute.assert_any_call(GEN_AI_SYSTEM, 'test_system')
|
||||
mock_span.set_attribute.assert_any_call(
|
||||
GEN_AI_OPERATION_NAME, 'generate_content'
|
||||
)
|
||||
mock_span.set_attribute.assert_any_call(GEN_AI_REQUEST_MODEL, 'some-model')
|
||||
mock_span.set_attribute.assert_any_call(
|
||||
GEN_AI_RESPONSE_FINISH_REASONS, ['stop']
|
||||
)
|
||||
mock_span.set_attribute.assert_any_call(GEN_AI_USAGE_INPUT_TOKENS, 10)
|
||||
mock_span.set_attribute.assert_any_call(GEN_AI_USAGE_OUTPUT_TOKENS, 20)
|
||||
|
||||
mock_span.set_attributes.assert_called_once_with({
|
||||
GEN_AI_CONVERSATION_ID: invocation_context.session.id,
|
||||
'gcp.vertex.agent.event_id': 'event-123',
|
||||
})
|
||||
|
||||
# Assert Logs
|
||||
assert mock_otel_logger.emit.call_count == 4
|
||||
|
||||
expected_system_body = {
|
||||
'content': (
|
||||
system_instruction.model_dump() if capture_content else '<elided>'
|
||||
)
|
||||
}
|
||||
expected_user1_body = {
|
||||
'content': user_content1.model_dump() if capture_content else '<elided>'
|
||||
}
|
||||
expected_user2_body = {
|
||||
'content': user_content2.model_dump() if capture_content else '<elided>'
|
||||
}
|
||||
expected_choice_body = {
|
||||
'content': model_content.model_dump() if capture_content else '<elided>',
|
||||
'index': 0,
|
||||
'finish_reason': 'STOP',
|
||||
}
|
||||
|
||||
log_records: list[LogRecord] = [
|
||||
call.args[0] for call in mock_otel_logger.emit.call_args_list
|
||||
]
|
||||
|
||||
system_log = next(
|
||||
(lr for lr in log_records if lr.event_name == 'gen_ai.system.message'),
|
||||
None,
|
||||
)
|
||||
assert system_log is not None
|
||||
assert system_log.body == expected_system_body
|
||||
assert system_log.attributes == {GEN_AI_SYSTEM: 'test_system'}
|
||||
|
||||
user_logs = [
|
||||
lr for lr in log_records if lr.event_name == 'gen_ai.user.message'
|
||||
]
|
||||
assert len(user_logs) == 2
|
||||
assert expected_user1_body == user_logs[0].body
|
||||
assert expected_user2_body == user_logs[1].body
|
||||
for log in user_logs:
|
||||
assert log.attributes == {GEN_AI_SYSTEM: 'test_system'}
|
||||
|
||||
choice_log = next(
|
||||
(lr for lr in log_records if lr.event_name == 'gen_ai.choice'),
|
||||
None,
|
||||
)
|
||||
assert choice_log is not None
|
||||
assert choice_log.body == expected_choice_body
|
||||
assert choice_log.attributes == {GEN_AI_SYSTEM: 'test_system'}
|
||||
|
||||
@@ -1,198 +0,0 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import pathlib
|
||||
from unittest import mock
|
||||
|
||||
from google.adk.tools.data_agent import data_agent_tool
|
||||
from google.adk.tools.tool_context import ToolContext
|
||||
import pytest
|
||||
import requests
|
||||
import yaml
|
||||
|
||||
|
||||
@mock.patch.object(data_agent_tool, "requests", autospec=True)
|
||||
def test_list_accessible_data_agents_success(mock_requests):
|
||||
"""Tests list_accessible_data_agents success path."""
|
||||
mock_creds = mock.Mock()
|
||||
mock_creds.token = "fake-token"
|
||||
mock_response = mock.Mock()
|
||||
mock_response.json.return_value = {"dataAgents": ["agent1", "agent2"]}
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_requests.get.return_value = mock_response
|
||||
result = data_agent_tool.list_accessible_data_agents(
|
||||
"test-project", mock_creds
|
||||
)
|
||||
assert result["status"] == "SUCCESS"
|
||||
assert result["response"] == ["agent1", "agent2"]
|
||||
mock_requests.get.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch.object(data_agent_tool, "requests", autospec=True)
|
||||
def test_list_accessible_data_agents_exception(mock_requests):
|
||||
"""Tests list_accessible_data_agents exception path."""
|
||||
mock_creds = mock.Mock()
|
||||
mock_creds.token = "fake-token"
|
||||
mock_requests.get.side_effect = Exception("List failed!")
|
||||
result = data_agent_tool.list_accessible_data_agents(
|
||||
"test-project", mock_creds
|
||||
)
|
||||
assert result["status"] == "ERROR"
|
||||
assert "List failed!" in result["error_details"]
|
||||
mock_requests.get.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch.object(data_agent_tool, "requests", autospec=True)
|
||||
def test_get_data_agent_info_success(mock_requests):
|
||||
"""Tests get_data_agent_info success path."""
|
||||
mock_creds = mock.Mock()
|
||||
mock_creds.token = "fake-token"
|
||||
mock_response = mock.Mock()
|
||||
mock_response.json.return_value = "agent_info"
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_requests.get.return_value = mock_response
|
||||
result = data_agent_tool.get_data_agent_info("agent_name", mock_creds)
|
||||
assert result["status"] == "SUCCESS"
|
||||
assert result["response"] == "agent_info"
|
||||
mock_requests.get.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch.object(data_agent_tool, "requests", autospec=True)
|
||||
def test_get_data_agent_info_exception(mock_requests):
|
||||
"""Tests get_data_agent_info exception path."""
|
||||
mock_creds = mock.Mock()
|
||||
mock_creds.token = "fake-token"
|
||||
mock_requests.get.side_effect = Exception("Get failed!")
|
||||
result = data_agent_tool.get_data_agent_info("agent_name", mock_creds)
|
||||
assert result["status"] == "ERROR"
|
||||
assert "Get failed!" in result["error_details"]
|
||||
mock_requests.get.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch.object(data_agent_tool, "_get_stream", autospec=True)
|
||||
@mock.patch.object(data_agent_tool, "requests", autospec=True)
|
||||
@mock.patch.object(data_agent_tool, "get_data_agent_info", autospec=True)
|
||||
def test_ask_data_agent_success(
|
||||
mock_get_agent_info, mock_requests, mock_get_stream
|
||||
):
|
||||
"""Tests ask_data_agent success path."""
|
||||
mock_creds = mock.Mock()
|
||||
mock_creds.token = "fake-token"
|
||||
mock_get_agent_info.return_value = {"status": "SUCCESS", "response": {}}
|
||||
mock_get_stream.return_value = [
|
||||
{"Answer": "response1"},
|
||||
{"Answer": "response2"},
|
||||
]
|
||||
mock_invocation_context = mock.Mock()
|
||||
mock_invocation_context.session.state = {}
|
||||
mock_context = ToolContext(mock_invocation_context)
|
||||
mock_settings = mock.Mock()
|
||||
|
||||
result = data_agent_tool.ask_data_agent(
|
||||
"projects/p/locations/l/dataAgents/a",
|
||||
"query",
|
||||
credentials=mock_creds,
|
||||
tool_context=mock_context,
|
||||
settings=mock_settings,
|
||||
)
|
||||
assert result["status"] == "SUCCESS"
|
||||
assert result["response"] == [
|
||||
{"Answer": "response1"},
|
||||
{"Answer": "response2"},
|
||||
]
|
||||
mock_get_agent_info.assert_called_once()
|
||||
mock_get_stream.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch.object(data_agent_tool, "_get_stream", autospec=True)
|
||||
@mock.patch.object(data_agent_tool, "requests", autospec=True)
|
||||
@mock.patch.object(data_agent_tool, "get_data_agent_info", autospec=True)
|
||||
def test_ask_data_agent_exception(
|
||||
mock_get_agent_info, mock_requests, mock_get_stream
|
||||
):
|
||||
"""Tests ask_data_agent exception path."""
|
||||
mock_creds = mock.Mock()
|
||||
mock_creds.token = "fake-token"
|
||||
mock_get_agent_info.return_value = {"status": "SUCCESS", "response": {}}
|
||||
mock_get_stream.side_effect = Exception("Chat failed!")
|
||||
mock_invocation_context = mock.Mock()
|
||||
mock_invocation_context.session.state = {}
|
||||
mock_context = ToolContext(mock_invocation_context)
|
||||
mock_settings = mock.Mock()
|
||||
|
||||
result = data_agent_tool.ask_data_agent(
|
||||
"projects/p/locations/l/dataAgents/a",
|
||||
"query",
|
||||
credentials=mock_creds,
|
||||
tool_context=mock_context,
|
||||
settings=mock_settings,
|
||||
)
|
||||
assert result["status"] == "ERROR"
|
||||
assert "Chat failed!" in result["error_details"]
|
||||
mock_get_stream.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"case_file_path",
|
||||
[
|
||||
pytest.param("test_data/ask_data_insights_penguins_highest_mass.yaml"),
|
||||
],
|
||||
)
|
||||
@mock.patch.object(requests.Session, "post")
|
||||
def test_get_stream_from_file(mock_post, case_file_path):
|
||||
"""Runs a full integration test for the _get_stream function using data from a specific file."""
|
||||
# 1. Construct the full, absolute path to the data file
|
||||
full_path = pathlib.Path(__file__).parent.parent / "bigquery" / case_file_path
|
||||
|
||||
# 2. Load the test case data from the specified YAML file
|
||||
with open(full_path, "r", encoding="utf-8") as f:
|
||||
case_data = yaml.safe_load(f)
|
||||
|
||||
# 3. Prepare the mock stream and expected output from the loaded data
|
||||
mock_stream_str = case_data["mock_api_stream"]
|
||||
fake_stream_lines = [
|
||||
line.encode("utf-8") for line in mock_stream_str.splitlines()
|
||||
]
|
||||
# Load the expected output as a list of dictionaries, not a single string
|
||||
expected_final_list = case_data["expected_output"]
|
||||
data_retrieved = {
|
||||
"Data Retrieved": {
|
||||
"headers": ["island", "average_body_mass"],
|
||||
"rows": [
|
||||
["Biscoe", "4716.017964071853"],
|
||||
["Dream", "3712.9032258064512"],
|
||||
["Torgersen", "3706.3725490196075"],
|
||||
],
|
||||
"summary": "Showing all 3 rows.",
|
||||
}
|
||||
}
|
||||
expected_final_list.insert(-1, data_retrieved)
|
||||
|
||||
# 4. Configure the mock for requests.post
|
||||
mock_response = mock.Mock()
|
||||
mock_response.iter_lines.return_value = fake_stream_lines
|
||||
# Add raise_for_status mock which is called in the updated code
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_post.return_value.__enter__.return_value = mock_response
|
||||
|
||||
# 5. Call the function under test
|
||||
result = data_agent_tool._get_stream( # pylint: disable=protected-access
|
||||
url="fake_url",
|
||||
ca_payload={},
|
||||
headers={},
|
||||
max_query_result_rows=50,
|
||||
)
|
||||
|
||||
# 6. Assert that the final list of dicts matches the expected output
|
||||
assert result == expected_final_list
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user