diff --git a/samples/agent/adk/gemini_enterprise/README.md b/samples/agent/adk/gemini_enterprise/README.md new file mode 100644 index 000000000..0e9ae31a3 --- /dev/null +++ b/samples/agent/adk/gemini_enterprise/README.md @@ -0,0 +1,43 @@ +# A2UI on Gemini Enterprise + +This folder contains examples and scripts to develop and use A2UI agents on +**Google Cloud Gemini Enterprise**. + +## Folder Structure + +This directory is organized into two sub-folders depending the deployment +methods: + +- `cloud_run`: Contains scripts and configuration to deploy an agent to + **Cloud Run**. This is suitable for building and deploying AI agents + quickly, leveraging its speed and simplicity. + + - Starts with `cloud_run/README.md`. + +- `agent_engine`: Contains code to deploy to **Vertex AI Agent Engine**. Agent + Engine provides additional features like agent context management, agent + evaluation, agent lifecycle management, model-based conversation quality + monitoring, and model tuning with context data. + + - Starts with `agent_engine/README.md`. + +## Shared Resources + +- `agent.py`: Contains the JSON schema for A2UI messages, used for validation + during development and at runtime. +- `prompt_builder.py`: Provides prompts including Role, Workflow, and UI + descriptions. +- `agent_executor.py`: A base implementation of an A2A (Agent-to-Agent) + executor that handles A2UI validation and response formatting. +- `contact_data.json`: Fake contact data for demo. +- `tools.py`: A simple tools to find contact data. +- `examples`: Examples for A2UI messages. + +## Registration in Gemini Enterprise + +Regardless of the deployment method, agents must be registered with Gemini +Enterprise. This involves: 1. **Defining an A2A Agent Card**: Describing the +agent's skills, name, and capabilities (including the **A2UI extension**). 2. +**Configuring Authorization**: Setting up OAuth2 or other authentication +mechanisms to allow the agent to talk to secure services. This is optional for +Cloud Run deployment but **mandatory for Agent Engine deployment**. diff --git a/samples/agent/adk/gemini_enterprise/agent_engine/.env.example b/samples/agent/adk/gemini_enterprise/agent_engine/.env.example new file mode 100644 index 000000000..6ccb90ae1 --- /dev/null +++ b/samples/agent/adk/gemini_enterprise/agent_engine/.env.example @@ -0,0 +1,5 @@ +PROJECT_ID= +LOCATION= +STORAGE_BUCKET= +GEMINI_ENTERPRISE_APP_ID= +AGENT_AUTHORIZATION= diff --git a/samples/agent/adk/gemini_enterprise/agent_engine/README.md b/samples/agent/adk/gemini_enterprise/agent_engine/README.md new file mode 100644 index 000000000..02eab7f94 --- /dev/null +++ b/samples/agent/adk/gemini_enterprise/agent_engine/README.md @@ -0,0 +1,240 @@ +# Register an A2UI Agent deployed on Vertex AI Agent Engine with Gemini Enterprise + +This sample demonstrates how to deploy an A2UI Agent on **Agent Engine** and +register on **Gemini Enterprise**. + +## Overview + +High level steps: + +- Config Authorization +- Setup environment variables +- Develop A2UI agent with ADK + A2A (sample code provided) +- Deploy the agent to Agent Engine +- Register the agent on Gemini Enterprise + +## Config Authorization + +Check **Before you begin** section and follow **Obtain authorization details** +section on +[Register and manage A2A agents](https://docs.cloud.google.com/gemini/enterprise/docs/register-and-manage-an-a2a-agent). +Download JSON that looks like: + +``` +{ + "web": { + "client_id": "", + "project_id": "", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_secret": "", + "redirect_uris": [ + "https://vertexaisearch.cloud.google.com/oauth-redirect", + "https://vertexaisearch.cloud.google.com/static/oauth/oauth.html" + ] + } +} +``` + +NOTE: For this deployment, you can skip the rest of **Register and manage A2A +agents** page. + +Replace **YOUR_CLIENT_ID** with `client_id` which can be found in the downloaded +json and save the following as **authorizationUri** + +``` +https://accounts.google.com/o/oauth2/v2/auth?client_id=&redirect_uri=https%3A%2F%2Fvertexaisearch.cloud.google.com%2Fstatic%2Foauth%2Foauth.html&scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fcloud-platform&include_granted_scopes=true&response_type=code&access_type=offline&prompt=consent +``` + +In your console, run this: + +``` +curl -X POST \ + -H "Authorization: Bearer $(gcloud auth print-access-token)" \ + -H "Content-Type: application/json" \ + -H "X-Goog-User-Project: " \ + "https://-discoveryengine.googleapis.com/v1alpha/projects//locations//authorizations?authorizationId=" \ + -d '{ + "name": "projects//locations//authorizations/", + "serverSideOauth2": { + "clientId": "", + "clientSecret": "", + "authorizationUri": "", + "tokenUri": "" + } + }' +``` + +Replace the following: + +- **YOUR_PROJECT_ID**: the ID of your project. There are 3 occasions +- **ENDPOINT_LOCATION**: the multi-region for your API request. Specify one of + the following values: + - *us* for the US multi-region + - *eu* for the EU multi-region + - *global* for the Global location +- **LOCATION**: the multi-region of your data store: *global*, *us*, or *eu*. + There are 2 occasions +- **AUTH_ID**: The ID of the authorization resource. This is an arbitrary + alphanumeric ID that you define. You need to reference this ID later when + registering an Agent that requires OAuth support. There are 2 occasions. +- **OAUTH_CLIENT_ID**: copy `client_id` from the downloaded JSON. +- **OAUTH_CLIENT_SECRET**: copy `client_secret` from the downloaded JSON. +- **OAUTH_AUTH_URI**: the value of **authorizationUri**. See above. +- **OAUTH_TOKEN_URI**: copy `token_uri` from the downloaded JSON. + +NOTE: if `$(gcloud auth print-access-token)` does not work for you, replace it +with `$(gcloud auth application-default print-access-token)` and try again. + +As result, you will get **AGENT_AUTHORIZATION** like this: + +``` +projects/PROJECT_NUMBER/locations/global/authorizations/ +``` + +The value will be used as an environment variable described below. + +NOTE: if you already have an agent that is deployed to Agent Engine, skip to +**Manually Register An Agent** section. + +## Setup Environment Variables + +1. **Copy `.env.example`:** Duplicate the `.env.example` file and rename it to + `.env`. + - `cd /path/to/agent_engine` + - `cp .env.example .env` +2. **Fill `.env`:** Update the `.env` file with your specific Google Cloud + project details: + * `PROJECT_ID`: Your Google Cloud Project ID. + * `LOCATION`: The Google Cloud region you want to deploy the agent in + (e.g., `us-central1`). This location is **not** the same as the + *location* used in the command above. + * `STORAGE_BUCKET`: A Google Cloud Storage bucket name for staging. It + starts with **"gs://"**. + * `GEMINI_ENTERPRISE_APP_ID`: Your Gemini Enterprise Application ID. You + can create a new App or use an existing one on Google Cloud Gemini + Enterprise. + * `AGENT_AUTHORIZATION`: the value **AGENT_AUTHORIZATION** obtained above. + +## Running the Script + +The `main.py` script performs the following actions: + +1. Initializes the Vertex AI client. +2. Defines a sample "Contact Card Agent" skill and creates an agent card. +3. Creates a local `A2aAgent` instance. +4. Deploys the agent to Vertex AI Agent Engine (`client.agent_engines.create`). +5. Fetches the deployed agent's card. +6. Registers the agent on Gemini Enterprise using the Discovery Engine API. + +To run the script using `uv`: + +1. **Navigate to the script directory:** + - `cd /path/to/agent_engine` +2. **Create and activate a virtual environment:** + - `uv venv` + - `source .venv/bin/activate` +3. **Install dependencies:** + - `uv sync` +4. **Run the script:** + - `uv run deploy.py` + - It may take 5-10 minutes to finish. + +## Manually Register An Agent + +If you have an Agent that is already deployed to Agent Engine, you can manually +register it on Gemini Enterprise without running "main.py" script. + +1. Complete **Config Authorization** section above. +2. Open Google Cloud **Gemini Enterprise**. +3. Click on the **App** you want to register your agent. + - If you don't see the app being listed, click **Edit** to switch location +4. Select **Agents** from the left nav bar. +5. Click **Add agent** and select **Add** on **A2A** card. +6. Copy this following JSON to the "Agent Card JSON" input box. + + ``` + { + "name": "Test Contact Card Agent", + "url": "https://-aiplatform.googleapis.com/v1beta1//a2a", + "description": "A helpful assistant agent that can find contact card s.", + "skills": [ + { + "description": "A helpful assistant agent that can find contact cards.", + "tags": [ + "Contact-Card" + ], + "name": "Contact Card Agent", + "examples": [ + "Who is John Doe?", + "List all contact cards." + ], + "id": "contact_card_agent" + } + ], + "version": "1.0.0", + "capabilities": { + "streaming": true, + "extensions": [ + { + "uri": "https://a2ui.org/a2a-extension/a2ui/v0.8", + "description": "Ability to render A2UI", + "required": false, + "params": { + "supportedCatalogIds": [ + "https://a2ui.org/specification/v0_8/standard_catalog_definition.json" + ] + } + } + ] + }, + "protocolVersion": "0.3.0", + "defaultOutputModes": [ + "application/json" + ], + "defaultInputModes": [ + "text/plain" + ], + "supportsAuthenticatedExtendedCard": true, + "preferredTransport": "HTTP+JSON" + } + ``` + + Replace **LOCATION** and **RESOURCE_NAME**. + + - LOCATION is where you deploy your agent. For example; us-central1. + - RESOURCE_NAME can be found on Google Cloud **Agent Engine**: click the + agent; click **Service Configuration**; select **Deployment details**; + copy **Resource name**. + + Update *name*, *description*, *skills*, *version* as needed. Leave other + values unchanged. + +7. Click **Preview Agent Details** + +8. Click **Next** + +9. Fill the **Agent authorization** form: + + - Copy `client_id`, `client_secret`, `token_uri` from the downloaded JSON. + - Copy **authorizationUri** value from the above to **Authorization URL**. + - Leave **Scopes** field empty. + - Click **Finish** + +## Test Your Agent + +1. Open Google Cloud Console and search for **"Gemini Enterprise"** and click + on it. +2. Open the project you used in the above setting. +3. Click on the **App** you used to register your agent. + - If you don't see your app being listed, click **"Edit"** to switch + location +4. Select **"Agents"** from the left nav bar. +5. Click the three-dot button on the **"Actions"** column and select + **"Previwe"** menu. +6. It will open Gemini Enterprise Agent page. +7. Try queries like *"Find contact card of Sarah"*. + - If this is the first time you start a chat with the agent, it will ask + for manual authorization. +8. You should see a Contact Card being rendered on Gemini Enterprise. diff --git a/samples/agent/adk/gemini_enterprise/agent_engine/__init__.py b/samples/agent/adk/gemini_enterprise/agent_engine/__init__.py new file mode 100644 index 000000000..2309508cb --- /dev/null +++ b/samples/agent/adk/gemini_enterprise/agent_engine/__init__.py @@ -0,0 +1,15 @@ +# 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. + +# This file makes this directory a Python package diff --git a/samples/agent/adk/gemini_enterprise/agent_engine/agent.py b/samples/agent/adk/gemini_enterprise/agent_engine/agent.py new file mode 100644 index 000000000..27c8d7538 --- /dev/null +++ b/samples/agent/adk/gemini_enterprise/agent_engine/agent.py @@ -0,0 +1,382 @@ +# Copyright 2025 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 +# +# https://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 collections.abc import AsyncIterable +import json +import logging +import os +from typing import Any, Dict, Optional + +from a2a.types import ( + AgentCapabilities, + AgentCard, + AgentSkill, + Part, + TextPart, +) +from a2ui.a2a import ( + get_a2ui_agent_extension, + parse_response_to_parts, +) +from a2ui.basic_catalog.provider import BasicCatalog +from a2ui.core.parser.parser import parse_response +from a2ui.core.schema.common_modifiers import remove_strict_validation +from a2ui.core.schema.constants import A2UI_CLOSE_TAG, A2UI_OPEN_TAG, VERSION_0_8 +from a2ui.core.schema.manager import A2uiSchemaManager +import dotenv +from google.adk.agents import run_config +from google.adk.agents.llm_agent import LlmAgent +from google.adk.artifacts import InMemoryArtifactService +from google.adk.memory.in_memory_memory_service import InMemoryMemoryService +from google.adk.runners import Runner +from google.adk.sessions import InMemorySessionService +from google.genai import types +import jsonschema +from prompt_builder import ROLE_DESCRIPTION, UI_DESCRIPTION, WORKFLOW_DESCRIPTION, get_text_prompt +from tools import get_contact_info + +logger = logging.getLogger(__name__) + +SUPPORTED_CONTENT_TYPES = ["text", "text/plain"] + +dotenv.load_dotenv() + + +class ContactAgent: + """An agent that finds contact info for colleagues.""" + + def __init__(self, base_url: str): + self.base_url = base_url + self._agent_name = "contact_agent" + self._user_id = "remote_agent" + self._text_runner: Optional[Runner] = self._build_runner(self._build_llm_agent()) + + self._schema_managers: Dict[str, A2uiSchemaManager] = {} + self._ui_runners: Dict[str, Runner] = {} + + # Gemini Enerprise only supports VERSION_0_8 for now. + for version in [VERSION_0_8]: + schema_manager = self._build_schema_manager(version) + self._schema_managers[version] = schema_manager + agent = self._build_llm_agent(schema_manager) + self._ui_runners[version] = self._build_runner(agent) + + self._agent_card = self._build_agent_card() + + @property + def agent_card(self) -> AgentCard: + return self._agent_card + + def _build_schema_manager(self, version: str) -> A2uiSchemaManager: + # Gemini Enerprise only supports VERSION_0_8 for now. + return A2uiSchemaManager( + version=version, + catalogs=[ + BasicCatalog.get_config( + version=version, + examples_path=os.path.join( + os.path.dirname(__file__), f"examples/{version}" + ), + ) + ], + schema_modifiers=[remove_strict_validation], + ) + + def _build_agent_card(self) -> AgentCard: + """Builds the AgentCard for this agent, describing its capabilities and skills.""" + extensions = [] + if self._schema_managers: + for version, sm in self._schema_managers.items(): + ext = get_a2ui_agent_extension( + version, + sm.accepts_inline_catalogs, + sm.supported_catalog_ids, + ) + extensions.append(ext) + + capabilities = AgentCapabilities( + streaming=True, + extensions=extensions, + ) + skill = AgentSkill( + id="find_contact", + name="Find Contact Tool", + description=( + "Helps find contact information for colleagues (e.g., email," + " location, team)." + ), + tags=["contact", "directory", "people", "finder"], + examples=[ + "Who is David Chen in marketing?", + "Find Sarah Lee from engineering", + ], + ) + + return AgentCard( + name="Contact Lookup Agent", + description=( + "This agent helps find contact info for people in your organization." + ), + url=self.base_url, + version="1.0.0", + default_input_modes=SUPPORTED_CONTENT_TYPES, + default_output_modes=SUPPORTED_CONTENT_TYPES, + capabilities=capabilities, + preferred_transport="HTTP+JSON", + skills=[skill], + ) + + def _build_runner(self, agent: LlmAgent) -> Runner: + return Runner( + app_name=self._agent_name, + agent=agent, + artifact_service=InMemoryArtifactService(), + session_service=InMemorySessionService(), + memory_service=InMemoryMemoryService(), + ) + + def get_processing_message(self) -> str: + return "Looking up contact information..." + + def _build_llm_agent( + self, schema_manager: Optional[A2uiSchemaManager] = None + ) -> LlmAgent: + """Builds the LLM agent for the contact agent.""" + + instruction = ( + schema_manager.generate_system_prompt( + role_description=ROLE_DESCRIPTION, + workflow_description=WORKFLOW_DESCRIPTION, + ui_description=UI_DESCRIPTION, + include_schema=True, + include_examples=True, + validate_examples=True, + ) + if schema_manager + else get_text_prompt() + ) + + return LlmAgent( + model=os.getenv("MODEL", "gemini-2.5-flash"), + name=self._agent_name, + description="An agent that finds colleague contact info.", + instruction=instruction, + tools=[get_contact_info], + ) + + async def fetch_response( + self, query, session_id, ui_version: Optional[str] = None + ) -> List[Part]: + """Fetches the response from the agent.""" + + session_state = {"base_url": self.base_url} + + # Determine which runner to use based on whether the a2ui extension is active. + if ui_version: + runner = self._ui_runners[ui_version] + schema_manager = self._schema_managers[ui_version] + selected_catalog = ( + schema_manager.get_selected_catalog() if schema_manager else None + ) + else: + runner = self._text_runner + selected_catalog = None + + session = await runner.session_service.get_session( + app_name=self._agent_name, + user_id=self._user_id, + session_id=session_id, + ) + if session is None: + session = await runner.session_service.create_session( + app_name=self._agent_name, + user_id=self._user_id, + state=session_state, + session_id=session_id, + ) + elif "base_url" not in session.state: + session.state["base_url"] = self.base_url + + # --- Begin: UI Validation and Retry Logic --- + max_retries = 1 # Total 2 attempts + attempt = 0 + current_query_text = query + + # Ensure catalog schema was loaded + if ui_version and (not selected_catalog or not selected_catalog.catalog_schema): + logger.error( + "--- ContactAgent.fetch_response: A2UI_SCHEMA is not loaded. " + "Cannot perform UI validation. ---" + ) + return [ + Part( + root=TextPart( + text=( + "I'm sorry, I'm facing an internal configuration" + " error with my UI components. Please contact" + " support." + ) + ) + ) + ] + + while attempt <= max_retries: + attempt += 1 + logger.info( + "--- ContactAgent.fetch_response: Attempt" + f" {attempt}/{max_retries + 1} for session {session_id} ---" + ) + + current_message = types.Content( + role="user", parts=[types.Part.from_text(text=current_query_text)] + ) + + full_content_list = [] + + async for event in runner.run_async( + user_id=self._user_id, + session_id=session.id, + new_message=current_message, + ): + if event.is_final_response(): + if event.content and event.content.parts and event.content.parts[0].text: + full_content_list.extend([p.text for p in event.content.parts if p.text]) + + final_response_content = "".join(full_content_list) + + if final_response_content is None: + logger.warning( + "--- ContactAgent.fetch_response: Received no final response" + f" content from runner (Attempt {attempt}). ---" + ) + if attempt <= max_retries: + current_query_text = ( + "I received no response. Please try again." + f"Please retry the original request: '{query}'" + ) + logger.info(f"Retrying with query: {current_query_text}") + continue # Go to next retry + else: + logger.info("Retries exhausted on no-response") + # Retries exhausted on no-response + final_response_content = ( + "I'm sorry, I encountered an error and couldn't process your request." + ) + # Fall through to send this as a text-only error + + is_valid = False + error_message = "" + + if ui_version: + logger.info( + "--- ContactAgent.fetch_response: Validating UI response (Attempt" + f" {attempt})... ---" + ) + try: + logger.info( + "--- ContactAgent.fetch_response: trying to parse response:" + f" {final_response_content})... ---" + ) + response_parts = parse_response(final_response_content) + + for part in response_parts: + if not part.a2ui_json: + continue + + parsed_json_data = part.a2ui_json + + # Handle the "no results found" or empty JSON case + if parsed_json_data == []: + logger.info( + "--- ContactAgent.fetch_response: Empty JSON list found. " + "Assuming valid (e.g., 'no results'). ---" + ) + is_valid = True + else: + # --- Validation Steps --- + # Check if it validates against the A2UI_SCHEMA + # This will raise jsonschema.exceptions.ValidationError if it fails + logger.info( + "--- ContactAgent.fetch_response: Validating against" + " A2UI_SCHEMA... ---" + ) + selected_catalog.validator.validate(parsed_json_data) + # --- End Validation Steps --- + + logger.info( + "--- ContactAgent.fetch_response: UI JSON successfully" + " parsed AND validated against schema. Validation OK" + f" (Attempt {attempt}). ---" + ) + is_valid = True + except ( + ValueError, + json.JSONDecodeError, + jsonschema.exceptions.ValidationError, + ) as e: + logger.warning( + f"--- ContactAgent.fetch_response: A2UI validation failed: {e}" + f" (Attempt {attempt}) ---" + ) + logger.warning( + f"--- Failed response content: {final_response_content[:500]}... ---" + ) + error_message = f"Validation failed: {e}." + + else: # Not using UI, so text is always "valid" + is_valid = True + + if is_valid: + logger.info( + "--- ContactAgent.fetch_response: Response is valid. Task complete" + f" (Attempt {attempt}). ---" + ) + + # Already validated, so we can return the parts. + return parse_response_to_parts(final_response_content) + + # --- If we're here, it means validation failed --- + if attempt <= max_retries: + logger.warning( + "--- ContactAgent.fetch_response: Retrying..." + f" ({attempt}/{max_retries + 1}) ---" + ) + # Prepare the query for the retry + current_query_text = ( + f"Your previous response was invalid. {error_message} You MUST" + " generate a valid response that strictly follows the A2UI JSON" + " SCHEMA. The response MUST be a JSON list of A2UI messages." + f" Ensure each JSON part is wrapped in '{A2UI_OPEN_TAG}' and" + f" '{A2UI_CLOSE_TAG}' tags. Please retry the original request:" + f" '{query}'" + ) + # Loop continues... + + # --- If we're here, it means we've exhausted retries --- + logger.error( + "--- ContactAgent.fetch_response: Max retries exhausted. Sending" + " text-only error. ---" + ) + return [ + Part( + root=TextPart( + text=( + "I'm sorry, I'm having trouble generating the interface" + " for that request right now. Please try again in a" + " moment." + ) + ) + ) + ] + # --- End: UI Validation and Retry Logic --- diff --git a/samples/agent/adk/gemini_enterprise/agent_engine/agent_executor.py b/samples/agent/adk/gemini_enterprise/agent_engine/agent_executor.py new file mode 100644 index 000000000..d6e57ffc4 --- /dev/null +++ b/samples/agent/adk/gemini_enterprise/agent_engine/agent_executor.py @@ -0,0 +1,154 @@ +# Copyright 2025 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 +# +# https://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 logging + +from a2a.server.agent_execution import AgentExecutor, RequestContext +from a2a.server.events import EventQueue +from a2a.server.tasks import TaskUpdater +from a2a.types import ( + DataPart, + Part, + Task, + TaskState, + TextPart, + UnsupportedOperationError, +) +from a2a.utils import ( + new_agent_parts_message, + new_task, +) +from a2a.utils.errors import ServerError +from a2ui.a2a import try_activate_a2ui_extension +from agent import ContactAgent + +logger = logging.getLogger(__name__) + + +class ContactAgentExecutor(AgentExecutor): + """Contact AgentExecutor Example.""" + + def __init__(self, base_url: str): + self._agent = ContactAgent(base_url) + + async def execute( + self, + context: RequestContext, + event_queue: EventQueue, + ) -> None: + query = "" + ui_event_part = None + action = None + + logger.info(f"--- Client requested extensions: {context.requested_extensions} ---") + active_ui_version = try_activate_a2ui_extension(context, self._agent.agent_card) + + if active_ui_version: + logger.info( + "--- AGENT_EXECUTOR: A2UI extension is active" + f" (v{active_ui_version}). Using UI runner. ---" + ) + else: + logger.info( + "--- AGENT_EXECUTOR: A2UI extension is not active. Using text runner. ---" + ) + + if context.message and context.message.parts: + logger.info( + f"--- AGENT_EXECUTOR: Processing {len(context.message.parts)} message" + " parts ---" + ) + for i, part in enumerate(context.message.parts): + if isinstance(part.root, DataPart): + if "userAction" in part.root.data: + logger.info(f" Part {i}: Found a2ui UI ClientEvent payload.") + ui_event_part = part.root.data["userAction"] + else: + logger.info(f" Part {i}: DataPart (data: {part.root.data})") + elif isinstance(part.root, TextPart): + logger.info(f" Part {i}: TextPart (text: {part.root.text})") + else: + logger.info(f" Part {i}: Unknown part type ({type(part.root)})") + + if ui_event_part: + logger.info(f"Received a2ui ClientEvent: {ui_event_part}") + # Fix: Check both 'actionName' and 'name' + action = ui_event_part.get("name") + ctx = ui_event_part.get("context", {}) + + if action == "view_profile": + contact_name = ctx.get("contactName", "Unknown") + department = ctx.get("department", "") + query = f"WHO_IS: {contact_name} from {department}" + + elif action == "send_email": + contact_name = ctx.get("contactName", "Unknown") + email = ctx.get("email", "Unknown") + query = f"USER_WANTS_TO_EMAIL: {contact_name} at {email}" + + elif action == "send_message": + contact_name = ctx.get("contactName", "Unknown") + query = f"USER_WANTS_TO_MESSAGE: {contact_name}" + + elif action == "follow_contact": + query = "ACTION: follow_contact" + + elif action == "view_full_profile": + contact_name = ctx.get("contactName", "Unknown") + query = f"USER_WANTS_FULL_PROFILE: {contact_name}" + + else: + query = f"User submitted an event: {action} with data: {ctx}" + else: + logger.info("No a2ui UI event part found. Falling back to text input.") + query = context.get_user_input() + + logger.info(f"--- AGENT_EXECUTOR: Final query for LLM: '{query}' ---") + + task = context.current_task + + if not task: + task = new_task(context.message) + await event_queue.enqueue_event(task) + updater = TaskUpdater(event_queue, task.id, task.context_id) + + final_parts = await self._agent.fetch_response( + query, task.context_id, active_ui_version + ) + self._log_parts(final_parts) + + final_state = TaskState.input_required + if action in ["send_email", "send_message", "view_full_profile"]: + final_state = TaskState.completed + + await updater.update_status( + final_state, + new_agent_parts_message(final_parts, task.context_id, task.id), + final=(final_state == TaskState.completed), + ) + + async def cancel( + self, request: RequestContext, event_queue: EventQueue + ) -> Task | None: + raise ServerError(error=UnsupportedOperationError()) + + def _log_parts(self, parts: list[Part]): + logger.info("--- PARTS TO BE SENT ---") + for i, part in enumerate(parts): + logger.info(f" - Part {i}: Type = {type(part.root)}") + if isinstance(part.root, TextPart): + logger.info(f" - Text: {part.root.text[:200]}...") + elif isinstance(part.root, DataPart): + logger.info(f" - Data: {str(part.root.data)[:200]}...") + logger.info("-----------------------------") diff --git a/samples/agent/adk/gemini_enterprise/agent_engine/contact_data.json b/samples/agent/adk/gemini_enterprise/agent_engine/contact_data.json new file mode 100644 index 000000000..83d85e472 --- /dev/null +++ b/samples/agent/adk/gemini_enterprise/agent_engine/contact_data.json @@ -0,0 +1,38 @@ +[ + { + "id": "1", + "name": "Alex Li", + "title": "Product Marketing Manager", + "team": "Team Macally", + "department": "Marketing", + "location": "New York", + "email": "alex.li@example.com", + "mobile": "+1 (415) 171-1080", + "calendar": "Free until 4:00 PM", + "imageUrl": "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=100&h=100&fit=crop" + }, + { + "id": "2", + "name": "Casey Smith", + "title": "Digital Marketing Specialist", + "team": "Growth Team", + "department": "Marketing", + "location": "New York", + "email": "casey.smith@example.com", + "mobile": "+1 (415) 222-3333", + "calendar": "In a meeting", + "imageUrl": "https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=100&h=100&fit=crop" + }, + { + "id": "3", + "name": "Jordan Taylor", + "title": "Senior Software Engineer", + "team": "Core Platform", + "department": "Engineering", + "location": "San Francisco", + "email": "jordan.taylor@example.com", + "mobile": "+1 (650) 444-5555", + "calendar": "Focus time", + "imageUrl": "https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=100&h=100&fit=crop" + } +] diff --git a/samples/agent/adk/gemini_enterprise/agent_engine/deploy.py b/samples/agent/adk/gemini_enterprise/agent_engine/deploy.py new file mode 100644 index 000000000..5b673cfb3 --- /dev/null +++ b/samples/agent/adk/gemini_enterprise/agent_engine/deploy.py @@ -0,0 +1,255 @@ +# 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. + +"""Main file for creating and managing A2UI agents on Agent Engine.""" + +import json +import os + +from a2a.types import AgentSkill +from agent import ContactAgent +import agent_executor +from dotenv import load_dotenv +from google.auth import default +from google.auth.transport.requests import Request +from google.genai import types +import httpx +import requests +import vertexai +from vertexai.preview.reasoning_engines import A2aAgent +from vertexai.preview.reasoning_engines.templates.a2a import create_agent_card + + +def _get_bearer_token(): + """Gets a bearer token for authenticating with Google Cloud.""" + try: + credentials, _ = default(scopes=["https://www.googleapis.com/auth/cloud-platform"]) + request = Request() + credentials.refresh(request) + return credentials.token + except Exception as e: # pylint: disable=broad-except + print(f"Error getting credentials: {e}") + print( + "Please ensure you have authenticated with 'gcloud auth " + "application-default login'." + ) + return None + + +def _register_agent_on_gemini_enterprise( + project_id: str, + app_id: str, + agent_card: str, + agent_name: str, + display_name: str, + description: str, + agent_authorization: str | None = None, +): + """Register an Agent Engine to Gemini Enterprise. + + Args: + project_id: Google Cloud project id + app_id: Gemini Enterprise application ID + agent_card: Agent card definition + agent_name: Name of the agent in Gemini Enterprise + display_name: Display name for the agent in Gemini Enterprise + description: Description of the agent + agent_authorization: Agent authorization config + + Returns: + dict: Response from Discovery Engine API + """ + api_endpoint = ( + f"https://discoveryengine.googleapis.com/v1alpha/projects/{project_id}/" + f"locations/global/collections/default_collection/engines/{app_id}/" + "assistants/default_assistant/agents" + ) + + payload = { + "name": agent_name, + "displayName": display_name, + "description": description, + "a2aAgentDefinition": {"jsonAgentCard": agent_card}, + } + + if agent_authorization: + payload["authorization_config"] = {"agent_authorization": agent_authorization} + + # Get access token + bearer_token = _get_bearer_token() + + # Prepare headers + headers = { + "Authorization": f"Bearer {bearer_token}", + "Content-Type": "application/json", + "X-Goog-User-Project": project_id, + } + + response = requests.post(api_endpoint, headers=headers, json=payload) + + if response.status_code == 200: + print("✓ Agent registered successfully!") + return response.json() + print(f"✗ Registration failed with status code: {response.status_code}") + print(f"Response: {response.text}") + response.raise_for_status() + + +def main(): + + project_id = os.environ.get("PROJECT_ID") + location = os.environ.get("LOCATION") + # STORAGE_BUCKET starts with gs:// + storage = os.environ.get("STORAGE_BUCKET") + app_id = os.environ.get("GEMINI_ENTERPRISE_APP_ID") + api_endpoint = f"{location}-aiplatform.googleapis.com" + + vertexai.init( + project=project_id, + location=location, + api_endpoint=api_endpoint, + staging_bucket=storage, + ) + print("≈" * 120) + + print("✓ Vertex AI client initialized.") + + client = vertexai.Client( + project=project_id, + location=location, + http_options=types.HttpOptions( + api_version="v1beta1", + ), + ) + print("✓ Vertex AI client created.") + + agent_skill = AgentSkill( + id="contact_card_agent", + name="Contact Card Agent", + description="A helpful assistant agent that can find contact cards.", + tags=["Contact-Card"], + examples=[ + "Who is John Doe?", + "List all contact cards.", + ], + ) + + cc_agent_card = create_agent_card( + agent_name="Test Contact Card Agent", + description="A helpful assistant agent that can find contact cards.", + skills=[agent_skill], + streaming=True, + ) + + base_url = "http://0.0.0.0:8080" + contact_agent = ContactAgent(base_url=base_url) + + # cc_agent_card = contact_agent.agent_card + + print(f"✓ Contact Card agent card created. {cc_agent_card}") + + a2ui_agent = A2aAgent( + agent_card=cc_agent_card, + agent_executor_builder=agent_executor.ContactAgentExecutor, + agent_executor_kwargs={"base_url": base_url}, + ) + print("✓ Local Contact Card agent created.") + + config = { + "display_name": "A2UI Contact Card Agent on Agent Engine", + "description": ( + "A helpful assistant agent that uses A2UI to render contact cards." + ), + "agent_framework": "google-adk", + "staging_bucket": storage, + "requirements": [ + "google-cloud-aiplatform[agent_engines,adk]", + "google-genai>=1.27.0", + "python-dotenv>=1.1.0", + "uvicorn", + "a2a-sdk>=0.3.4", + "cloudpickle>=3.1.2", + "pydantic", + "jsonschema>=4.0.0", + "a2ui-agent-sdk>=0.1.1", + ], + "http_options": { + "api_version": "v1beta1", + }, + "max_instances": 1, + "extra_packages": [ + "agent_executor.py", + "contact_data.json", + "prompt_builder.py", + "agent.py", + "tools.py", + "examples/0.8", + ], + "env_vars": { + "NUM_WORKERS": "1", + }, + } + + remote_agent = client.agent_engines.create(agent=a2ui_agent, config=config) + + remote_engine_resource = remote_agent.api_resource.name + print(f"✓ Remote agent created. {remote_engine_resource}") + + a2a_endpoint = f"https://{api_endpoint}/v1beta1/{remote_engine_resource}/a2a/v1/card" + bearer_token = _get_bearer_token() + headers = { + "Authorization": f"Bearer {bearer_token}", + "Content-Type": "application/json", + } + + print(f"✓ A2A endpoint: {a2a_endpoint}") + + response = httpx.get(a2a_endpoint, headers=headers) + response.raise_for_status() + a2ui_agent_card_json = response.json() + # Add A2UI capabilities to the agent card. + a2ui_agent_card_json["capabilities"] = { + "streaming": True, + "extensions": [{ + "uri": "https://a2ui.org/a2a-extension/a2ui/v0.8", + "description": "Ability to render A2UI", + "required": False, + "params": { + "supportedCatalogIds": [ + "https://a2ui.org/specification/v0_8/standard_catalog_definition.json" + ] + }, + }], + } + a2ui_agent_card_str = json.dumps(a2ui_agent_card_json) + + print("✓ A2UI agent card fetched.") + + enterprise_agent = _register_agent_on_gemini_enterprise( + project_id=project_id, + app_id=app_id, + agent_card=a2ui_agent_card_str, + agent_name="a2ui_contact_card_agent", + display_name="A2UI Contact Card Agent", + description="A helpful assistant agent that uses A2UI to render contact cards.", + agent_authorization=os.environ.get("AGENT_AUTHORIZATION"), + ) + + print(enterprise_agent) + print("≈" * 120) + + +if __name__ == "__main__": + load_dotenv() + main() diff --git a/samples/agent/adk/gemini_enterprise/agent_engine/examples/0.8/action_confirmation.json b/samples/agent/adk/gemini_enterprise/agent_engine/examples/0.8/action_confirmation.json new file mode 100644 index 000000000..74a665a04 --- /dev/null +++ b/samples/agent/adk/gemini_enterprise/agent_engine/examples/0.8/action_confirmation.json @@ -0,0 +1,112 @@ +[ + { + "beginRendering": { + "surfaceId": "action-modal", + "root": "modal-wrapper", + "styles": { + "primaryColor": "#007BFF", + "font": "Roboto" + } + } + }, + { + "surfaceUpdate": { + "surfaceId": "action-modal", + "components": [ + { + "id": "modal-wrapper", + "component": { + "Modal": { + "entryPointChild": "hidden-entry-point", + "contentChild": "modal-content-column" + } + } + }, + { + "id": "hidden-entry-point", + "component": { + "Text": { + "text": { + "literalString": "" + } + } + } + }, + { + "id": "modal-content-column", + "component": { + "Column": { + "children": { + "explicitList": [ + "modal-title", + "modal-message", + "dismiss-button" + ] + }, + "alignment": "center" + } + } + }, + { + "id": "modal-title", + "component": { + "Text": { + "usageHint": "h2", + "text": { + "path": "/actionTitle" + } + } + } + }, + { + "id": "modal-message", + "component": { + "Text": { + "text": { + "path": "/actionMessage" + } + } + } + }, + { + "id": "dismiss-button-text", + "component": { + "Text": { + "text": { + "literalString": "Dismiss" + } + } + } + }, + { + "id": "dismiss-button", + "component": { + "Button": { + "child": "dismiss-button-text", + "primary": true, + "action": { + "name": "dismiss_modal" + } + } + } + } + ] + } + }, + { + "dataModelUpdate": { + "surfaceId": "action-modal", + "path": "/", + "contents": [ + { + "key": "actionTitle", + "valueString": "Action Confirmation" + }, + { + "key": "actionMessage", + "valueString": "Your action has been processed." + } + ] + } + } +] \ No newline at end of file diff --git a/samples/agent/adk/gemini_enterprise/agent_engine/examples/0.8/contact_card.json b/samples/agent/adk/gemini_enterprise/agent_engine/examples/0.8/contact_card.json new file mode 100644 index 000000000..c783dce5e --- /dev/null +++ b/samples/agent/adk/gemini_enterprise/agent_engine/examples/0.8/contact_card.json @@ -0,0 +1,509 @@ +[ + { + "beginRendering": { + "surfaceId": "contact-card", + "root": "main_card" + } + }, + { + "surfaceUpdate": { + "surfaceId": "contact-card", + "components": [ + { + "id": "main_card", + "component": { + "Card": { + "child": "main_column" + } + } + }, + { + "id": "main_column", + "component": { + "Column": { + "children": { + "explicitList": [ + "profile_image_column", + "description_column", + "div", + "info_rows_column", + "action_buttons_row", + "link_text_wrapper" + ] + }, + "alignment": "stretch" + } + } + }, + { + "id": "profile_image_column", + "component": { + "Column": { + "children": { + "explicitList": [ + "profile_image" + ] + }, + "alignment": "center" + } + } + }, + { + "id": "profile_image", + "component": { + "Image": { + "url": { + "path": "/imageUrl" + }, + "usageHint": "avatar", + "fit": "cover" + } + } + }, + { + "id": "user_heading", + "weight": 1, + "component": { + "Text": { + "text": { + "path": "/name" + }, + "usageHint": "h2" + } + } + }, + { + "id": "description_text_1", + "component": { + "Text": { + "text": { + "path": "/title" + }, + "usageHint": "h4" + } + } + }, + { + "id": "description_text_2", + "component": { + "Text": { + "text": { + "path": "/team" + }, + "usageHint": "caption" + } + } + }, + { + "id": "description_column", + "component": { + "Column": { + "children": { + "explicitList": [ + "user_heading", + "description_text_1", + "description_text_2" + ] + }, + "alignment": "center" + } + } + }, + { + "id": "calendar_icon", + "component": { + "Icon": { + "name": { + "literalString": "calendarToday" + } + } + } + }, + { + "id": "calendar_secondary_text", + "component": { + "Text": { + "usageHint": "h5", + "text": { + "path": "/calendar" + } + } + } + }, + { + "id": "calendar_primary_text", + "component": { + "Text": { + "text": { + "literalString": "Calendar" + }, + "usageHint": "caption" + } + } + }, + { + "id": "calendar_text_column", + "component": { + "Column": { + "children": { + "explicitList": [ + "calendar_primary_text", + "calendar_secondary_text" + ] + }, + "distribution": "start", + "alignment": "start" + } + } + }, + { + "id": "info_row_1", + "component": { + "Row": { + "children": { + "explicitList": [ + "calendar_icon", + "calendar_text_column" + ] + }, + "distribution": "start", + "alignment": "start" + } + } + }, + { + "id": "location_icon", + "component": { + "Icon": { + "name": { + "literalString": "locationOn" + } + } + } + }, + { + "id": "location_secondary_text", + "component": { + "Text": { + "usageHint": "h5", + "text": { + "path": "/location" + } + } + } + }, + { + "id": "location_primary_text", + "component": { + "Text": { + "text": { + "literalString": "Location" + }, + "usageHint": "caption" + } + } + }, + { + "id": "location_text_column", + "component": { + "Column": { + "children": { + "explicitList": [ + "location_primary_text", + "location_secondary_text" + ] + }, + "distribution": "start", + "alignment": "start" + } + } + }, + { + "id": "info_row_2", + "component": { + "Row": { + "children": { + "explicitList": [ + "location_icon", + "location_text_column" + ] + }, + "distribution": "start", + "alignment": "start" + } + } + }, + { + "id": "mail_icon", + "component": { + "Icon": { + "name": { + "literalString": "mail" + } + } + } + }, + { + "id": "mail_secondary_text", + "component": { + "Text": { + "usageHint": "h5", + "text": { + "path": "/email" + } + } + } + }, + { + "id": "mail_primary_text", + "component": { + "Text": { + "text": { + "literalString": "Email" + }, + "usageHint": "caption" + } + } + }, + { + "id": "mail_text_column", + "component": { + "Column": { + "children": { + "explicitList": [ + "mail_primary_text", + "mail_secondary_text" + ] + }, + "distribution": "start", + "alignment": "start" + } + } + }, + { + "id": "info_row_3", + "component": { + "Row": { + "children": { + "explicitList": [ + "mail_icon", + "mail_text_column" + ] + }, + "distribution": "start", + "alignment": "start" + } + } + }, + { + "id": "div", + "component": { + "Divider": {} + } + }, + { + "id": "call_icon", + "component": { + "Icon": { + "name": { + "literalString": "call" + } + } + } + }, + { + "id": "call_secondary_text", + "component": { + "Text": { + "usageHint": "h5", + "text": { + "path": "/mobile" + } + } + } + }, + { + "id": "call_primary_text", + "component": { + "Text": { + "text": { + "literalString": "Mobile" + }, + "usageHint": "caption" + } + } + }, + { + "id": "call_text_column", + "component": { + "Column": { + "children": { + "explicitList": [ + "call_primary_text", + "call_secondary_text" + ] + }, + "distribution": "start", + "alignment": "start" + } + } + }, + { + "id": "info_row_4", + "component": { + "Row": { + "children": { + "explicitList": [ + "call_icon", + "call_text_column" + ] + }, + "distribution": "start", + "alignment": "start" + } + } + }, + { + "id": "info_rows_column", + "weight": 1, + "component": { + "Column": { + "children": { + "explicitList": [ + "info_row_1", + "info_row_2", + "info_row_3", + "info_row_4" + ] + }, + "alignment": "stretch" + } + } + }, + { + "id": "button_1_text", + "component": { + "Text": { + "text": { + "literalString": "Follow" + } + } + } + }, + { + "id": "button_1", + "component": { + "Button": { + "child": "button_1_text", + "primary": true, + "action": { + "name": "follow_contact" + } + } + } + }, + { + "id": "button_2_text", + "component": { + "Text": { + "text": { + "literalString": "Message" + } + } + } + }, + { + "id": "button_2", + "component": { + "Button": { + "child": "button_2_text", + "primary": false, + "action": { + "name": "send_message" + } + } + } + }, + { + "id": "action_buttons_row", + "component": { + "Row": { + "children": { + "explicitList": [ + "button_1", + "button_2" + ] + }, + "distribution": "center", + "alignment": "center" + } + } + }, + { + "id": "link_text", + "component": { + "Text": { + "text": { + "literalString": "[View Full Profile](/profile)" + } + } + } + }, + { + "id": "link_text_wrapper", + "component": { + "Row": { + "children": { + "explicitList": [ + "link_text" + ] + }, + "distribution": "center", + "alignment": "center" + } + } + } + ] + } + }, + { + "dataModelUpdate": { + "surfaceId": "contact-card", + "path": "/", + "contents": [ + { + "key": "name", + "valueString": "Alex Jordan" + }, + { + "key": "title", + "valueString": "Software Engineer" + }, + { + "key": "team", + "valueString": "Cloud AI" + }, + { + "key": "location", + "valueString": "Sunnyvale, CA" + }, + { + "key": "email", + "valueString": "alex.jordan@google.com" + }, + { + "key": "mobile", + "valueString": "(123) 456-7890" + }, + { + "key": "calendar", + "valueString": "Available until 5:00 PM PST" + }, + { + "key": "imageUrl", + "valueString": "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=100&h=100&fit=crop" + } + ] + } + } +] diff --git a/samples/agent/adk/gemini_enterprise/agent_engine/examples/0.8/contact_list.json b/samples/agent/adk/gemini_enterprise/agent_engine/examples/0.8/contact_list.json new file mode 100644 index 000000000..2cf52bede --- /dev/null +++ b/samples/agent/adk/gemini_enterprise/agent_engine/examples/0.8/contact_list.json @@ -0,0 +1,232 @@ +[ + { + "beginRendering": { + "surfaceId": "contact-list", + "root": "root-column", + "styles": { + "primaryColor": "#007BFF", + "font": "Roboto" + } + } + }, + { + "surfaceUpdate": { + "surfaceId": "contact-list", + "components": [ + { + "id": "root-column", + "component": { + "Column": { + "children": { + "explicitList": [ + "title-heading", + "item-list" + ] + } + } + } + }, + { + "id": "title-heading", + "component": { + "Text": { + "usageHint": "h1", + "text": { + "literalString": "Found Contacts" + } + } + } + }, + { + "id": "item-list", + "component": { + "List": { + "direction": "vertical", + "children": { + "template": { + "componentId": "item-card-template", + "dataBinding": "/contacts" + } + } + } + } + }, + { + "id": "item-card-template", + "component": { + "Card": { + "child": "card-layout" + } + } + }, + { + "id": "card-layout", + "component": { + "Row": { + "children": { + "explicitList": [ + "template-image", + "card-details", + "view-button" + ] + }, + "alignment": "center" + } + } + }, + { + "id": "template-image", + "component": { + "Image": { + "url": { + "path": "/imageUrl" + }, + "fit": "cover" + } + } + }, + { + "id": "card-details", + "component": { + "Column": { + "children": { + "explicitList": [ + "template-name", + "template-title" + ] + } + } + } + }, + { + "id": "template-name", + "component": { + "Text": { + "usageHint": "h3", + "text": { + "path": "/name" + } + } + } + }, + { + "id": "template-title", + "component": { + "Text": { + "text": { + "path": "/title" + } + } + } + }, + { + "id": "view-button-text", + "component": { + "Text": { + "text": { + "literalString": "View" + } + } + } + }, + { + "id": "view-button", + "component": { + "Button": { + "child": "view-button-text", + "primary": true, + "action": { + "name": "view_profile", + "context": [ + { + "key": "contactName", + "value": { + "path": "/name" + } + }, + { + "key": "department", + "value": { + "path": "/department" + } + } + ] + } + } + } + } + ] + } + }, + { + "dataModelUpdate": { + "surfaceId": "contact-list", + "path": "/", + "contents": [ + { + "key": "contacts", + "valueMap": [ + { + "key": "contact1", + "valueMap": [ + { + "key": "name", + "valueString": "Alice Wonderland" + }, + { + "key": "phone", + "valueString": "+1-555-123-4567" + }, + { + "key": "email", + "valueString": "alice@example.com" + }, + { + "key": "imageUrl", + "valueString": "https://example.com/alice.jpg" + }, + { + "key": "title", + "valueString": "Mad Hatter" + }, + { + "key": "department", + "valueString": "Wonderland" + } + ] + }, + { + "key": "contact2", + "valueMap": [ + { + "key": "name", + "valueString": "Bob The Builder" + }, + { + "key": "phone", + "valueString": "+1-555-765-4321" + }, + { + "key": "email", + "valueString": "bob@example.com" + }, + { + "key": "imageUrl", + "valueString": "https://example.com/bob.jpg" + }, + { + "key": "title", + "valueString": "Construction" + }, + { + "key": "department", + "valueString": "Building" + } + ] + } + ] + } + ] + } + } +] \ No newline at end of file diff --git a/samples/agent/adk/gemini_enterprise/agent_engine/examples/0.8/follow_success.json b/samples/agent/adk/gemini_enterprise/agent_engine/examples/0.8/follow_success.json new file mode 100644 index 000000000..bfc866e91 --- /dev/null +++ b/samples/agent/adk/gemini_enterprise/agent_engine/examples/0.8/follow_success.json @@ -0,0 +1,58 @@ +[ + { + "beginRendering": { + "surfaceId": "contact-card", + "root": "success_card" + } + }, + { + "surfaceUpdate": { + "surfaceId": "contact-card", + "components": [ + { + "id": "success_card", + "component": { + "Card": { + "child": "success_column" + } + } + }, + { + "id": "success_column", + "component": { + "Column": { + "children": { + "explicitList": [ + "success_icon", + "success_text" + ] + }, + "alignment": "center" + } + } + }, + { + "id": "success_icon", + "component": { + "Icon": { + "name": { + "literalString": "check" + } + } + } + }, + { + "id": "success_text", + "component": { + "Text": { + "text": { + "literalString": "Successfully Followed" + }, + "usageHint": "h2" + } + } + } + ] + } + } +] \ No newline at end of file diff --git a/samples/agent/adk/gemini_enterprise/agent_engine/prompt_builder.py b/samples/agent/adk/gemini_enterprise/agent_engine/prompt_builder.py new file mode 100644 index 000000000..4be60f1d9 --- /dev/null +++ b/samples/agent/adk/gemini_enterprise/agent_engine/prompt_builder.py @@ -0,0 +1,64 @@ +# Copyright 2025 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 +# +# https://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 a2ui.core.schema.constants import A2UI_CLOSE_TAG, A2UI_OPEN_TAG + +ROLE_DESCRIPTION = ( + "You are a helpful contact lookup assistant. Your final output MUST always" + " start with a short text description and then A2UI JSON response that" + " follows Workflow and UI descriptions." +) + +WORKFLOW_DESCRIPTION = """ +Buttons that represent the main action on a card or view (e.g., 'Follow', 'Email', 'Search') SHOULD include the `"primary": true` attribute. +""" + +UI_DESCRIPTION = f""" +- **For finding contacts (e.g., "Who is Alex Jordan?"):** + a. You MUST call the `get_contact_info` tool. + b. If the tool returns a **single contact**, you MUST use the `CONTACT_CARD_EXAMPLE` template. Populate the `dataModelUpdate.contents` with the contact's details (name, title, email, etc.). + c. If the tool returns **multiple contacts**, you MUST use the `CONTACT_LIST_EXAMPLE` template. Populate the `dataModelUpdate.contents` with the list of contacts for the "contacts" key. + d. If the tool returns an **empty list**, respond with text only and an empty JSON list: "I couldn't find anyone by that name.{A2UI_OPEN_TAG}[]{A2UI_CLOSE_TAG}" + +- **For handling a profile view (e.g., "WHO_IS: Alex Jordan..."):** + a. You MUST call the `get_contact_info` tool with the specific name. + b. This will return a single contact. You MUST use the `CONTACT_CARD_EXAMPLE` template. + +- **For listing all contacts (e.g., "List all contacts"):** + a. You MUST call the `get_contact_info` tool with `None` for the name. + b. This will return a multiple contacts. You MUST use the `CONTACT_LIST_EXAMPLE` template. + +- **For handling actions (e.g., "follow_contact"):** + a. You MUST use the `FOLLOW_SUCCESS_EXAMPLE` template. + b. This will render a new card with a "Successfully Followed" message. + c. Respond with a text confirmation like "You are now following this contact." along with the JSON. +""" + + +# For non-A2UI clients. +def get_text_prompt() -> str: + """Constructs the prompt for a text-only agent.""" + return """ + You are a helpful contact lookup assistant. Your final output MUST be a text response. + + To generate the response, you MUST follow these rules: + 1. **For finding contacts:** + a. You MUST call the `get_contact_info` tool. Extract the name and department from the user's query. + b. After receiving the data, format the contact(s) as a clear, human-readable text response. + c. If multiple contacts are found, list their names and titles. + d. If one contact is found, list all their details. + + 2. **For handling actions (e.g., "USER_WANTS_TO_EMAIL: ..."):** + a. Respond with a simple text confirmation (e.g., "Drafting an email to..."). + """ diff --git a/samples/agent/adk/gemini_enterprise/agent_engine/pyproject.toml b/samples/agent/adk/gemini_enterprise/agent_engine/pyproject.toml new file mode 100644 index 000000000..1c6225353 --- /dev/null +++ b/samples/agent/adk/gemini_enterprise/agent_engine/pyproject.toml @@ -0,0 +1,25 @@ +# 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. + +[project] +name = "a2ui-contactcard-agentengine" +version = "0.1.0" +description = "Sample Google ADK-based Contact Lookup agent that uses a2ui extension and is hosted as an A2A agent on Agent Engine." +readme = "README.md" +requires-python = ">=3.13" +dependencies = [ + "a2a-sdk>=0.3.4", + "google-cloud-aiplatform[adk,agent-engines]>=1.128.0", + "a2ui-agent-sdk>=0.1.1", +] diff --git a/samples/agent/adk/gemini_enterprise/agent_engine/tools.py b/samples/agent/adk/gemini_enterprise/agent_engine/tools.py new file mode 100644 index 000000000..2ba665c38 --- /dev/null +++ b/samples/agent/adk/gemini_enterprise/agent_engine/tools.py @@ -0,0 +1,65 @@ +# Copyright 2025 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 +# +# https://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 json +import logging +import os + +logger = logging.getLogger(__name__) + + +def get_contact_info(name: str = None, department: str = "") -> str: + """Call this tool to get a list of contacts based on a name and optional department. + + 'name' is the person's name to search for. 'department' is the optional + department to filter by. + """ + logger.info("--- TOOL CALLED: get_contact_info ---") + logger.info(f" - Name: {name}") + logger.info(f" - Department: {department}") + + results = [] + try: + script_dir = os.path.dirname(__file__) + file_path = os.path.join(script_dir, "contact_data.json") + with open(file_path) as f: + contact_data_str = f.read() + all_contacts = json.loads(contact_data_str) + + if name is None: + return json.dumps(all_contacts) + + name_lower = name.lower() + + dept_lower = department.lower() if department else "" + + # Filter by name + results = [ + contact for contact in all_contacts if name_lower in contact["name"].lower() + ] + + # If department is provided, filter results further + if dept_lower: + results = [ + contact for contact in results if dept_lower in contact["department"].lower() + ] + + logger.info(f" - Success: Found {len(results)} matching contacts.") + + except FileNotFoundError: + logger.error(f" - Error: contact_data.json not found at {file_path}") + except json.JSONDecodeError: + logger.error(f" - Error: Failed to decode JSON from {file_path}") + + return json.dumps(results) diff --git a/samples/agent/adk/gemini_enterprise/cloud_run/README.md b/samples/agent/adk/gemini_enterprise/cloud_run/README.md new file mode 100644 index 000000000..930e59f82 --- /dev/null +++ b/samples/agent/adk/gemini_enterprise/cloud_run/README.md @@ -0,0 +1,129 @@ +# User Guide: Deploying an A2UI Agent to Cloud Run, Registering with Gemini Enterprise and Interacting with the Agent using A2UI components + +This guide provides a comprehensive walkthrough of deploying an A2A +(Agent-to-Agent) enabled agent with **A2UI** extension, built with the Google +**Agent Development Kit (ADK)**, to Google **Cloud Run**. You can interact with +the agent by rich content A2UI components. You will also learn how to register +your deployed agent with Gemini Enterprise to make it discoverable and usable by +other agents. + +## Introduction + +This project provides a template for creating and deploying a powerful, +Gemini-based agent that can communicate with users with A2UI components. By the +end of this guide, you will have an agent running on Cloud Run and can display +A2UI components on Gemini Enterprise UI. + +## Steps + +There are 2 steps: + +1. **Deployment**: Deploy an A2UI agent to Google Cloud Run from source code. +2. **Registration**: Register the deploy agent in Gemini Enterprise. + +## Deployment + +The `deploy.sh` script automates the deployment process. To deploy your agent, +navigate to this directory and run the script with your Google Cloud Project ID +and a name for your new service. You can also optionally specify the Gemini +model to use. + +```bash +chmod +x deploy.sh +./deploy.sh [MODEL_NAME] +``` + +* `MODEL_NAME`: Optional. Can be `gemini-2.5-pro` or `gemini-2.5-flash`. + Defaults to `gemini-2.5-flash` if not specified. + +For example: + +```bash +# Deploy with the default gemini-2.5-flash model +./deploy.sh my-gcp-project my-gemini-agent + +# Deploy with the gemini-2.5-pro model +./deploy.sh my-gcp-project my-gemini-agent gemini-2.5-pro +``` + +The script will: + +1. **Build a container image** from your source code. +2. **Push the image** to the Google Container Registry. +3. **Deploy the image** to Cloud Run. +4. **Set environment variables**, including the `MODEL` and the public + `AGENT_URL` of the service itself. + +Once the script completes, it will print the service URL of your deployed agent. +You will need the **Service URL** in the next step. + +## Registration in Gemini Enterprise + +Now that your agent is deployed, you need to register it with Gemini Enterprise +to make it discoverable. This is done programmatically using the Discovery +Engine API. + +**1. Get your Gemini Enterprise Engine ID:** + +You can create or find an existing Engine ID (a.k.a. App ID) in the Google Cloud +Console. + +**2. Register the agent:** + +Execute the following `curl` command, replacing the placeholders with your own +values: + +```bash +curl -X POST -H "Authorization: Bearer $(gcloud auth print-access-token)" -H "Content-Type: application/json" https://discoveryengine.googleapis.com/v1alpha/projects/PROJECT_NUMBER/locations/LOCATION/collections/default_collection/engines/ENGINE_ID/assistants/default_assistant/agents -d '{ + "name": "AGENT_NAME", + "displayName": "AGENT_DISPLAY_NAME", + "description": "AGENT_DESCRIPTION", + "a2aAgentDefinition": { + "jsonAgentCard": "{\"protocolVersion\": \"0.3.0\", \"name\": \"AGENT_NAME\", \"description\": \"AGENT_DESCRIPTION\", \"url\": \"AGENT_URL\", \"version\": \"1.0.0\", \"capabilities\": {\"streaming\": true, \"extensions\": [{\"uri\": \"https://a2ui.org/a2a-extension/a2ui/v0.8\", \"description\": \"Ability to render A2UI\", \"required\": false, \"params\": {\"supportedCatalogIds\": [\"https://a2ui.org/specification/v0_8/standard_catalog_definition.json\"]}}]}, \"skills\": [], \"defaultInputModes\": [\"text/plain\"], \"defaultOutputModes\": [\"text/plain\"]}" + } +}' +``` + +**Placeholder Descriptions:** + +* `PROJECT_NUMBER`: Your Google Cloud project number. +* `LOCATION`: The location of your Discovery Engine instance (e.g., `global`). +* `ENGINE_ID`: The ID of your Gemini Enterprise engine (a.k.a App ID). +* `AGENT_NAME`: A unique name for your agent. +* `AGENT_DISPLAY_NAME`: The name that will be displayed in the Gemini + Enterprise UI. +* `AGENT_DESCRIPTION`: A brief description of your agent's capabilities. +* `AGENT_URL`: The service URL of your deployed agent which was printed in the + previous step. + +**3. Locate the agent on the Gemini Enterprise UI:** + +Your agent can be found in the Gemini Enterprise UI. You can click the 3-dots +button and select "Preview" to interact with the agent. Send queries like "Find +Alex contact card", or "List all contacts" and you will see A2UI components +being rendered. + +### IAM Support for Agents Running on Cloud Run + +When the agent is deployed on Cloud Run (when the `AGENT_URL` ends with +"run.app"), Gemini Enterprise attempts IAM authentication when talking to the +agent. For this to work, you should grant the "Cloud Run Invoker" role to the +following principal in the project where Cloud Run is running: + +`service-PROJECT_NUMBER@gcp-sa-discoveryengine.iam.gserviceaccount.com` + +### Unregistering the Agent (Optional) + +The following command can be used to unregister the agent: + +```bash +curl -X DELETE -H "Authorization: Bearer $(gcloud auth print-access-token)" -H "Content-Type: application/json" https://discoveryengine.googleapis.com/v1alpha/projects/PROJECT_NUMBER/locations/LOCATION/collections/default_collection/engines/ENGINE_ID/assistants/default_assistant/agents/AGENT_ID +``` + +## Conclusion + +Congratulations! You have successfully deployed an A2A-enabled agent with A2UI +capacity to Cloud Run and registered it with Gemini Enterprise. Your agent is +now ready to interact with other agents in the A2A ecosystem. You can further +customize your agent by adding more tools, refining its system instructions, and +enhancing its capabilities. diff --git a/samples/agent/adk/gemini_enterprise/cloud_run/__init__.py b/samples/agent/adk/gemini_enterprise/cloud_run/__init__.py new file mode 100644 index 000000000..d2465e5eb --- /dev/null +++ b/samples/agent/adk/gemini_enterprise/cloud_run/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2025 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 +# +# https://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. diff --git a/samples/agent/adk/gemini_enterprise/cloud_run/agent.py b/samples/agent/adk/gemini_enterprise/cloud_run/agent.py new file mode 100644 index 000000000..27c8d7538 --- /dev/null +++ b/samples/agent/adk/gemini_enterprise/cloud_run/agent.py @@ -0,0 +1,382 @@ +# Copyright 2025 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 +# +# https://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 collections.abc import AsyncIterable +import json +import logging +import os +from typing import Any, Dict, Optional + +from a2a.types import ( + AgentCapabilities, + AgentCard, + AgentSkill, + Part, + TextPart, +) +from a2ui.a2a import ( + get_a2ui_agent_extension, + parse_response_to_parts, +) +from a2ui.basic_catalog.provider import BasicCatalog +from a2ui.core.parser.parser import parse_response +from a2ui.core.schema.common_modifiers import remove_strict_validation +from a2ui.core.schema.constants import A2UI_CLOSE_TAG, A2UI_OPEN_TAG, VERSION_0_8 +from a2ui.core.schema.manager import A2uiSchemaManager +import dotenv +from google.adk.agents import run_config +from google.adk.agents.llm_agent import LlmAgent +from google.adk.artifacts import InMemoryArtifactService +from google.adk.memory.in_memory_memory_service import InMemoryMemoryService +from google.adk.runners import Runner +from google.adk.sessions import InMemorySessionService +from google.genai import types +import jsonschema +from prompt_builder import ROLE_DESCRIPTION, UI_DESCRIPTION, WORKFLOW_DESCRIPTION, get_text_prompt +from tools import get_contact_info + +logger = logging.getLogger(__name__) + +SUPPORTED_CONTENT_TYPES = ["text", "text/plain"] + +dotenv.load_dotenv() + + +class ContactAgent: + """An agent that finds contact info for colleagues.""" + + def __init__(self, base_url: str): + self.base_url = base_url + self._agent_name = "contact_agent" + self._user_id = "remote_agent" + self._text_runner: Optional[Runner] = self._build_runner(self._build_llm_agent()) + + self._schema_managers: Dict[str, A2uiSchemaManager] = {} + self._ui_runners: Dict[str, Runner] = {} + + # Gemini Enerprise only supports VERSION_0_8 for now. + for version in [VERSION_0_8]: + schema_manager = self._build_schema_manager(version) + self._schema_managers[version] = schema_manager + agent = self._build_llm_agent(schema_manager) + self._ui_runners[version] = self._build_runner(agent) + + self._agent_card = self._build_agent_card() + + @property + def agent_card(self) -> AgentCard: + return self._agent_card + + def _build_schema_manager(self, version: str) -> A2uiSchemaManager: + # Gemini Enerprise only supports VERSION_0_8 for now. + return A2uiSchemaManager( + version=version, + catalogs=[ + BasicCatalog.get_config( + version=version, + examples_path=os.path.join( + os.path.dirname(__file__), f"examples/{version}" + ), + ) + ], + schema_modifiers=[remove_strict_validation], + ) + + def _build_agent_card(self) -> AgentCard: + """Builds the AgentCard for this agent, describing its capabilities and skills.""" + extensions = [] + if self._schema_managers: + for version, sm in self._schema_managers.items(): + ext = get_a2ui_agent_extension( + version, + sm.accepts_inline_catalogs, + sm.supported_catalog_ids, + ) + extensions.append(ext) + + capabilities = AgentCapabilities( + streaming=True, + extensions=extensions, + ) + skill = AgentSkill( + id="find_contact", + name="Find Contact Tool", + description=( + "Helps find contact information for colleagues (e.g., email," + " location, team)." + ), + tags=["contact", "directory", "people", "finder"], + examples=[ + "Who is David Chen in marketing?", + "Find Sarah Lee from engineering", + ], + ) + + return AgentCard( + name="Contact Lookup Agent", + description=( + "This agent helps find contact info for people in your organization." + ), + url=self.base_url, + version="1.0.0", + default_input_modes=SUPPORTED_CONTENT_TYPES, + default_output_modes=SUPPORTED_CONTENT_TYPES, + capabilities=capabilities, + preferred_transport="HTTP+JSON", + skills=[skill], + ) + + def _build_runner(self, agent: LlmAgent) -> Runner: + return Runner( + app_name=self._agent_name, + agent=agent, + artifact_service=InMemoryArtifactService(), + session_service=InMemorySessionService(), + memory_service=InMemoryMemoryService(), + ) + + def get_processing_message(self) -> str: + return "Looking up contact information..." + + def _build_llm_agent( + self, schema_manager: Optional[A2uiSchemaManager] = None + ) -> LlmAgent: + """Builds the LLM agent for the contact agent.""" + + instruction = ( + schema_manager.generate_system_prompt( + role_description=ROLE_DESCRIPTION, + workflow_description=WORKFLOW_DESCRIPTION, + ui_description=UI_DESCRIPTION, + include_schema=True, + include_examples=True, + validate_examples=True, + ) + if schema_manager + else get_text_prompt() + ) + + return LlmAgent( + model=os.getenv("MODEL", "gemini-2.5-flash"), + name=self._agent_name, + description="An agent that finds colleague contact info.", + instruction=instruction, + tools=[get_contact_info], + ) + + async def fetch_response( + self, query, session_id, ui_version: Optional[str] = None + ) -> List[Part]: + """Fetches the response from the agent.""" + + session_state = {"base_url": self.base_url} + + # Determine which runner to use based on whether the a2ui extension is active. + if ui_version: + runner = self._ui_runners[ui_version] + schema_manager = self._schema_managers[ui_version] + selected_catalog = ( + schema_manager.get_selected_catalog() if schema_manager else None + ) + else: + runner = self._text_runner + selected_catalog = None + + session = await runner.session_service.get_session( + app_name=self._agent_name, + user_id=self._user_id, + session_id=session_id, + ) + if session is None: + session = await runner.session_service.create_session( + app_name=self._agent_name, + user_id=self._user_id, + state=session_state, + session_id=session_id, + ) + elif "base_url" not in session.state: + session.state["base_url"] = self.base_url + + # --- Begin: UI Validation and Retry Logic --- + max_retries = 1 # Total 2 attempts + attempt = 0 + current_query_text = query + + # Ensure catalog schema was loaded + if ui_version and (not selected_catalog or not selected_catalog.catalog_schema): + logger.error( + "--- ContactAgent.fetch_response: A2UI_SCHEMA is not loaded. " + "Cannot perform UI validation. ---" + ) + return [ + Part( + root=TextPart( + text=( + "I'm sorry, I'm facing an internal configuration" + " error with my UI components. Please contact" + " support." + ) + ) + ) + ] + + while attempt <= max_retries: + attempt += 1 + logger.info( + "--- ContactAgent.fetch_response: Attempt" + f" {attempt}/{max_retries + 1} for session {session_id} ---" + ) + + current_message = types.Content( + role="user", parts=[types.Part.from_text(text=current_query_text)] + ) + + full_content_list = [] + + async for event in runner.run_async( + user_id=self._user_id, + session_id=session.id, + new_message=current_message, + ): + if event.is_final_response(): + if event.content and event.content.parts and event.content.parts[0].text: + full_content_list.extend([p.text for p in event.content.parts if p.text]) + + final_response_content = "".join(full_content_list) + + if final_response_content is None: + logger.warning( + "--- ContactAgent.fetch_response: Received no final response" + f" content from runner (Attempt {attempt}). ---" + ) + if attempt <= max_retries: + current_query_text = ( + "I received no response. Please try again." + f"Please retry the original request: '{query}'" + ) + logger.info(f"Retrying with query: {current_query_text}") + continue # Go to next retry + else: + logger.info("Retries exhausted on no-response") + # Retries exhausted on no-response + final_response_content = ( + "I'm sorry, I encountered an error and couldn't process your request." + ) + # Fall through to send this as a text-only error + + is_valid = False + error_message = "" + + if ui_version: + logger.info( + "--- ContactAgent.fetch_response: Validating UI response (Attempt" + f" {attempt})... ---" + ) + try: + logger.info( + "--- ContactAgent.fetch_response: trying to parse response:" + f" {final_response_content})... ---" + ) + response_parts = parse_response(final_response_content) + + for part in response_parts: + if not part.a2ui_json: + continue + + parsed_json_data = part.a2ui_json + + # Handle the "no results found" or empty JSON case + if parsed_json_data == []: + logger.info( + "--- ContactAgent.fetch_response: Empty JSON list found. " + "Assuming valid (e.g., 'no results'). ---" + ) + is_valid = True + else: + # --- Validation Steps --- + # Check if it validates against the A2UI_SCHEMA + # This will raise jsonschema.exceptions.ValidationError if it fails + logger.info( + "--- ContactAgent.fetch_response: Validating against" + " A2UI_SCHEMA... ---" + ) + selected_catalog.validator.validate(parsed_json_data) + # --- End Validation Steps --- + + logger.info( + "--- ContactAgent.fetch_response: UI JSON successfully" + " parsed AND validated against schema. Validation OK" + f" (Attempt {attempt}). ---" + ) + is_valid = True + except ( + ValueError, + json.JSONDecodeError, + jsonschema.exceptions.ValidationError, + ) as e: + logger.warning( + f"--- ContactAgent.fetch_response: A2UI validation failed: {e}" + f" (Attempt {attempt}) ---" + ) + logger.warning( + f"--- Failed response content: {final_response_content[:500]}... ---" + ) + error_message = f"Validation failed: {e}." + + else: # Not using UI, so text is always "valid" + is_valid = True + + if is_valid: + logger.info( + "--- ContactAgent.fetch_response: Response is valid. Task complete" + f" (Attempt {attempt}). ---" + ) + + # Already validated, so we can return the parts. + return parse_response_to_parts(final_response_content) + + # --- If we're here, it means validation failed --- + if attempt <= max_retries: + logger.warning( + "--- ContactAgent.fetch_response: Retrying..." + f" ({attempt}/{max_retries + 1}) ---" + ) + # Prepare the query for the retry + current_query_text = ( + f"Your previous response was invalid. {error_message} You MUST" + " generate a valid response that strictly follows the A2UI JSON" + " SCHEMA. The response MUST be a JSON list of A2UI messages." + f" Ensure each JSON part is wrapped in '{A2UI_OPEN_TAG}' and" + f" '{A2UI_CLOSE_TAG}' tags. Please retry the original request:" + f" '{query}'" + ) + # Loop continues... + + # --- If we're here, it means we've exhausted retries --- + logger.error( + "--- ContactAgent.fetch_response: Max retries exhausted. Sending" + " text-only error. ---" + ) + return [ + Part( + root=TextPart( + text=( + "I'm sorry, I'm having trouble generating the interface" + " for that request right now. Please try again in a" + " moment." + ) + ) + ) + ] + # --- End: UI Validation and Retry Logic --- diff --git a/samples/agent/adk/gemini_enterprise/cloud_run/agent_executor.py b/samples/agent/adk/gemini_enterprise/cloud_run/agent_executor.py new file mode 100644 index 000000000..a8c01232c --- /dev/null +++ b/samples/agent/adk/gemini_enterprise/cloud_run/agent_executor.py @@ -0,0 +1,154 @@ +# Copyright 2025 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 +# +# https://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 logging + +from a2a.server.agent_execution import AgentExecutor, RequestContext +from a2a.server.events import EventQueue +from a2a.server.tasks import TaskUpdater +from a2a.types import ( + DataPart, + Part, + Task, + TaskState, + TextPart, + UnsupportedOperationError, +) +from a2a.utils import ( + new_agent_parts_message, + new_task, +) +from a2a.utils.errors import ServerError +from a2ui.a2a import try_activate_a2ui_extension +from agent import ContactAgent + +logger = logging.getLogger(__name__) + + +class ContactAgentExecutor(AgentExecutor): + """Contact AgentExecutor Example.""" + + def __init__(self, agent: ContactAgent): + self._agent = agent + + async def execute( + self, + context: RequestContext, + event_queue: EventQueue, + ) -> None: + query = "" + ui_event_part = None + action = None + + logger.info(f"--- Client requested extensions: {context.requested_extensions} ---") + active_ui_version = try_activate_a2ui_extension(context, self._agent.agent_card) + + if active_ui_version: + logger.info( + "--- AGENT_EXECUTOR: A2UI extension is active" + f" (v{active_ui_version}). Using UI runner. ---" + ) + else: + logger.info( + "--- AGENT_EXECUTOR: A2UI extension is not active. Using text runner. ---" + ) + + if context.message and context.message.parts: + logger.info( + f"--- AGENT_EXECUTOR: Processing {len(context.message.parts)} message" + " parts ---" + ) + for i, part in enumerate(context.message.parts): + if isinstance(part.root, DataPart): + if "userAction" in part.root.data: + logger.info(f" Part {i}: Found a2ui UI ClientEvent payload.") + ui_event_part = part.root.data["userAction"] + else: + logger.info(f" Part {i}: DataPart (data: {part.root.data})") + elif isinstance(part.root, TextPart): + logger.info(f" Part {i}: TextPart (text: {part.root.text})") + else: + logger.info(f" Part {i}: Unknown part type ({type(part.root)})") + + if ui_event_part: + logger.info(f"Received a2ui ClientEvent: {ui_event_part}") + # Fix: Check both 'actionName' and 'name' + action = ui_event_part.get("name") + ctx = ui_event_part.get("context", {}) + + if action == "view_profile": + contact_name = ctx.get("contactName", "Unknown") + department = ctx.get("department", "") + query = f"WHO_IS: {contact_name} from {department}" + + elif action == "send_email": + contact_name = ctx.get("contactName", "Unknown") + email = ctx.get("email", "Unknown") + query = f"USER_WANTS_TO_EMAIL: {contact_name} at {email}" + + elif action == "send_message": + contact_name = ctx.get("contactName", "Unknown") + query = f"USER_WANTS_TO_MESSAGE: {contact_name}" + + elif action == "follow_contact": + query = "ACTION: follow_contact" + + elif action == "view_full_profile": + contact_name = ctx.get("contactName", "Unknown") + query = f"USER_WANTS_FULL_PROFILE: {contact_name}" + + else: + query = f"User submitted an event: {action} with data: {ctx}" + else: + logger.info("No a2ui UI event part found. Falling back to text input.") + query = context.get_user_input() + + logger.info(f"--- AGENT_EXECUTOR: Final query for LLM: '{query}' ---") + + task = context.current_task + + if not task: + task = new_task(context.message) + await event_queue.enqueue_event(task) + updater = TaskUpdater(event_queue, task.id, task.context_id) + + final_parts = await self._agent.fetch_response( + query, task.context_id, active_ui_version + ) + self._log_parts(final_parts) + + final_state = TaskState.input_required + if action in ["send_email", "send_message", "view_full_profile"]: + final_state = TaskState.completed + + await updater.update_status( + final_state, + new_agent_parts_message(final_parts, task.context_id, task.id), + final=(final_state == TaskState.completed), + ) + + async def cancel( + self, request: RequestContext, event_queue: EventQueue + ) -> Task | None: + raise ServerError(error=UnsupportedOperationError()) + + def _log_parts(self, parts: list[Part]): + logger.info("--- PARTS TO BE SENT ---") + for i, part in enumerate(parts): + logger.info(f" - Part {i}: Type = {type(part.root)}") + if isinstance(part.root, TextPart): + logger.info(f" - Text: {part.root.text[:200]}...") + elif isinstance(part.root, DataPart): + logger.info(f" - Data: {str(part.root.data)[:200]}...") + logger.info("-----------------------------") diff --git a/samples/agent/adk/gemini_enterprise/cloud_run/contact_data.json b/samples/agent/adk/gemini_enterprise/cloud_run/contact_data.json new file mode 100644 index 000000000..83d85e472 --- /dev/null +++ b/samples/agent/adk/gemini_enterprise/cloud_run/contact_data.json @@ -0,0 +1,38 @@ +[ + { + "id": "1", + "name": "Alex Li", + "title": "Product Marketing Manager", + "team": "Team Macally", + "department": "Marketing", + "location": "New York", + "email": "alex.li@example.com", + "mobile": "+1 (415) 171-1080", + "calendar": "Free until 4:00 PM", + "imageUrl": "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=100&h=100&fit=crop" + }, + { + "id": "2", + "name": "Casey Smith", + "title": "Digital Marketing Specialist", + "team": "Growth Team", + "department": "Marketing", + "location": "New York", + "email": "casey.smith@example.com", + "mobile": "+1 (415) 222-3333", + "calendar": "In a meeting", + "imageUrl": "https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=100&h=100&fit=crop" + }, + { + "id": "3", + "name": "Jordan Taylor", + "title": "Senior Software Engineer", + "team": "Core Platform", + "department": "Engineering", + "location": "San Francisco", + "email": "jordan.taylor@example.com", + "mobile": "+1 (650) 444-5555", + "calendar": "Focus time", + "imageUrl": "https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=100&h=100&fit=crop" + } +] diff --git a/samples/agent/adk/gemini_enterprise/cloud_run/deploy.sh b/samples/agent/adk/gemini_enterprise/cloud_run/deploy.sh new file mode 100755 index 000000000..e87fd8121 --- /dev/null +++ b/samples/agent/adk/gemini_enterprise/cloud_run/deploy.sh @@ -0,0 +1,79 @@ +#!/bin/bash +# 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. + + +# Exit immediately if a command exits with a non-zero status. +set -e + +# --- Configuration --- +if [[ "$#" -lt 2 ]]; then + echo "Usage: $0 [MODEL_NAME]" + echo "MODEL_NAME can be 'gemini-2.5-pro' or 'gemini-2.5-flash' (default)." + exit 1 +fi + +PROJECT_ID=$1 +SERVICE_NAME=$2 +MODEL_NAME=${3:-"gemini-2.5-flash"} + +# Validate model name +if [[ "$MODEL_NAME" != "gemini-2.5-pro" && "$MODEL_NAME" != "gemini-2.5-flash" ]]; then + echo "Invalid model name. Please use 'gemini-2.5-pro' or 'gemini-2.5-flash'." + exit 1 +fi + +# The region to deploy to +REGION="us-central1" + +# The memory to allocate to the service +MEMORY="1Gi" + +# --- Deployment --- + +echo "Starting deployment of service '$SERVICE_NAME' to project '$PROJECT_ID' in region '$REGION' with model '$MODEL_NAME'..." + +# Deploy to Cloud Run from source code +gcloud run deploy "$SERVICE_NAME" \ + --source . \ + --project "$PROJECT_ID" \ + --region "$REGION" \ + --memory "$MEMORY" \ + --no-allow-unauthenticated \ + --set-env-vars=GOOGLE_CLOUD_PROJECT="$PROJECT_ID",GOOGLE_CLOUD_LOCATION="$REGION",GOOGLE_GENAI_USE_VERTEXAI=TRUE,MODEL="$MODEL_NAME",GOOGLE_PYTHON_PACKAGE_MANAGER=uv + + +echo "Deployment complete." +echo "Service URL: $(gcloud run services describe "$SERVICE_NAME" \ + --platform managed \ + --region "$REGION" \ + --project "$PROJECT_ID" \ + --format 'value(status.url)')" + +# After the initial deployment, get the service URL +SERVICE_URL=$(gcloud run services describe "$SERVICE_NAME" \ + --project="$PROJECT_ID" \ + --region="$REGION" \ + --format='value(status.url)') + +# Update the service to set the AGENT_URL environment variable +echo "Updating service with its public URL: $SERVICE_URL" +gcloud run services update "$SERVICE_NAME" \ + --project="$PROJECT_ID" \ + --region="$REGION" \ + --update-env-vars=AGENT_URL="$SERVICE_URL" + + +echo "Deployment Complete!" +echo "Agent URL: ${SERVICE_URL}" diff --git a/samples/agent/adk/gemini_enterprise/cloud_run/examples/0.8/action_confirmation.json b/samples/agent/adk/gemini_enterprise/cloud_run/examples/0.8/action_confirmation.json new file mode 100644 index 000000000..74a665a04 --- /dev/null +++ b/samples/agent/adk/gemini_enterprise/cloud_run/examples/0.8/action_confirmation.json @@ -0,0 +1,112 @@ +[ + { + "beginRendering": { + "surfaceId": "action-modal", + "root": "modal-wrapper", + "styles": { + "primaryColor": "#007BFF", + "font": "Roboto" + } + } + }, + { + "surfaceUpdate": { + "surfaceId": "action-modal", + "components": [ + { + "id": "modal-wrapper", + "component": { + "Modal": { + "entryPointChild": "hidden-entry-point", + "contentChild": "modal-content-column" + } + } + }, + { + "id": "hidden-entry-point", + "component": { + "Text": { + "text": { + "literalString": "" + } + } + } + }, + { + "id": "modal-content-column", + "component": { + "Column": { + "children": { + "explicitList": [ + "modal-title", + "modal-message", + "dismiss-button" + ] + }, + "alignment": "center" + } + } + }, + { + "id": "modal-title", + "component": { + "Text": { + "usageHint": "h2", + "text": { + "path": "/actionTitle" + } + } + } + }, + { + "id": "modal-message", + "component": { + "Text": { + "text": { + "path": "/actionMessage" + } + } + } + }, + { + "id": "dismiss-button-text", + "component": { + "Text": { + "text": { + "literalString": "Dismiss" + } + } + } + }, + { + "id": "dismiss-button", + "component": { + "Button": { + "child": "dismiss-button-text", + "primary": true, + "action": { + "name": "dismiss_modal" + } + } + } + } + ] + } + }, + { + "dataModelUpdate": { + "surfaceId": "action-modal", + "path": "/", + "contents": [ + { + "key": "actionTitle", + "valueString": "Action Confirmation" + }, + { + "key": "actionMessage", + "valueString": "Your action has been processed." + } + ] + } + } +] \ No newline at end of file diff --git a/samples/agent/adk/gemini_enterprise/cloud_run/examples/0.8/contact_card.json b/samples/agent/adk/gemini_enterprise/cloud_run/examples/0.8/contact_card.json new file mode 100644 index 000000000..c783dce5e --- /dev/null +++ b/samples/agent/adk/gemini_enterprise/cloud_run/examples/0.8/contact_card.json @@ -0,0 +1,509 @@ +[ + { + "beginRendering": { + "surfaceId": "contact-card", + "root": "main_card" + } + }, + { + "surfaceUpdate": { + "surfaceId": "contact-card", + "components": [ + { + "id": "main_card", + "component": { + "Card": { + "child": "main_column" + } + } + }, + { + "id": "main_column", + "component": { + "Column": { + "children": { + "explicitList": [ + "profile_image_column", + "description_column", + "div", + "info_rows_column", + "action_buttons_row", + "link_text_wrapper" + ] + }, + "alignment": "stretch" + } + } + }, + { + "id": "profile_image_column", + "component": { + "Column": { + "children": { + "explicitList": [ + "profile_image" + ] + }, + "alignment": "center" + } + } + }, + { + "id": "profile_image", + "component": { + "Image": { + "url": { + "path": "/imageUrl" + }, + "usageHint": "avatar", + "fit": "cover" + } + } + }, + { + "id": "user_heading", + "weight": 1, + "component": { + "Text": { + "text": { + "path": "/name" + }, + "usageHint": "h2" + } + } + }, + { + "id": "description_text_1", + "component": { + "Text": { + "text": { + "path": "/title" + }, + "usageHint": "h4" + } + } + }, + { + "id": "description_text_2", + "component": { + "Text": { + "text": { + "path": "/team" + }, + "usageHint": "caption" + } + } + }, + { + "id": "description_column", + "component": { + "Column": { + "children": { + "explicitList": [ + "user_heading", + "description_text_1", + "description_text_2" + ] + }, + "alignment": "center" + } + } + }, + { + "id": "calendar_icon", + "component": { + "Icon": { + "name": { + "literalString": "calendarToday" + } + } + } + }, + { + "id": "calendar_secondary_text", + "component": { + "Text": { + "usageHint": "h5", + "text": { + "path": "/calendar" + } + } + } + }, + { + "id": "calendar_primary_text", + "component": { + "Text": { + "text": { + "literalString": "Calendar" + }, + "usageHint": "caption" + } + } + }, + { + "id": "calendar_text_column", + "component": { + "Column": { + "children": { + "explicitList": [ + "calendar_primary_text", + "calendar_secondary_text" + ] + }, + "distribution": "start", + "alignment": "start" + } + } + }, + { + "id": "info_row_1", + "component": { + "Row": { + "children": { + "explicitList": [ + "calendar_icon", + "calendar_text_column" + ] + }, + "distribution": "start", + "alignment": "start" + } + } + }, + { + "id": "location_icon", + "component": { + "Icon": { + "name": { + "literalString": "locationOn" + } + } + } + }, + { + "id": "location_secondary_text", + "component": { + "Text": { + "usageHint": "h5", + "text": { + "path": "/location" + } + } + } + }, + { + "id": "location_primary_text", + "component": { + "Text": { + "text": { + "literalString": "Location" + }, + "usageHint": "caption" + } + } + }, + { + "id": "location_text_column", + "component": { + "Column": { + "children": { + "explicitList": [ + "location_primary_text", + "location_secondary_text" + ] + }, + "distribution": "start", + "alignment": "start" + } + } + }, + { + "id": "info_row_2", + "component": { + "Row": { + "children": { + "explicitList": [ + "location_icon", + "location_text_column" + ] + }, + "distribution": "start", + "alignment": "start" + } + } + }, + { + "id": "mail_icon", + "component": { + "Icon": { + "name": { + "literalString": "mail" + } + } + } + }, + { + "id": "mail_secondary_text", + "component": { + "Text": { + "usageHint": "h5", + "text": { + "path": "/email" + } + } + } + }, + { + "id": "mail_primary_text", + "component": { + "Text": { + "text": { + "literalString": "Email" + }, + "usageHint": "caption" + } + } + }, + { + "id": "mail_text_column", + "component": { + "Column": { + "children": { + "explicitList": [ + "mail_primary_text", + "mail_secondary_text" + ] + }, + "distribution": "start", + "alignment": "start" + } + } + }, + { + "id": "info_row_3", + "component": { + "Row": { + "children": { + "explicitList": [ + "mail_icon", + "mail_text_column" + ] + }, + "distribution": "start", + "alignment": "start" + } + } + }, + { + "id": "div", + "component": { + "Divider": {} + } + }, + { + "id": "call_icon", + "component": { + "Icon": { + "name": { + "literalString": "call" + } + } + } + }, + { + "id": "call_secondary_text", + "component": { + "Text": { + "usageHint": "h5", + "text": { + "path": "/mobile" + } + } + } + }, + { + "id": "call_primary_text", + "component": { + "Text": { + "text": { + "literalString": "Mobile" + }, + "usageHint": "caption" + } + } + }, + { + "id": "call_text_column", + "component": { + "Column": { + "children": { + "explicitList": [ + "call_primary_text", + "call_secondary_text" + ] + }, + "distribution": "start", + "alignment": "start" + } + } + }, + { + "id": "info_row_4", + "component": { + "Row": { + "children": { + "explicitList": [ + "call_icon", + "call_text_column" + ] + }, + "distribution": "start", + "alignment": "start" + } + } + }, + { + "id": "info_rows_column", + "weight": 1, + "component": { + "Column": { + "children": { + "explicitList": [ + "info_row_1", + "info_row_2", + "info_row_3", + "info_row_4" + ] + }, + "alignment": "stretch" + } + } + }, + { + "id": "button_1_text", + "component": { + "Text": { + "text": { + "literalString": "Follow" + } + } + } + }, + { + "id": "button_1", + "component": { + "Button": { + "child": "button_1_text", + "primary": true, + "action": { + "name": "follow_contact" + } + } + } + }, + { + "id": "button_2_text", + "component": { + "Text": { + "text": { + "literalString": "Message" + } + } + } + }, + { + "id": "button_2", + "component": { + "Button": { + "child": "button_2_text", + "primary": false, + "action": { + "name": "send_message" + } + } + } + }, + { + "id": "action_buttons_row", + "component": { + "Row": { + "children": { + "explicitList": [ + "button_1", + "button_2" + ] + }, + "distribution": "center", + "alignment": "center" + } + } + }, + { + "id": "link_text", + "component": { + "Text": { + "text": { + "literalString": "[View Full Profile](/profile)" + } + } + } + }, + { + "id": "link_text_wrapper", + "component": { + "Row": { + "children": { + "explicitList": [ + "link_text" + ] + }, + "distribution": "center", + "alignment": "center" + } + } + } + ] + } + }, + { + "dataModelUpdate": { + "surfaceId": "contact-card", + "path": "/", + "contents": [ + { + "key": "name", + "valueString": "Alex Jordan" + }, + { + "key": "title", + "valueString": "Software Engineer" + }, + { + "key": "team", + "valueString": "Cloud AI" + }, + { + "key": "location", + "valueString": "Sunnyvale, CA" + }, + { + "key": "email", + "valueString": "alex.jordan@google.com" + }, + { + "key": "mobile", + "valueString": "(123) 456-7890" + }, + { + "key": "calendar", + "valueString": "Available until 5:00 PM PST" + }, + { + "key": "imageUrl", + "valueString": "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=100&h=100&fit=crop" + } + ] + } + } +] diff --git a/samples/agent/adk/gemini_enterprise/cloud_run/examples/0.8/contact_list.json b/samples/agent/adk/gemini_enterprise/cloud_run/examples/0.8/contact_list.json new file mode 100644 index 000000000..2cf52bede --- /dev/null +++ b/samples/agent/adk/gemini_enterprise/cloud_run/examples/0.8/contact_list.json @@ -0,0 +1,232 @@ +[ + { + "beginRendering": { + "surfaceId": "contact-list", + "root": "root-column", + "styles": { + "primaryColor": "#007BFF", + "font": "Roboto" + } + } + }, + { + "surfaceUpdate": { + "surfaceId": "contact-list", + "components": [ + { + "id": "root-column", + "component": { + "Column": { + "children": { + "explicitList": [ + "title-heading", + "item-list" + ] + } + } + } + }, + { + "id": "title-heading", + "component": { + "Text": { + "usageHint": "h1", + "text": { + "literalString": "Found Contacts" + } + } + } + }, + { + "id": "item-list", + "component": { + "List": { + "direction": "vertical", + "children": { + "template": { + "componentId": "item-card-template", + "dataBinding": "/contacts" + } + } + } + } + }, + { + "id": "item-card-template", + "component": { + "Card": { + "child": "card-layout" + } + } + }, + { + "id": "card-layout", + "component": { + "Row": { + "children": { + "explicitList": [ + "template-image", + "card-details", + "view-button" + ] + }, + "alignment": "center" + } + } + }, + { + "id": "template-image", + "component": { + "Image": { + "url": { + "path": "/imageUrl" + }, + "fit": "cover" + } + } + }, + { + "id": "card-details", + "component": { + "Column": { + "children": { + "explicitList": [ + "template-name", + "template-title" + ] + } + } + } + }, + { + "id": "template-name", + "component": { + "Text": { + "usageHint": "h3", + "text": { + "path": "/name" + } + } + } + }, + { + "id": "template-title", + "component": { + "Text": { + "text": { + "path": "/title" + } + } + } + }, + { + "id": "view-button-text", + "component": { + "Text": { + "text": { + "literalString": "View" + } + } + } + }, + { + "id": "view-button", + "component": { + "Button": { + "child": "view-button-text", + "primary": true, + "action": { + "name": "view_profile", + "context": [ + { + "key": "contactName", + "value": { + "path": "/name" + } + }, + { + "key": "department", + "value": { + "path": "/department" + } + } + ] + } + } + } + } + ] + } + }, + { + "dataModelUpdate": { + "surfaceId": "contact-list", + "path": "/", + "contents": [ + { + "key": "contacts", + "valueMap": [ + { + "key": "contact1", + "valueMap": [ + { + "key": "name", + "valueString": "Alice Wonderland" + }, + { + "key": "phone", + "valueString": "+1-555-123-4567" + }, + { + "key": "email", + "valueString": "alice@example.com" + }, + { + "key": "imageUrl", + "valueString": "https://example.com/alice.jpg" + }, + { + "key": "title", + "valueString": "Mad Hatter" + }, + { + "key": "department", + "valueString": "Wonderland" + } + ] + }, + { + "key": "contact2", + "valueMap": [ + { + "key": "name", + "valueString": "Bob The Builder" + }, + { + "key": "phone", + "valueString": "+1-555-765-4321" + }, + { + "key": "email", + "valueString": "bob@example.com" + }, + { + "key": "imageUrl", + "valueString": "https://example.com/bob.jpg" + }, + { + "key": "title", + "valueString": "Construction" + }, + { + "key": "department", + "valueString": "Building" + } + ] + } + ] + } + ] + } + } +] \ No newline at end of file diff --git a/samples/agent/adk/gemini_enterprise/cloud_run/examples/0.8/follow_success.json b/samples/agent/adk/gemini_enterprise/cloud_run/examples/0.8/follow_success.json new file mode 100644 index 000000000..bfc866e91 --- /dev/null +++ b/samples/agent/adk/gemini_enterprise/cloud_run/examples/0.8/follow_success.json @@ -0,0 +1,58 @@ +[ + { + "beginRendering": { + "surfaceId": "contact-card", + "root": "success_card" + } + }, + { + "surfaceUpdate": { + "surfaceId": "contact-card", + "components": [ + { + "id": "success_card", + "component": { + "Card": { + "child": "success_column" + } + } + }, + { + "id": "success_column", + "component": { + "Column": { + "children": { + "explicitList": [ + "success_icon", + "success_text" + ] + }, + "alignment": "center" + } + } + }, + { + "id": "success_icon", + "component": { + "Icon": { + "name": { + "literalString": "check" + } + } + } + }, + { + "id": "success_text", + "component": { + "Text": { + "text": { + "literalString": "Successfully Followed" + }, + "usageHint": "h2" + } + } + } + ] + } + } +] \ No newline at end of file diff --git a/samples/agent/adk/gemini_enterprise/cloud_run/main.py b/samples/agent/adk/gemini_enterprise/cloud_run/main.py new file mode 100644 index 000000000..3adbe50b1 --- /dev/null +++ b/samples/agent/adk/gemini_enterprise/cloud_run/main.py @@ -0,0 +1,61 @@ +# Copyright 2025 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 +# +# https://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 logging +import os + +from a2a.server.apps import A2AStarletteApplication +from a2a.server.request_handlers import DefaultRequestHandler +from a2a.server.tasks import InMemoryTaskStore +from agent import ContactAgent +from agent_executor import ContactAgentExecutor +from dotenv import load_dotenv +import uvicorn + +load_dotenv() + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +def serve(): + """Starts the A2UI server.""" + + try: + host = "0.0.0.0" + port = int(os.environ.get("PORT", 8080)) + base_url = f"http://{host}:{port}" + + agent = ContactAgent(base_url=base_url) + agent_executor = ContactAgentExecutor(agent=agent) + request_handler = DefaultRequestHandler( + agent_executor=agent_executor, + task_store=InMemoryTaskStore(), + ) + server = A2AStarletteApplication( + agent_card=agent.agent_card, http_handler=request_handler + ) + + app = server.build() + + print(f"Running server on {host}:{port}") + uvicorn.run(app, host=host, port=port) + + except Exception as e: + logger.error(f"An error occurred during server startup: {e}") + exit(1) + + +if __name__ == "__main__": + serve() diff --git a/samples/agent/adk/gemini_enterprise/cloud_run/prompt_builder.py b/samples/agent/adk/gemini_enterprise/cloud_run/prompt_builder.py new file mode 100644 index 000000000..4be60f1d9 --- /dev/null +++ b/samples/agent/adk/gemini_enterprise/cloud_run/prompt_builder.py @@ -0,0 +1,64 @@ +# Copyright 2025 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 +# +# https://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 a2ui.core.schema.constants import A2UI_CLOSE_TAG, A2UI_OPEN_TAG + +ROLE_DESCRIPTION = ( + "You are a helpful contact lookup assistant. Your final output MUST always" + " start with a short text description and then A2UI JSON response that" + " follows Workflow and UI descriptions." +) + +WORKFLOW_DESCRIPTION = """ +Buttons that represent the main action on a card or view (e.g., 'Follow', 'Email', 'Search') SHOULD include the `"primary": true` attribute. +""" + +UI_DESCRIPTION = f""" +- **For finding contacts (e.g., "Who is Alex Jordan?"):** + a. You MUST call the `get_contact_info` tool. + b. If the tool returns a **single contact**, you MUST use the `CONTACT_CARD_EXAMPLE` template. Populate the `dataModelUpdate.contents` with the contact's details (name, title, email, etc.). + c. If the tool returns **multiple contacts**, you MUST use the `CONTACT_LIST_EXAMPLE` template. Populate the `dataModelUpdate.contents` with the list of contacts for the "contacts" key. + d. If the tool returns an **empty list**, respond with text only and an empty JSON list: "I couldn't find anyone by that name.{A2UI_OPEN_TAG}[]{A2UI_CLOSE_TAG}" + +- **For handling a profile view (e.g., "WHO_IS: Alex Jordan..."):** + a. You MUST call the `get_contact_info` tool with the specific name. + b. This will return a single contact. You MUST use the `CONTACT_CARD_EXAMPLE` template. + +- **For listing all contacts (e.g., "List all contacts"):** + a. You MUST call the `get_contact_info` tool with `None` for the name. + b. This will return a multiple contacts. You MUST use the `CONTACT_LIST_EXAMPLE` template. + +- **For handling actions (e.g., "follow_contact"):** + a. You MUST use the `FOLLOW_SUCCESS_EXAMPLE` template. + b. This will render a new card with a "Successfully Followed" message. + c. Respond with a text confirmation like "You are now following this contact." along with the JSON. +""" + + +# For non-A2UI clients. +def get_text_prompt() -> str: + """Constructs the prompt for a text-only agent.""" + return """ + You are a helpful contact lookup assistant. Your final output MUST be a text response. + + To generate the response, you MUST follow these rules: + 1. **For finding contacts:** + a. You MUST call the `get_contact_info` tool. Extract the name and department from the user's query. + b. After receiving the data, format the contact(s) as a clear, human-readable text response. + c. If multiple contacts are found, list their names and titles. + d. If one contact is found, list all their details. + + 2. **For handling actions (e.g., "USER_WANTS_TO_EMAIL: ..."):** + a. Respond with a simple text confirmation (e.g., "Drafting an email to..."). + """ diff --git a/samples/agent/adk/gemini_enterprise/cloud_run/pyproject.toml b/samples/agent/adk/gemini_enterprise/cloud_run/pyproject.toml new file mode 100644 index 000000000..21b01d7a1 --- /dev/null +++ b/samples/agent/adk/gemini_enterprise/cloud_run/pyproject.toml @@ -0,0 +1,47 @@ +# 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. + +[project] +name = "a2ui-contact-lookup" +version = "0.1.0" +description = "Sample Google ADK-based Contact Lookup agent that uses a2ui extension and is hosted as an A2A server agent on Cloud Run." +readme = "README.md" +requires-python = ">=3.13" +dependencies = [ + "a2a-sdk>=0.3.4", + "google-adk>=1.28.0", + "google-genai>=1.27.0", + "python-dotenv>=1.1.0", + "uvicorn", + "google-cloud-aiplatform>=1.84", + "jsonschema>=4.0.0", + "a2ui-agent-sdk>=0.1.1", +] + +[tool.hatch.build.targets.wheel] +packages = ["."] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.metadata] +allow-direct-references = true + +[[tool.uv.index]] +url = "https://pypi.org/simple" +default = true + +[project.scripts] +start = "main:serve" diff --git a/samples/agent/adk/gemini_enterprise/cloud_run/tools.py b/samples/agent/adk/gemini_enterprise/cloud_run/tools.py new file mode 100644 index 000000000..2ba665c38 --- /dev/null +++ b/samples/agent/adk/gemini_enterprise/cloud_run/tools.py @@ -0,0 +1,65 @@ +# Copyright 2025 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 +# +# https://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 json +import logging +import os + +logger = logging.getLogger(__name__) + + +def get_contact_info(name: str = None, department: str = "") -> str: + """Call this tool to get a list of contacts based on a name and optional department. + + 'name' is the person's name to search for. 'department' is the optional + department to filter by. + """ + logger.info("--- TOOL CALLED: get_contact_info ---") + logger.info(f" - Name: {name}") + logger.info(f" - Department: {department}") + + results = [] + try: + script_dir = os.path.dirname(__file__) + file_path = os.path.join(script_dir, "contact_data.json") + with open(file_path) as f: + contact_data_str = f.read() + all_contacts = json.loads(contact_data_str) + + if name is None: + return json.dumps(all_contacts) + + name_lower = name.lower() + + dept_lower = department.lower() if department else "" + + # Filter by name + results = [ + contact for contact in all_contacts if name_lower in contact["name"].lower() + ] + + # If department is provided, filter results further + if dept_lower: + results = [ + contact for contact in results if dept_lower in contact["department"].lower() + ] + + logger.info(f" - Success: Found {len(results)} matching contacts.") + + except FileNotFoundError: + logger.error(f" - Error: contact_data.json not found at {file_path}") + except json.JSONDecodeError: + logger.error(f" - Error: Failed to decode JSON from {file_path}") + + return json.dumps(results)