Compare commits

..

6 Commits

Author SHA1 Message Date
Yasir Modak 7e00c52074 Update bug_report.md 2026-01-22 12:40:37 -08:00
Yasir Modak 58204fb421 Merge branch 'main' into ymodak-patch-1 2026-01-21 19:32:46 -08:00
Yasir Modak 565a0e1ecb Merge branch 'main' into ymodak-patch-1 2026-01-21 14:05:11 -08:00
Yasir Modak 49da199c02 Update .github/ISSUE_TEMPLATE/bug_report.md
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-01-20 13:27:59 -08:00
Yasir Modak 88a246f308 Update .github/ISSUE_TEMPLATE/bug_report.md
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-01-20 13:27:45 -08:00
Yasir Modak fd46b7a66b Update bug_report.md 2026-01-20 13:25:16 -08:00
22 changed files with 66 additions and 1984 deletions
+32 -17
View File
@@ -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.
-83
View File
@@ -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)
-61
View File
@@ -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
-84
View File
@@ -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],
)
-1
View File
@@ -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],
... )
+15 -211
View File
@@ -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()
)
+11 -66
View File
@@ -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",
]
-35
View File
@@ -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
+1 -1
View File
@@ -13,4 +13,4 @@
# limitations under the License.
# version: major.minor.patch
__version__ = "1.23.0"
__version__ = "1.22.1"
+3 -50
View File
@@ -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()
)
-149
View File
@@ -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