ec2b666284
Continuous Integration / Pre-commit Linter (push) Waiting to run
Continuous Integration / Mypy Check (Python 3.10) (push) Waiting to run
Continuous Integration / Mypy Check (Python 3.11) (push) Waiting to run
Continuous Integration / Mypy Check (Python 3.12) (push) Waiting to run
Continuous Integration / Mypy Check (Python 3.13) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.10) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.11) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.12) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.13) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.14) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.10) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.11) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.12) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.13) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.14) (push) Waiting to run
Copybara PR Handler / close-imported-pr (push) Waiting to run
335 lines
11 KiB
Python
335 lines
11 KiB
Python
# 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.
|
|
|
|
"""Integration tests for grounding metadata preservation in SSE streaming.
|
|
|
|
Verifies that grounding_metadata from VertexAiSearchTool reaches the final
|
|
non-partial event in both progressive and non-progressive SSE streaming modes.
|
|
|
|
Prerequisites:
|
|
- GOOGLE_CLOUD_PROJECT env var set to a GCP project with Vertex AI enabled
|
|
- Discovery Engine API enabled (discoveryengine.googleapis.com)
|
|
- Authenticated via `gcloud auth application-default login`
|
|
|
|
Usage:
|
|
GOOGLE_CLOUD_PROJECT=my-project pytest
|
|
tests/integration/test_vertex_ai_search_grounding_streaming.py -v -s
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import time
|
|
import uuid
|
|
|
|
from google.adk.features._feature_registry import FeatureName
|
|
from google.adk.features._feature_registry import temporary_feature_override
|
|
from google.genai import types
|
|
import pytest
|
|
|
|
_PROJECT = os.environ.get("GOOGLE_CLOUD_PROJECT", "")
|
|
_LOCATION = os.environ.get("GOOGLE_CLOUD_LOCATION", "global")
|
|
_COLLECTION = "default_collection"
|
|
_DATA_STORE_ID = f"adk-grounding-test-{uuid.uuid4().hex[:8]}"
|
|
_DATA_STORE_DISPLAY_NAME = "ADK Grounding Integration Test"
|
|
_MODEL = "gemini-2.5-flash"
|
|
|
|
_TEST_DOCUMENTS = (
|
|
{
|
|
"id": "doc-adk-overview",
|
|
"title": "ADK Overview",
|
|
"content": (
|
|
"The Agent Development Kit (ADK) is an open-source framework by"
|
|
" Google for building AI agents. ADK supports multi-agent"
|
|
" architectures, tool use, and integrates with Gemini models."
|
|
" ADK was first released in April 2025."
|
|
),
|
|
},
|
|
{
|
|
"id": "doc-adk-tools",
|
|
"title": "ADK Built-in Tools",
|
|
"content": (
|
|
"ADK provides built-in tools including VertexAiSearchTool for"
|
|
" grounded search, GoogleSearchTool for web search, and"
|
|
" CodeExecutionTool for running code. The VertexAiSearchTool"
|
|
" returns grounding metadata with citations pointing to source"
|
|
" documents."
|
|
),
|
|
},
|
|
)
|
|
|
|
|
|
def _parent_path() -> str:
|
|
return f"projects/{_PROJECT}/locations/{_LOCATION}/collections/{_COLLECTION}"
|
|
|
|
|
|
def _data_store_path() -> str:
|
|
return f"{_parent_path()}/dataStores/{_DATA_STORE_ID}"
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def project_id():
|
|
if not _PROJECT:
|
|
pytest.skip("GOOGLE_CLOUD_PROJECT env var not set")
|
|
return _PROJECT
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def data_store_resource(project_id) -> str:
|
|
"""Create a Vertex AI Search data store with test documents."""
|
|
from google.api_core.exceptions import AlreadyExists
|
|
from google.cloud import discoveryengine_v1beta as discoveryengine
|
|
|
|
ds_client = discoveryengine.DataStoreServiceClient()
|
|
doc_client = discoveryengine.DocumentServiceClient()
|
|
|
|
# Create data store
|
|
try:
|
|
request = discoveryengine.CreateDataStoreRequest(
|
|
parent=_parent_path(),
|
|
data_store=discoveryengine.DataStore(
|
|
display_name=_DATA_STORE_DISPLAY_NAME,
|
|
industry_vertical=discoveryengine.IndustryVertical.GENERIC,
|
|
solution_types=[discoveryengine.SolutionType.SOLUTION_TYPE_SEARCH],
|
|
content_config=discoveryengine.DataStore.ContentConfig.NO_CONTENT,
|
|
),
|
|
data_store_id=_DATA_STORE_ID,
|
|
)
|
|
operation = ds_client.create_data_store(request=request)
|
|
print(f"\nCreating data store '{_DATA_STORE_ID}'...")
|
|
operation.result(timeout=120)
|
|
print("Data store created.")
|
|
except AlreadyExists:
|
|
print(f"\nData store '{_DATA_STORE_ID}' already exists, reusing.")
|
|
|
|
# Ingest test documents
|
|
branch = f"{_data_store_path()}/branches/default_branch"
|
|
for doc_data in _TEST_DOCUMENTS:
|
|
json_data = json.dumps({
|
|
"title": doc_data["title"],
|
|
"description": doc_data["content"],
|
|
})
|
|
doc = discoveryengine.Document(
|
|
id=doc_data["id"],
|
|
json_data=json_data,
|
|
)
|
|
try:
|
|
doc_client.create_document(
|
|
parent=branch,
|
|
document=doc,
|
|
document_id=doc_data["id"],
|
|
)
|
|
print(f" Created document: {doc_data['id']}")
|
|
except AlreadyExists:
|
|
doc_client.update_document(
|
|
document=discoveryengine.Document(
|
|
name=f"{branch}/documents/{doc_data['id']}",
|
|
json_data=json_data,
|
|
),
|
|
)
|
|
print(f" Updated document: {doc_data['id']}")
|
|
|
|
print("Waiting 5s for indexing...")
|
|
time.sleep(5)
|
|
|
|
yield _data_store_path()
|
|
|
|
# Cleanup — best-effort, ignore errors from Discovery Engine LRO
|
|
try:
|
|
operation = ds_client.delete_data_store(name=_data_store_path())
|
|
operation.result(timeout=120)
|
|
print(f"\nDeleted data store '{_DATA_STORE_ID}'.")
|
|
except Exception as e:
|
|
print(f"\nFailed to delete data store '{_DATA_STORE_ID}': {e}")
|
|
|
|
|
|
class TestIntegrationVertexAiSearchGrounding:
|
|
"""Integration tests hitting real Vertex AI with VertexAiSearchTool."""
|
|
|
|
@pytest.mark.parametrize("llm_backend", ["VERTEX"], indirect=True)
|
|
@pytest.mark.parametrize(
|
|
"progressive_sse, label",
|
|
[
|
|
(True, "Progressive SSE"),
|
|
(False, "Non-Progressive SSE"),
|
|
],
|
|
)
|
|
@pytest.mark.asyncio
|
|
async def test_grounding_metadata_with_sse_streaming(
|
|
self, project_id, data_store_resource, progressive_sse, label
|
|
):
|
|
"""Verifies grounding_metadata in SSE streaming modes."""
|
|
from google.adk.agents.llm_agent import LlmAgent
|
|
from google.adk.tools.vertex_ai_search_tool import VertexAiSearchTool
|
|
|
|
agent = LlmAgent(
|
|
name="test_agent",
|
|
model=_MODEL,
|
|
tools=[VertexAiSearchTool(data_store_id=data_store_resource)],
|
|
instruction="Answer questions using the search tool.",
|
|
)
|
|
|
|
with temporary_feature_override(
|
|
FeatureName.PROGRESSIVE_SSE_STREAMING, progressive_sse
|
|
):
|
|
all_events, saved_events = await self._run_agent_streaming(
|
|
agent, project_id
|
|
)
|
|
|
|
self._report_events(label, all_events, saved_events)
|
|
|
|
saved_with_grounding = [e for e in saved_events if e["has_grounding"]]
|
|
assert (
|
|
saved_with_grounding
|
|
), f"No saved (non-partial) events have grounding_metadata with {label}."
|
|
|
|
@pytest.mark.parametrize("llm_backend", ["VERTEX"], indirect=True)
|
|
@pytest.mark.asyncio
|
|
async def test_grounding_metadata_without_streaming(
|
|
self, project_id, data_store_resource
|
|
):
|
|
"""Without streaming, grounding_metadata should always be present."""
|
|
from google.adk.agents.llm_agent import LlmAgent
|
|
from google.adk.agents.run_config import RunConfig
|
|
from google.adk.agents.run_config import StreamingMode
|
|
from google.adk.runners import Runner
|
|
from google.adk.sessions.in_memory_session_service import InMemorySessionService
|
|
from google.adk.tools.vertex_ai_search_tool import VertexAiSearchTool
|
|
from google.adk.utils.context_utils import Aclosing
|
|
|
|
agent = LlmAgent(
|
|
name="test_agent",
|
|
model=_MODEL,
|
|
tools=[VertexAiSearchTool(data_store_id=data_store_resource)],
|
|
instruction="Answer questions using the search tool.",
|
|
)
|
|
|
|
session_service = InMemorySessionService()
|
|
runner = Runner(
|
|
app_name="test_app",
|
|
agent=agent,
|
|
session_service=session_service,
|
|
)
|
|
session = await session_service.create_session(
|
|
app_name="test_app", user_id="test_user"
|
|
)
|
|
|
|
run_config = RunConfig(streaming_mode=StreamingMode.NONE)
|
|
events = []
|
|
async with Aclosing(
|
|
runner.run_async(
|
|
user_id="test_user",
|
|
session_id=session.id,
|
|
new_message=types.Content(
|
|
role="user",
|
|
parts=[
|
|
types.Part.from_text(
|
|
text="What built-in tools does ADK provide?"
|
|
)
|
|
],
|
|
),
|
|
run_config=run_config,
|
|
)
|
|
) as agen:
|
|
async for event in agen:
|
|
events.append({
|
|
"author": event.author,
|
|
"partial": event.partial,
|
|
"has_grounding": event.grounding_metadata is not None,
|
|
"has_content": bool(event.content and event.content.parts),
|
|
})
|
|
|
|
print("\n=== No Streaming ===")
|
|
for i, e in enumerate(events):
|
|
print(
|
|
f" Event {i}: author={e['author']}, partial={e['partial']},"
|
|
f" grounding={e['has_grounding']}, content={e['has_content']}"
|
|
)
|
|
|
|
model_events = [e for e in events if e["author"] == "test_agent"]
|
|
with_grounding = [e for e in model_events if e["has_grounding"]]
|
|
assert (
|
|
with_grounding
|
|
), "No events have grounding_metadata even without streaming."
|
|
|
|
async def _run_agent_streaming(self, agent, project_id):
|
|
from google.adk.agents.run_config import RunConfig
|
|
from google.adk.agents.run_config import StreamingMode
|
|
from google.adk.runners import Runner
|
|
from google.adk.sessions.in_memory_session_service import InMemorySessionService
|
|
from google.adk.utils.context_utils import Aclosing
|
|
|
|
session_service = InMemorySessionService()
|
|
runner = Runner(
|
|
app_name="test_app",
|
|
agent=agent,
|
|
session_service=session_service,
|
|
)
|
|
session = await session_service.create_session(
|
|
app_name="test_app", user_id="test_user"
|
|
)
|
|
|
|
run_config = RunConfig(streaming_mode=StreamingMode.SSE)
|
|
all_events = []
|
|
async with Aclosing(
|
|
runner.run_async(
|
|
user_id="test_user",
|
|
session_id=session.id,
|
|
new_message=types.Content(
|
|
role="user",
|
|
parts=[
|
|
types.Part.from_text(
|
|
text="What is ADK and when was it first released?"
|
|
)
|
|
],
|
|
),
|
|
run_config=run_config,
|
|
)
|
|
) as agen:
|
|
async for event in agen:
|
|
all_events.append({
|
|
"author": event.author,
|
|
"partial": event.partial,
|
|
"has_grounding": event.grounding_metadata is not None,
|
|
"has_content": bool(event.content and event.content.parts),
|
|
})
|
|
|
|
saved_events = [e for e in all_events if e["partial"] is not True]
|
|
return all_events, saved_events
|
|
|
|
def _report_events(self, label, all_events, saved_events):
|
|
print(f"\n=== {label} — All Events ===")
|
|
for i, e in enumerate(all_events):
|
|
print(
|
|
f" Event {i}: author={e['author']}, partial={e['partial']},"
|
|
f" grounding={e['has_grounding']},"
|
|
f" content={e['has_content']}"
|
|
)
|
|
print(f"\n=== {label} — Saved (non-partial) Events ===")
|
|
for i, e in enumerate(saved_events):
|
|
print(
|
|
f" Event {i}: author={e['author']}, partial={e['partial']},"
|
|
f" grounding={e['has_grounding']},"
|
|
f" content={e['has_content']}"
|
|
)
|
|
partial_with_grounding = [
|
|
e for e in all_events if e["partial"] is True and e["has_grounding"]
|
|
]
|
|
if partial_with_grounding:
|
|
print(
|
|
f"\n NOTE: {len(partial_with_grounding)} partial event(s)"
|
|
" had grounding_metadata but were NOT saved to session."
|
|
)
|