From bfc4c7017afe2d115861ff47621f9686c26bd9f9 Mon Sep 17 00:00:00 2001 From: Yuan Tian Date: Wed, 1 Apr 2026 04:27:15 +0000 Subject: [PATCH 1/8] Add Exmaples for how to use A2UI on Gemini Enterprise --- samples/agent/adk/gemini_enterprise/README.md | 25 + .../agent_engine/.env.example | 5 + .../gemini_enterprise/agent_engine/README.md | 248 ++++++ .../agent_engine/__init__.py | 1 + .../agent_engine/a2ui_examples.py | 618 +++++++++++++ .../agent_engine/a2ui_schema.py | 834 ++++++++++++++++++ .../gemini_enterprise/agent_engine/agent.py | 9 + .../agent_engine/agent_executor.py | 238 +++++ .../agent_engine/gemini_agent.py | 158 ++++ .../gemini_enterprise/agent_engine/main.py | 234 +++++ .../agent_engine/pyproject.toml | 10 + .../adk/gemini_enterprise/cloud_run/Procfile | 1 + .../adk/gemini_enterprise/cloud_run/README.md | 247 ++++++ .../cloud_run/a2ui_examples.py | 618 +++++++++++++ .../cloud_run/a2ui_schema.py | 834 ++++++++++++++++++ .../adk/gemini_enterprise/cloud_run/agent.py | 9 + .../cloud_run/agent_executor.py | 238 +++++ .../adk/gemini_enterprise/cloud_run/deploy.sh | 65 ++ .../cloud_run/gemini_agent.py | 158 ++++ .../adk/gemini_enterprise/cloud_run/main.py | 35 + .../cloud_run/requirements.txt | 9 + 21 files changed, 4594 insertions(+) create mode 100644 samples/agent/adk/gemini_enterprise/README.md create mode 100644 samples/agent/adk/gemini_enterprise/agent_engine/.env.example create mode 100644 samples/agent/adk/gemini_enterprise/agent_engine/README.md create mode 100644 samples/agent/adk/gemini_enterprise/agent_engine/__init__.py create mode 100644 samples/agent/adk/gemini_enterprise/agent_engine/a2ui_examples.py create mode 100644 samples/agent/adk/gemini_enterprise/agent_engine/a2ui_schema.py create mode 100644 samples/agent/adk/gemini_enterprise/agent_engine/agent.py create mode 100644 samples/agent/adk/gemini_enterprise/agent_engine/agent_executor.py create mode 100644 samples/agent/adk/gemini_enterprise/agent_engine/gemini_agent.py create mode 100644 samples/agent/adk/gemini_enterprise/agent_engine/main.py create mode 100644 samples/agent/adk/gemini_enterprise/agent_engine/pyproject.toml create mode 100644 samples/agent/adk/gemini_enterprise/cloud_run/Procfile create mode 100644 samples/agent/adk/gemini_enterprise/cloud_run/README.md create mode 100644 samples/agent/adk/gemini_enterprise/cloud_run/a2ui_examples.py create mode 100644 samples/agent/adk/gemini_enterprise/cloud_run/a2ui_schema.py create mode 100644 samples/agent/adk/gemini_enterprise/cloud_run/agent.py create mode 100644 samples/agent/adk/gemini_enterprise/cloud_run/agent_executor.py create mode 100755 samples/agent/adk/gemini_enterprise/cloud_run/deploy.sh create mode 100644 samples/agent/adk/gemini_enterprise/cloud_run/gemini_agent.py create mode 100644 samples/agent/adk/gemini_enterprise/cloud_run/main.py create mode 100644 samples/agent/adk/gemini_enterprise/cloud_run/requirements.txt diff --git a/samples/agent/adk/gemini_enterprise/README.md b/samples/agent/adk/gemini_enterprise/README.md new file mode 100644 index 000000000..be08624cf --- /dev/null +++ b/samples/agent/adk/gemini_enterprise/README.md @@ -0,0 +1,25 @@ +# A2UI on Gemini Enterprise + +This folder contains examples and scripts to develop and use A2UI agents on **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 + +- `a2ui_schema.py`: Contains the JSON schema for A2UI messages, used for validation during development and at runtime. +- `a2ui_examples.py`: Provides several complete A2UI payload examples, such as Contact Cards and Action Confirmation modals. +- `agent_executor.py`: A base implementation of an A2A (Agent-to-Agent) executor that handles A2UI validation and response formatting. + +## 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..0e99519e5 --- /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..ea7538886 --- /dev/null +++ b/samples/agent/adk/gemini_enterprise/agent_engine/README.md @@ -0,0 +1,248 @@ +# 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/a2ui_on_agentengine` + - `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/a2ui_on_agentengine` +2. **Create and activate a virtual environment:** + - `uv venv source` + - `.venv/bin/activate` +3. **Install dependencies:** + - `uv sync` +4. **Run the script:** + - `uv run main.py` + - It may take 5-10 minutes to finish. + +## Customization + +To build your own agent, you will need to: + +* Implement your agent's logic, by modifying or replacing `agent_executor.py` + and the `AdkAgentToA2AExecutor` class. +* Adjust the `agent_name`, `display_name`, and `description` when calling + `_register_agent_on_gemini_enterprise` in `main.py`. + +## 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": false, + "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..256eabb5b --- /dev/null +++ b/samples/agent/adk/gemini_enterprise/agent_engine/__init__.py @@ -0,0 +1 @@ +# This file makes this directory a Python package diff --git a/samples/agent/adk/gemini_enterprise/agent_engine/a2ui_examples.py b/samples/agent/adk/gemini_enterprise/agent_engine/a2ui_examples.py new file mode 100644 index 000000000..631004379 --- /dev/null +++ b/samples/agent/adk/gemini_enterprise/agent_engine/a2ui_examples.py @@ -0,0 +1,618 @@ +# 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. + +"""Contains UI examples for A2UI sample.""" + +# a2ui_examples.py + +CONTACT_UI_EXAMPLES = """ +---BEGIN CONTACT_LIST_EXAMPLE--- +[ + { "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", "distribution": "spaceBetween" } } }, + { "id": "template-image", "component": { "Image": { "url": { "path": "imageUrl" }, "fit": "cover", "usageHint": "avatar" } } }, + { "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://lh3.googleusercontent.com/a/default-user=s80-cc" }, + { "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://lh3.googleusercontent.com/a/default-user=s80-cc" }, + { "key": "title", "valueString": "Construction" }, + { "key": "department", "valueString": "Building" } + ] } + ] } + ] + } } +] +---END CONTACT_LIST_EXAMPLE--- + +---BEGIN CONTACT_CARD_EXAMPLE--- +[ + { + "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": "calendar_today" + } + } + } + }, + { + "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": "location_on" + } + } + } + }, + { + "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" + } + ] + } + } +] +---END CONTACT_CARD_EXAMPLE--- + +---BEGIN ACTION_CONFIRMATION_EXAMPLE--- +[ + { "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." } + ] + } } +] +---END ACTION_CONFIRMATION_EXAMPLE--- + +---BEGIN FOLLOW_SUCCESS_EXAMPLE--- +[ + { "beginRendering": { "surfaceId": "contact-card", "root": "success_card"} }, + { "surfaceUpdate": { + "surfaceId": "contact-card", + "components": [ + { "id": "success_card", "component": { "Card": { "child": "success_column"} } }, + { "id": "success_icon", "component": { "Icon": { "name": { "literalString": "check_circle"}, "size": 48.0, "color": "#4CAF50"} } } , + { "id": "success_text", "component": { "Text": { "text": { "literalString": "Successfully Followed"}, "usageHint": "h2"} } } , + { "id": "success_column", "component": { "Column": { "children": { "explicitList": ["success_icon", "success_text"]} , "alignment": "center"} } } + ] + } } +] +---END FOLLOW_SUCCESS_EXAMPLE--- +""" diff --git a/samples/agent/adk/gemini_enterprise/agent_engine/a2ui_schema.py b/samples/agent/adk/gemini_enterprise/agent_engine/a2ui_schema.py new file mode 100644 index 000000000..4fd49890c --- /dev/null +++ b/samples/agent/adk/gemini_enterprise/agent_engine/a2ui_schema.py @@ -0,0 +1,834 @@ +# 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. + +"""Contains A2UI schema definitions.""" + +# a2ui_schema.py + +A2UI_SCHEMA = r""" +{ + "title": "A2UI Message Schema", + "description": "Describes a JSON payload for an A2UI (Agent to UI) message, which is used to dynamically construct and update user interfaces. A message MUST contain exactly ONE of the action properties: 'beginRendering', 'surfaceUpdate', 'dataModelUpdate', or 'deleteSurface'.", + "type": "object", + "properties": { + "beginRendering": { + "type": "object", + "description": "Signals the client to begin rendering a surface with a root component and specific styles.", + "properties": { + "surfaceId": { + "type": "string", + "description": "The unique identifier for the UI surface to be rendered." + }, + "root": { + "type": "string", + "description": "The ID of the root component to render." + }, + "styles": { + "type": "object", + "description": "Styling information for the UI.", + "properties": { + "font": { + "type": "string", + "description": "The primary font for the UI." + }, + "primaryColor": { + "type": "string", + "description": "The primary UI color as a hexadecimal code (e.g., '#00BFFF').", + "pattern": "^#[0-9a-fA-F]{6}$" + } + } + } + }, + "required": ["root", "surfaceId"] + }, + "surfaceUpdate": { + "type": "object", + "description": "Updates a surface with a new set of components.", + "properties": { + "surfaceId": { + "type": "string", + "description": "The unique identifier for the UI surface to be updated. If you are adding a new surface this *must* be a new, unique identified that has never been used for any existing surfaces shown." + }, + "components": { + "type": "array", + "description": "A list containing all UI components for the surface.", + "minItems": 1, + "items": { + "type": "object", + "description": "Represents a *single* component in a UI widget tree. This component could be one of many supported types.", + "properties": { + "id": { + "type": "string", + "description": "The unique identifier for this component." + }, + "weight": { + "type": "number", + "description": "The relative weight of this component within a Row or Column. This corresponds to the CSS 'flex-grow' property. Note: this may ONLY be set when the component is a direct descendant of a Row or Column." + }, + "component": { + "type": "object", + "description": "A wrapper object that MUST contain exactly one key, which is the name of the component type (e.g., 'Heading'). The value is an object containing the properties for that specific component.", + "properties": { + "Text": { + "type": "object", + "properties": { + "text": { + "type": "object", + "description": "The text content to display. This can be a literal string or a reference to a value in the data model ('path', e.g., '/doc/title'). While simple Markdown formatting is supported (i.e. without HTML, images, or links), utilizing dedicated UI components is generally preferred for a richer and more structured presentation.", + "properties": { + "literalString": { + "type": "string" + }, + "path": { + "type": "string" + } + } + }, + "usageHint": { + "type": "string", + "description": "A hint for the base text style. One of:\n- `h1`: Largest heading.\n- `h2`: Second largest heading.\n- `h3`: Third largest heading.\n- `h4`: Fourth largest heading.\n- `h5`: Fifth largest heading.\n- `caption`: Small text for captions.\n- `body`: Standard body text.", + "enum": [ + "h1", + "h2", + "h3", + "h4", + "h5", + "caption", + "body" + ] + } + }, + "required": ["text"] + }, + "Image": { + "type": "object", + "properties": { + "url": { + "type": "object", + "description": "The URL of the image to display. This can be a literal string ('literal') or a reference to a value in the data model ('path', e.g. '/thumbnail/url').", + "properties": { + "literalString": { + "type": "string" + }, + "path": { + "type": "string" + } + } + }, + "fit": { + "type": "string", + "description": "Specifies how the image should be resized to fit its container. This corresponds to the CSS 'object-fit' property.", + "enum": [ + "contain", + "cover", + "fill", + "none", + "scale-down" + ] + }, + "usageHint": { + "type": "string", + "description": "A hint for the image size and style. One of:\n- `icon`: Small square icon.\n- `avatar`: Circular avatar image.\n- `smallFeature`: Small feature image.\n- `mediumFeature`: Medium feature image.\n- `largeFeature`: Large feature image.\n- `header`: Full-width, full bleed, header image.", + "enum": [ + "icon", + "avatar", + "smallFeature", + "mediumFeature", + "largeFeature", + "header" + ] + } + }, + "required": ["url"] + }, + "Icon": { + "type": "object", + "properties": { + "name": { + "type": "object", + "description": "The name of the icon to display. This can be a literal string or a reference to a value in the data model ('path', e.g. '/form/submit').", + "properties": { + "literalString": { + "type": "string", + "enum": [ + "accountCircle", + "add", + "arrowBack", + "arrowForward", + "attachFile", + "calendarToday", + "call", + "camera", + "check", + "close", + "delete", + "download", + "edit", + "event", + "error", + "favorite", + "favoriteOff", + "folder", + "help", + "home", + "info", + "locationOn", + "lock", + "lockOpen", + "mail", + "menu", + "moreVert", + "moreHoriz", + "notificationsOff", + "notifications", + "payment", + "person", + "phone", + "photo", + "print", + "refresh", + "search", + "send", + "settings", + "share", + "shoppingCart", + "star", + "starHalf", + "starOff", + "upload", + "visibility", + "visibilityOff", + "warning" + ] + }, + "path": { + "type": "string" + } + } + } + }, + "required": ["name"] + }, + "Video": { + "type": "object", + "properties": { + "url": { + "type": "object", + "description": "The URL of the video to display. This can be a literal string or a reference to a value in the data model ('path', e.g. '/video/url').", + "properties": { + "literalString": { + "type": "string" + }, + "path": { + "type": "string" + } + } + } + }, + "required": ["url"] + }, + "AudioPlayer": { + "type": "object", + "properties": { + "url": { + "type": "object", + "description": "The URL of the audio to be played. This can be a literal string ('literal') or a reference to a value in the data model ('path', e.g. '/song/url').", + "properties": { + "literalString": { + "type": "string" + }, + "path": { + "type": "string" + } + } + }, + "description": { + "type": "object", + "description": "A description of the audio, such as a title or summary. This can be a literal string or a reference to a value in the data model ('path', e.g. '/song/title').", + "properties": { + "literalString": { + "type": "string" + }, + "path": { + "type": "string" + } + } + } + }, + "required": ["url"] + }, + "Row": { + "type": "object", + "properties": { + "children": { + "type": "object", + "description": "Defines the children. Use 'explicitList' for a fixed set of children, or 'template' to generate children from a data list.", + "properties": { + "explicitList": { + "type": "array", + "items": { + "type": "string" + } + }, + "template": { + "type": "object", + "description": "A template for generating a dynamic list of children from a data model list. `componentId` is the component to use as a template, and `dataBinding` is the path to the map of components in the data model. Values in the map will define the list of children.", + "properties": { + "componentId": { + "type": "string" + }, + "dataBinding": { + "type": "string" + } + }, + "required": ["componentId", "dataBinding"] + } + } + }, + "distribution": { + "type": "string", + "description": "Defines the arrangement of children along the main axis (horizontally). This corresponds to the CSS 'justify-content' property.", + "enum": [ + "center", + "end", + "spaceAround", + "spaceBetween", + "spaceEvenly", + "start" + ] + }, + "alignment": { + "type": "string", + "description": "Defines the alignment of children along the cross axis (vertically). This corresponds to the CSS 'align-items' property.", + "enum": ["start", "center", "end", "stretch"] + } + }, + "required": ["children"] + }, + "Column": { + "type": "object", + "properties": { + "children": { + "type": "object", + "description": "Defines the children. Use 'explicitList' for a fixed set of children, or 'template' to generate children from a data list.", + "properties": { + "explicitList": { + "type": "array", + "items": { + "type": "string" + } + }, + "template": { + "type": "object", + "description": "A template for generating a dynamic list of children from a data model list. `componentId` is the component to use as a template, and `dataBinding` is the path to the map of components in the data model. Values in the map will define the list of children.", + "properties": { + "componentId": { + "type": "string" + }, + "dataBinding": { + "type": "string" + } + }, + "required": ["componentId", "dataBinding"] + } + } + }, + "distribution": { + "type": "string", + "description": "Defines the arrangement of children along the main axis (vertically). This corresponds to the CSS 'justify-content' property.", + "enum": [ + "start", + "center", + "end", + "spaceBetween", + "spaceAround", + "spaceEvenly" + ] + }, + "alignment": { + "type": "string", + "description": "Defines the alignment of children along the cross axis (horizontally). This corresponds to the CSS 'align-items' property.", + "enum": ["center", "end", "start", "stretch"] + } + }, + "required": ["children"] + }, + "List": { + "type": "object", + "properties": { + "children": { + "type": "object", + "description": "Defines the children. Use 'explicitList' for a fixed set of children, or 'template' to generate children from a data list.", + "properties": { + "explicitList": { + "type": "array", + "items": { + "type": "string" + } + }, + "template": { + "type": "object", + "description": "A template for generating a dynamic list of children from a data model list. `componentId` is the component to use as a template, and `dataBinding` is the path to the map of components in the data model. Values in the map will define the list of children.", + "properties": { + "componentId": { + "type": "string" + }, + "dataBinding": { + "type": "string" + } + }, + "required": ["componentId", "dataBinding"] + } + } + }, + "direction": { + "type": "string", + "description": "The direction in which the list items are laid out.", + "enum": ["vertical", "horizontal"] + }, + "alignment": { + "type": "string", + "description": "Defines the alignment of children along the cross axis.", + "enum": ["start", "center", "end", "stretch"] + } + }, + "required": ["children"] + }, + "Card": { + "type": "object", + "properties": { + "child": { + "type": "string", + "description": "The ID of the component to be rendered inside the card." + } + }, + "required": ["child"] + }, + "Tabs": { + "type": "object", + "properties": { + "tabItems": { + "type": "array", + "description": "An array of objects, where each object defines a tab with a title and a child component.", + "items": { + "type": "object", + "properties": { + "title": { + "type": "object", + "description": "The tab title. Defines the value as either a literal value or a path to data model value (e.g. '/options/title').", + "properties": { + "literalString": { + "type": "string" + }, + "path": { + "type": "string" + } + } + }, + "child": { + "type": "string" + } + }, + "required": ["title", "child"] + } + } + }, + "required": ["tabItems"] + }, + "Divider": { + "type": "object", + "properties": { + "axis": { + "type": "string", + "description": "The orientation of the divider.", + "enum": ["horizontal", "vertical"] + } + } + }, + "Modal": { + "type": "object", + "properties": { + "entryPointChild": { + "type": "string", + "description": "The ID of the component that opens the modal when interacted with (e.g., a button)." + }, + "contentChild": { + "type": "string", + "description": "The ID of the component to be displayed inside the modal." + } + }, + "required": ["entryPointChild", "contentChild"] + }, + "Button": { + "type": "object", + "properties": { + "child": { + "type": "string", + "description": "The ID of the component to display in the button, typically a Text component." + }, + "primary": { + "type": "boolean", + "description": "Indicates if this button should be styled as the primary action." + }, + "action": { + "type": "object", + "description": "The client-side action to be dispatched when the button is clicked. It includes the action's name and an optional context payload.", + "properties": { + "name": { + "type": "string" + }, + "context": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "object", + "description": "Defines the value to be included in the context as either a literal value or a path to a data model value (e.g. '/user/name').", + "properties": { + "path": { + "type": "string" + }, + "literalString": { + "type": "string" + }, + "literalNumber": { + "type": "number" + }, + "literalBoolean": { + "type": "boolean" + } + } + } + }, + "required": ["key", "value"] + } + } + }, + "required": ["name"] + } + }, + "required": ["child", "action"] + }, + "CheckBox": { + "type": "object", + "properties": { + "label": { + "type": "object", + "description": "The text to display next to the checkbox. Defines the value as either a literal value or a path to data model ('path', e.g. '/option/label').", + "properties": { + "literalString": { + "type": "string" + }, + "path": { + "type": "string" + } + } + }, + "value": { + "type": "object", + "description": "The current state of the checkbox (true for checked, false for unchecked). This can be a literal boolean ('literalBoolean') or a reference to a value in the data model ('path', e.g. '/filter/open').", + "properties": { + "literalBoolean": { + "type": "boolean" + }, + "path": { + "type": "string" + } + } + } + }, + "required": ["label", "value"] + }, + "TextField": { + "type": "object", + "properties": { + "label": { + "type": "object", + "description": "The text label for the input field. This can be a literal string or a reference to a value in the data model ('path, e.g. '/user/name').", + "properties": { + "literalString": { + "type": "string" + }, + "path": { + "type": "string" + } + } + }, + "text": { + "type": "object", + "description": "The value of the text field. This can be a literal string or a reference to a value in the data model ('path', e.g. '/user/name').", + "properties": { + "literalString": { + "type": "string" + }, + "path": { + "type": "string" + } + } + }, + "textFieldType": { + "type": "string", + "description": "The type of input field to display.", + "enum": [ + "date", + "longText", + "number", + "shortText", + "obscured" + ] + }, + "validationRegexp": { + "type": "string", + "description": "A regular expression used for client-side validation of the input." + } + }, + "required": ["label"] + }, + "DateTimeInput": { + "type": "object", + "properties": { + "value": { + "type": "object", + "description": "The selected date and/or time value in ISO 8601 format. This can be a literal string ('literalString') or a reference to a value in the data model ('path', e.g. '/user/dob').", + "properties": { + "literalString": { + "type": "string" + }, + "path": { + "type": "string" + } + } + }, + "enableDate": { + "type": "boolean", + "description": "If true, allows the user to select a date." + }, + "enableTime": { + "type": "boolean", + "description": "If true, allows the user to select a time." + } + }, + "required": ["value"] + }, + "MultipleChoice": { + "type": "object", + "properties": { + "selections": { + "type": "object", + "description": "The currently selected values for the component. This can be a literal array of strings or a path to an array in the data model('path', e.g. '/hotel/options').", + "properties": { + "literalArray": { + "type": "array", + "items": { + "type": "string" + } + }, + "path": { + "type": "string" + } + } + }, + "options": { + "type": "array", + "description": "An array of available options for the user to choose from.", + "items": { + "type": "object", + "properties": { + "label": { + "type": "object", + "description": "The text to display for this option. This can be a literal string or a reference to a value in the data model (e.g. '/option/label').", + "properties": { + "literalString": { + "type": "string" + }, + "path": { + "type": "string" + } + } + }, + "value": { + "type": "string", + "description": "The value to be associated with this option when selected." + } + }, + "required": ["label", "value"] + } + }, + "maxAllowedSelections": { + "type": "integer", + "description": "The maximum number of options that the user is allowed to select." + } + }, + "required": ["selections", "options"] + }, + "Slider": { + "type": "object", + "properties": { + "value": { + "type": "object", + "description": "The current value of the slider. This can be a literal number ('literalNumber') or a reference to a value in the data model ('path', e.g. '/restaurant/cost').", + "properties": { + "literalNumber": { + "type": "number" + }, + "path": { + "type": "string" + } + } + }, + "minValue": { + "type": "number", + "description": "The minimum value of the slider." + }, + "maxValue": { + "type": "number", + "description": "The maximum value of the slider." + } + }, + "required": ["value"] + }, + "WebFrame": { + "type": "object", + "properties": { + "url": { + "type": "object", + "description": "The URL of the web page to display. This can be a literal string ('literalString') or a reference to a value in the data model ('path', e.g. '/page/url').", + "properties": { + "literalString": { + "type": "string" + }, + "path": { + "type": "string" + } + } + }, + "htmlContent": { + "type": "object", + "description": "The HTML content to display. This can be a literal string ('literalString') or a reference to a value in the data model ('path', e.g. '/page/html').", + "properties": { + "literalString": { + "type": "string" + }, + "path": { + "type": "string" + } + } + }, + "height": { + "type": "number", + "description": "The height of the web frame in pixels. If not provided, the frame will use a default aspect ratio." + }, + "interactionMode": { + "type": "string", + "description": "The interaction mode of the web frame. One of 'readOnly' or 'interactive'. Defaults to 'readOnly'.", + "enum": ["readOnly", "interactive"] + }, + "allowedEvents": { + "type": "array", + "description": "A list of event names that are allowed to be sent from the web frame to the agent.", + "items": { + "type": "string" + } + } + } + } + } + } + }, + "required": ["id", "component"] + } + } + }, + "required": ["surfaceId", "components"] + }, + "dataModelUpdate": { + "type": "object", + "description": "Updates the data model for a surface.", + "properties": { + "surfaceId": { + "type": "string", + "description": "The unique identifier for the UI surface this data model update applies to." + }, + "path": { + "type": "string", + "description": "An optional path to a location within the data model (e.g., '/user/name'). If omitted, or set to '/', the entire data model will be replaced." + }, + "contents": { + "type": "array", + "description": "An array of data entries. Each entry must contain a 'key' and exactly one corresponding typed 'value*' property.", + "items": { + "type": "object", + "description": "A single data entry. Exactly one 'value*' property should be provided alongside the key.", + "properties": { + "key": { + "type": "string", + "description": "The key for this data entry." + }, + "valueString": { + "type": "string" + }, + "valueNumber": { + "type": "number" + }, + "valueBoolean": { + "type": "boolean" + }, + "valueMap": { + "description": "Represents a map as an adjacency list.", + "type": "array", + "items": { + "type": "object", + "description": "One entry in the map. Exactly one 'value*' property should be provided alongside the key.", + "properties": { + "key": { + "type": "string" + }, + "valueString": { + "type": "string" + }, + "valueNumber": { + "type": "number" + }, + "valueBoolean": { + "type": "boolean" + } + }, + "required": ["key"] + } + } + }, + "required": ["key"] + } + } + }, + "required": ["contents", "surfaceId"] + }, + "deleteSurface": { + "type": "object", + "description": "Signals the client to delete the surface identified by 'surfaceId'.", + "properties": { + "surfaceId": { + "type": "string", + "description": "The unique identifier for the UI surface to be deleted." + } + }, + "required": ["surfaceId"] + } + } +} +""" 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..488f9b6b8 --- /dev/null +++ b/samples/agent/adk/gemini_enterprise/agent_engine/agent.py @@ -0,0 +1,9 @@ +"""Agent entry point for ADK web.""" + +import dotenv +import gemini_agent + +dotenv.load_dotenv() + +# ADK web looks for 'root_agent' in this file. +root_agent = gemini_agent.GeminiAgent() 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..0710b381f --- /dev/null +++ b/samples/agent/adk/gemini_enterprise/agent_engine/agent_executor.py @@ -0,0 +1,238 @@ +"""Agent executor for ADK agents with A2UI validation.""" + +import json +import logging + +import a2ui_schema +import gemini_agent +import jsonschema +from a2a import types, utils +from a2a.server import agent_execution, events, tasks +from a2a.utils import errors as a2a_errors +from google.adk import runners +from google.adk.artifacts import in_memory_artifact_service +from google.adk.memory import in_memory_memory_service +from google.adk.sessions import in_memory_session_service +from google.genai import types as genai_types + +logger = logging.getLogger(__name__) + + +class AdkAgentToA2AExecutor(agent_execution.AgentExecutor): + """An agent executor for ADK agents.""" + + _runner: runners.Runner + + def __init__( + self, + ): + # Prepare A2UI schema validator + try: + single_message_schema = json.loads(a2ui_schema.A2UI_SCHEMA) + self.a2ui_schema_object = { + "type": "array", + "items": single_message_schema, + } + logger.info("[DEBUG]A2UI_SCHEMA successfully loaded.") + except Exception as e: # pylint: disable=broad-except + logger.error("[DEBUG] Failed to parse A2UI_SCHEMA: %s", e) + self.a2ui_schema_object = None + + self._agent = gemini_agent.GeminiAgent() + self._runner = runners.Runner( + app_name=self._agent.name, + agent=self._agent, + session_service=in_memory_session_service.InMemorySessionService(), + artifact_service=in_memory_artifact_service.InMemoryArtifactService(), + memory_service=in_memory_memory_service.InMemoryMemoryService(), + ) + self._user_id = "remote_agent" + + async def execute( + self, + context: agent_execution.RequestContext, + event_queue: events.EventQueue, + ) -> None: + query = context.get_user_input() + task = context.current_task + logger.info("[DEBUG] Query: %s", query) + + if not task: + if not context.message: + return + + task = utils.new_task(context.message) + await event_queue.enqueue_event(task) + + updater = tasks.TaskUpdater(event_queue, task.id, task.context_id) + session_id = task.context_id + + session = await self._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 self._runner.session_service.create_session( + app_name=self._agent.name, + user_id=self._user_id, + state={}, + session_id=session_id, + ) + + current_query_text = query + max_retries = 1 + attempt = 0 + + # Working status + await updater.start_work() + + while attempt <= max_retries: + attempt += 1 + content = genai_types.Content( + role="user", parts=[{"text": current_query_text}] + ) + + final_response_content = None + + logger.info("[DEBUG] attempt: %s", attempt) + + try: + async for event in self._runner.run_async( + user_id=self._user_id, session_id=session.id, new_message=content + ): + # For intermediate thought updates/streaming, you might want to + # process them here. But A2UI usually requires the full JSON to be + # valid before rendering. + if event.is_final_response(): + if ( + event.content + and event.content.parts + and event.content.parts[0].text + ): + final_response_content = "\n".join( + [p.text for p in event.content.parts if p.text] + ) + logger.info( + "[DEBUG] Final response content: %s", + final_response_content, + ) + + except Exception as e: # pylint: disable=broad-except + await updater.failed( + message=utils.new_agent_text_message( + f"Task failed with error: {e!s}" + ) + ) + return + + if final_response_content is None: + if attempt <= max_retries: + current_query_text = "I received no response. Please try again." + continue + await updater.failed( + message=utils.new_agent_text_message("No response generated.") + ) + return + + logger.info("[DEBUG]Final response content: %s", final_response_content) + # Validate A2UI + is_valid = False + error_message = "" + json_string_cleaned = "[]" + text_part = final_response_content + + if "---a2ui_JSON---" not in final_response_content: + error_message = "Delimiter '---a2ui_JSON---' not found." + else: + try: + text_part, json_string = final_response_content.split( + "---a2ui_JSON---", 1 + ) + json_string_cleaned = ( + json_string.strip().lstrip("```json").rstrip("```").strip() + ) + + if not json_string_cleaned: + json_string_cleaned = "[]" + + parsed_json = json.loads(json_string_cleaned) + logger.info("[DEBUG] Parsed JSON: %s", parsed_json) + if self.a2ui_schema_object: + jsonschema.validate( + instance=parsed_json, schema=self.a2ui_schema_object + ) + + is_valid = True + except Exception as e: # pylint: disable=broad-except + error_message = f"Validation failed: {e!s}" + + if is_valid: + # Construct the A2A response + parts = [] + if text_part.strip(): + parts.append( + types.Part(root=types.TextPart(text=text_part.strip())) + ) + + logger.info("[DEBUG]UI JSON: %s", json_string_cleaned) + + json_data = json.loads(json_string_cleaned) + if isinstance(json_data, list): + for message in json_data: + ui_data_part = types.Part( + root=types.DataPart( + data=message, + metadata={"mimeType": "application/json+a2ui"}, + ) + ) + parts.append(ui_data_part) + else: + ui_data_part = types.Part( + root=types.DataPart( + data=json_data, + metadata={"mimeType": "application/json+a2ui"}, + ) + ) + parts.append(ui_data_part) + logger.info("[DEBUG] Parts: %s", parts) + + await updater.add_artifact(parts, name="response") + await updater.complete() + return + + # Retry logic + if attempt <= max_retries: + current_query_text = ( + f"Your previous response was invalid. {error_message} You MUST" + " generate a valid response that strictly follows the A2UI JSON" + f" SCHEMA. Please retry the original request: '{query}'" + ) + logger.warning( + "[DEBUG] Retrying due to validation error: %s", error_message + ) + continue + # Fallback to text only error + await updater.add_artifact( + [ + types.Part( + root=types.TextPart( + text=( + "I encountered an error generating the UI:" + f" {error_message}. Here is the raw response:" + f" {final_response_content}" + ) + ) + ) + ], + name="error_response", + ) + await updater.complete() + return + + async def cancel( + self, + context: agent_execution.RequestContext, + event_queue: events.EventQueue, + ) -> None: + raise a2a_errors.ServerError(error=types.UnsupportedOperationError()) diff --git a/samples/agent/adk/gemini_enterprise/agent_engine/gemini_agent.py b/samples/agent/adk/gemini_enterprise/agent_engine/gemini_agent.py new file mode 100644 index 000000000..f622b2c3b --- /dev/null +++ b/samples/agent/adk/gemini_enterprise/agent_engine/gemini_agent.py @@ -0,0 +1,158 @@ +"""Gemini agent for A2UI sample.""" + +import os + +import a2ui_examples +import a2ui_schema +from a2a import types +from google.adk import agents + + +# --- DEFINE YOUR TOOLS HERE --- +def get_contact_info(name: str = None) -> str: + """Gets contact information for a person. + + Args: + name: The name of the person to look up. If None, returns a list of + suggested contacts. + + Returns: + JSON string containing contact details. + """ + # Mock data + if name and "alex" in name.lower(): + return """ + { + "name": "Alex Jordan", + "title": "Software Engineer", + "team": "Cloud AI", + "location": "Sunnyvale, CA", + "email": "alexj@example.com", + "mobile": "+1-555-0102", + "calendar": "Available until 4PM", + "imageUrl": "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=100&h=100&fit=crop" + } + """ + if ( + name and "sarah" in name.lower() + ): # Match "sarah" to Sarah Chen as well for robustness + return """ + { + "name": "Sarah Chen", + "title": "Product Manager", + "team": "Cloud UI", + "location": "New York, NY", + "email": "caseys@example.com", + "mobile": "+1-555-0103", + "calendar": "In a meeting", + "imageUrl": "https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=150&h=150&fit=crop" + } + """ + + # Default list if no specific name match or no name provided + return """ + [ + { + "name": "Alex Jordan", + "title": "Software Engineer", + "team": "Cloud AI", + "location": "Sunnyvale, CA", + "email": "alexj@example.com", + "mobile": "+1-555-0102", + "calendar": "Available until 4PM", + "imageUrl": "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=100&h=100&fit=crop" + }, + { + "name": "Sarah Chen", + "title": "Product Manager", + "team": "Cloud UI", + "location": "New York, NY", + "email": "caseys@example.com", + "mobile": "+1-555-0103", + "calendar": "In a meeting", + "imageUrl": "https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=150&h=150&fit=crop" + } + ] + """ + + +def get_ui_prompt(examples: str) -> str: + """Constructs the full prompt with UI instructions, rules, examples, and schema.""" + formatted_examples = examples + + return f""" + You are a helpful contact lookup assistant. Your final output MUST be a a2ui UI JSON response. + + To generate the response, you MUST follow these rules: + 1. Your response MUST be in two parts, separated by the delimiter: `---a2ui_JSON---`. + 2. The first part is your conversational text response (e.g., "Here is the contact you requested..."). + 3. The second part is a single, raw JSON object which is a list of A2UI messages. + 4. The JSON part MUST validate against the A2UI JSON SCHEMA provided below. + 5. Buttons that represent the main action on a card or view (e.g., 'Follow', 'Email', 'Search') SHOULD include the `"primary": true` attribute. + + --- UI TEMPLATE RULES --- + - **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_JSON---[]" + + - **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 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. + + {formatted_examples} + + ---BEGIN A2UI JSON SCHEMA--- + {a2ui_schema.A2UI_SCHEMA} + ---END A2UI JSON SCHEMA--- + """ + + +class GeminiAgent(agents.LlmAgent): + """An agent powered by the Gemini model via Vertex AI.""" + + # --- AGENT IDENTITY --- + name: str = "a2ui_contact_agent" + description: str = "A contact lookup assistant with rich UI." + + def __init__(self, **kwargs): + print("Initializing A2UI GeminiAgent...") + + # In a real deployment, base_url might come from env or config + instructions = get_ui_prompt(a2ui_examples.CONTACT_UI_EXAMPLES) + + # --- REGISTER YOUR TOOLS HERE --- + tools = [get_contact_info] + + super().__init__( + model=os.environ.get("MODEL", "gemini-3-flash-preview"), + instruction=instructions, + tools=tools, + **kwargs, + ) + + def create_agent_card(self, agent_url: str) -> "AgentCard": + return types.AgentCard( + name=self.name, + description=self.description, + url=agent_url, + version="1.0.0", + default_input_modes=["text/plain"], + default_output_modes=["text/plain"], + capabilities=types.AgentCapabilities(streaming=True), + skills=[ + types.AgentSkill( + id="contact_lookup", + name="Contact Lookup", + description="Find contacts and view their details.", + tags=["contact", "directory"], + examples=["Who is Alex Jordan?", "Find software engineers"], + ) + ], + ) diff --git a/samples/agent/adk/gemini_enterprise/agent_engine/main.py b/samples/agent/adk/gemini_enterprise/agent_engine/main.py new file mode 100644 index 000000000..335b14d85 --- /dev/null +++ b/samples/agent/adk/gemini_enterprise/agent_engine/main.py @@ -0,0 +1,234 @@ +"""Main file for creating and managing A2UI agents on Agent Engine.""" + +import json +import os + +import agent_executor +import httpx +import requests +import vertexai +from a2a.types import AgentSkill +from dotenv import load_dotenv +from google.auth import default +from google.auth.transport.requests import Request +from google.genai import types +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 card s.", + skills=[agent_skill], + ) + print(f"✓ Contact Card agent card created. {cc_agent_card}") + + a2a_agent = A2aAgent( + agent_card=cc_agent_card, + agent_executor_builder=agent_executor.AdkAgentToA2AExecutor, + ) + print("✓ Local Contact Card agent created.") + + config = { + "display_name": "A2UI Contact Card Agent (Demo New Agent Card)", + "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]", + "a2a-sdk >= 0.3.4", + "cloudpickle >= 3.1.2", + "pydantic", + ], + "http_options": { + "api_version": "v1beta1", + }, + "max_instances": 1, + "extra_packages": [ + "agent_executor.py", + "a2ui_examples.py", + "a2ui_schema.py", + "agent.py", + "gemini_agent.py", + ], + "env_vars": { + "NUM_WORKERS": "1", + }, + } + + remote_agent = client.agent_engines.create(agent=a2a_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": False, + "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/pyproject.toml b/samples/agent/adk/gemini_enterprise/agent_engine/pyproject.toml new file mode 100644 index 000000000..b3c3a9af3 --- /dev/null +++ b/samples/agent/adk/gemini_enterprise/agent_engine/pyproject.toml @@ -0,0 +1,10 @@ +[project] +name = "a2ui-on-agentengine" +version = "0.1.0" +description = "A2UI Contact Card Agent on Agent Engine for Demo purposes" +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "a2a-sdk>=0.3.4", + "google-cloud-aiplatform[adk,agent-engines]>=1.128.0", +] diff --git a/samples/agent/adk/gemini_enterprise/cloud_run/Procfile b/samples/agent/adk/gemini_enterprise/cloud_run/Procfile new file mode 100644 index 000000000..2948102ee --- /dev/null +++ b/samples/agent/adk/gemini_enterprise/cloud_run/Procfile @@ -0,0 +1 @@ +web: gunicorn -w 2 -k uvicorn.workers.UvicornWorker main:app --bind 0.0.0.0:$PORT \ No newline at end of file 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..817018e1c --- /dev/null +++ b/samples/agent/adk/gemini_enterprise/cloud_run/README.md @@ -0,0 +1,247 @@ +# 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 other agents using the A2A protocol +and can display A2UI components. By the end of this guide, you will have a +publicly accessible agent running on Cloud Run and can display A2UI components +on Gemini Enterprise UI. + +## Prerequisites + +Before you begin, ensure you have the following installed and configured: + +* **Google Cloud SDK:** + [Install the gcloud CLI](https://cloud.google.com/sdk/docs/install). +* **A Google Cloud Project:** You will need a project with billing enabled to + deploy to Cloud Run. +* **Authentication:** Log in to your Google Cloud account and set up + application default credentials: + ```bash + gcloud config set project + gcloud auth login + gcloud auth application-default login + ``` + +## Project Structure + +This directory contains all the necessary files for deploying the agent: + +* `main.py`: The main entry point for the application. It initializes and runs + the FastAPI web server. +* `gemini_agent.py`: Contains the core logic and definition of the Gemini + agent, including its system instructions and tools. +* `agent_executor.py`: Handles the execution of agent tasks by interfacing + with the Google Agent Development Kit (ADK). +* `requirements.txt`: A list of all the Python dependencies required for the + agent to run. +* `Procfile`: Specifies the command to start the web server, used by Google + Cloud Run during deployment. +* `deploy.sh`: A shell script that automates the entire deployment process. + +## The ADK Agent (`gemini_agent.py`) + +The heart of our application is the `GeminiAgent` class in `gemini_agent.py`. +This class inherits from the `LlmAgent` provided by the Google ADK, and it's +where you define your agent's identity, capabilities, and tools. + +### Agent Identity + +The agent's identity is defined by its `name` and `description`. You can +customize these to reflect your agent's purpose: + +```python +class GeminiAgent(LlmAgent): + """An agent powered by the Gemini model via Vertex AI.""" + + # --- AGENT IDENTITY --- + name: str = "gemini_agent" + description: str = "A helpful assistant powered by Gemini." +``` + +### System Instructions + +The `instructions` variable within the `get_ui_prompt` method sets the agent's +system prompt. This is where you can define the agent's personality, its role, +and any constraints on its behavior. + +```python +class GeminiAgent(LlmAgent): + def __init__(self, **kwargs): + # --- SET YOUR SYSTEM INSTRUCTIONS HERE --- + instructions = """ + You are a helpful contact lookup assistant. Your final output MUST be a a2ui UI JSON response. + + + You can use the get_contact_info tool to find the contact card of a person. + """ +``` + +### Tools + +The ADK allows you to extend your agent's capabilities by giving it tools. In +this example, we have a `get_contact_info` function that the agent can call. You +can add your own tools by defining a Python function and registering it in the +`tools` list. + +```python +# --- DEFINE YOUR TOOLS HERE --- +def get_contact_info(name: str = None) -> str: + """Gets contact information for a person. + + Args: + name: The name of the person to look up. If None, returns a list of + suggested contacts. + + Returns: + JSON string containing contact details. + """ + ...... + +class GeminiAgent(LlmAgent): + def __init__(self, **kwargs): + # --- REGISTER YOUR TOOLS HERE --- + tools = [ + get_weather + ] +``` + +## The A2A Executor (`agent_executor.py`) + +The `AdkAgentToA2AExecutor` class in `agent_executor.py` is the bridge between +the A2A framework and your ADK agent. It implements the `AgentExecutor` +interface from the A2A library and is responsible for handling incoming requests +and invoking your agent. + +The `execute` method is the core of this class. It performs the following steps: + +1. **Retrieves the user's query** from the `RequestContext`. +2. **Manages the task lifecycle**, creating a new task if one doesn't exist. +3. **Manages the session**, creating a new session if one doesn't exist. +4. **Invokes the ADK Runner** by calling `self._runner.run_async()`, passing + the user's query. +5. **Streams the response** back to the A2A framework, updating the task with + the final result. + +This executor ensures that your ADK-based agent can seamlessly communicate +within the A2A protocol. + +## 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 +chmod +x deploy.sh +./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 public URL of your deployed agent. + +## Registration with 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 find your Engine 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\": \"v1.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\"], \"authentication\": {\"type\": \"http\", \"scheme\": \"bearer\", \"tokenFromEnv\": \"MY_AGENT_TOKEN\"}}" + } +}' +``` + +**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. +* `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 public URL of your deployed agent. +* `CREDENTIAL_KEY`: The key for your authentication credentials (e.g., +* `MY_AGENT_TOKEN`: The name of an environment variable that Gemini Enterprise + will read to get the bearer token for authentication. **Note on + Credentials:** At execution time, when Gemini Enterprise talks to the agent, + **Note on Authentication:** This example uses bearer token authentication. + Gemini Enterprise will read the environment variable specified in + `tokenFromEnv` (e.g., `MY_AGENT_TOKEN`) to get the token. It will then send + an HTTP `Authorization` header to your agent with the value `Bearer + `. **3. Locate the agent on the Gemini Enterprise + UI:** + +Your agent can be found in the Gemini Enterprise UI. Once you click it, you can +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/a2ui_examples.py b/samples/agent/adk/gemini_enterprise/cloud_run/a2ui_examples.py new file mode 100644 index 000000000..631004379 --- /dev/null +++ b/samples/agent/adk/gemini_enterprise/cloud_run/a2ui_examples.py @@ -0,0 +1,618 @@ +# 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. + +"""Contains UI examples for A2UI sample.""" + +# a2ui_examples.py + +CONTACT_UI_EXAMPLES = """ +---BEGIN CONTACT_LIST_EXAMPLE--- +[ + { "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", "distribution": "spaceBetween" } } }, + { "id": "template-image", "component": { "Image": { "url": { "path": "imageUrl" }, "fit": "cover", "usageHint": "avatar" } } }, + { "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://lh3.googleusercontent.com/a/default-user=s80-cc" }, + { "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://lh3.googleusercontent.com/a/default-user=s80-cc" }, + { "key": "title", "valueString": "Construction" }, + { "key": "department", "valueString": "Building" } + ] } + ] } + ] + } } +] +---END CONTACT_LIST_EXAMPLE--- + +---BEGIN CONTACT_CARD_EXAMPLE--- +[ + { + "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": "calendar_today" + } + } + } + }, + { + "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": "location_on" + } + } + } + }, + { + "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" + } + ] + } + } +] +---END CONTACT_CARD_EXAMPLE--- + +---BEGIN ACTION_CONFIRMATION_EXAMPLE--- +[ + { "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." } + ] + } } +] +---END ACTION_CONFIRMATION_EXAMPLE--- + +---BEGIN FOLLOW_SUCCESS_EXAMPLE--- +[ + { "beginRendering": { "surfaceId": "contact-card", "root": "success_card"} }, + { "surfaceUpdate": { + "surfaceId": "contact-card", + "components": [ + { "id": "success_card", "component": { "Card": { "child": "success_column"} } }, + { "id": "success_icon", "component": { "Icon": { "name": { "literalString": "check_circle"}, "size": 48.0, "color": "#4CAF50"} } } , + { "id": "success_text", "component": { "Text": { "text": { "literalString": "Successfully Followed"}, "usageHint": "h2"} } } , + { "id": "success_column", "component": { "Column": { "children": { "explicitList": ["success_icon", "success_text"]} , "alignment": "center"} } } + ] + } } +] +---END FOLLOW_SUCCESS_EXAMPLE--- +""" diff --git a/samples/agent/adk/gemini_enterprise/cloud_run/a2ui_schema.py b/samples/agent/adk/gemini_enterprise/cloud_run/a2ui_schema.py new file mode 100644 index 000000000..4fd49890c --- /dev/null +++ b/samples/agent/adk/gemini_enterprise/cloud_run/a2ui_schema.py @@ -0,0 +1,834 @@ +# 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. + +"""Contains A2UI schema definitions.""" + +# a2ui_schema.py + +A2UI_SCHEMA = r""" +{ + "title": "A2UI Message Schema", + "description": "Describes a JSON payload for an A2UI (Agent to UI) message, which is used to dynamically construct and update user interfaces. A message MUST contain exactly ONE of the action properties: 'beginRendering', 'surfaceUpdate', 'dataModelUpdate', or 'deleteSurface'.", + "type": "object", + "properties": { + "beginRendering": { + "type": "object", + "description": "Signals the client to begin rendering a surface with a root component and specific styles.", + "properties": { + "surfaceId": { + "type": "string", + "description": "The unique identifier for the UI surface to be rendered." + }, + "root": { + "type": "string", + "description": "The ID of the root component to render." + }, + "styles": { + "type": "object", + "description": "Styling information for the UI.", + "properties": { + "font": { + "type": "string", + "description": "The primary font for the UI." + }, + "primaryColor": { + "type": "string", + "description": "The primary UI color as a hexadecimal code (e.g., '#00BFFF').", + "pattern": "^#[0-9a-fA-F]{6}$" + } + } + } + }, + "required": ["root", "surfaceId"] + }, + "surfaceUpdate": { + "type": "object", + "description": "Updates a surface with a new set of components.", + "properties": { + "surfaceId": { + "type": "string", + "description": "The unique identifier for the UI surface to be updated. If you are adding a new surface this *must* be a new, unique identified that has never been used for any existing surfaces shown." + }, + "components": { + "type": "array", + "description": "A list containing all UI components for the surface.", + "minItems": 1, + "items": { + "type": "object", + "description": "Represents a *single* component in a UI widget tree. This component could be one of many supported types.", + "properties": { + "id": { + "type": "string", + "description": "The unique identifier for this component." + }, + "weight": { + "type": "number", + "description": "The relative weight of this component within a Row or Column. This corresponds to the CSS 'flex-grow' property. Note: this may ONLY be set when the component is a direct descendant of a Row or Column." + }, + "component": { + "type": "object", + "description": "A wrapper object that MUST contain exactly one key, which is the name of the component type (e.g., 'Heading'). The value is an object containing the properties for that specific component.", + "properties": { + "Text": { + "type": "object", + "properties": { + "text": { + "type": "object", + "description": "The text content to display. This can be a literal string or a reference to a value in the data model ('path', e.g., '/doc/title'). While simple Markdown formatting is supported (i.e. without HTML, images, or links), utilizing dedicated UI components is generally preferred for a richer and more structured presentation.", + "properties": { + "literalString": { + "type": "string" + }, + "path": { + "type": "string" + } + } + }, + "usageHint": { + "type": "string", + "description": "A hint for the base text style. One of:\n- `h1`: Largest heading.\n- `h2`: Second largest heading.\n- `h3`: Third largest heading.\n- `h4`: Fourth largest heading.\n- `h5`: Fifth largest heading.\n- `caption`: Small text for captions.\n- `body`: Standard body text.", + "enum": [ + "h1", + "h2", + "h3", + "h4", + "h5", + "caption", + "body" + ] + } + }, + "required": ["text"] + }, + "Image": { + "type": "object", + "properties": { + "url": { + "type": "object", + "description": "The URL of the image to display. This can be a literal string ('literal') or a reference to a value in the data model ('path', e.g. '/thumbnail/url').", + "properties": { + "literalString": { + "type": "string" + }, + "path": { + "type": "string" + } + } + }, + "fit": { + "type": "string", + "description": "Specifies how the image should be resized to fit its container. This corresponds to the CSS 'object-fit' property.", + "enum": [ + "contain", + "cover", + "fill", + "none", + "scale-down" + ] + }, + "usageHint": { + "type": "string", + "description": "A hint for the image size and style. One of:\n- `icon`: Small square icon.\n- `avatar`: Circular avatar image.\n- `smallFeature`: Small feature image.\n- `mediumFeature`: Medium feature image.\n- `largeFeature`: Large feature image.\n- `header`: Full-width, full bleed, header image.", + "enum": [ + "icon", + "avatar", + "smallFeature", + "mediumFeature", + "largeFeature", + "header" + ] + } + }, + "required": ["url"] + }, + "Icon": { + "type": "object", + "properties": { + "name": { + "type": "object", + "description": "The name of the icon to display. This can be a literal string or a reference to a value in the data model ('path', e.g. '/form/submit').", + "properties": { + "literalString": { + "type": "string", + "enum": [ + "accountCircle", + "add", + "arrowBack", + "arrowForward", + "attachFile", + "calendarToday", + "call", + "camera", + "check", + "close", + "delete", + "download", + "edit", + "event", + "error", + "favorite", + "favoriteOff", + "folder", + "help", + "home", + "info", + "locationOn", + "lock", + "lockOpen", + "mail", + "menu", + "moreVert", + "moreHoriz", + "notificationsOff", + "notifications", + "payment", + "person", + "phone", + "photo", + "print", + "refresh", + "search", + "send", + "settings", + "share", + "shoppingCart", + "star", + "starHalf", + "starOff", + "upload", + "visibility", + "visibilityOff", + "warning" + ] + }, + "path": { + "type": "string" + } + } + } + }, + "required": ["name"] + }, + "Video": { + "type": "object", + "properties": { + "url": { + "type": "object", + "description": "The URL of the video to display. This can be a literal string or a reference to a value in the data model ('path', e.g. '/video/url').", + "properties": { + "literalString": { + "type": "string" + }, + "path": { + "type": "string" + } + } + } + }, + "required": ["url"] + }, + "AudioPlayer": { + "type": "object", + "properties": { + "url": { + "type": "object", + "description": "The URL of the audio to be played. This can be a literal string ('literal') or a reference to a value in the data model ('path', e.g. '/song/url').", + "properties": { + "literalString": { + "type": "string" + }, + "path": { + "type": "string" + } + } + }, + "description": { + "type": "object", + "description": "A description of the audio, such as a title or summary. This can be a literal string or a reference to a value in the data model ('path', e.g. '/song/title').", + "properties": { + "literalString": { + "type": "string" + }, + "path": { + "type": "string" + } + } + } + }, + "required": ["url"] + }, + "Row": { + "type": "object", + "properties": { + "children": { + "type": "object", + "description": "Defines the children. Use 'explicitList' for a fixed set of children, or 'template' to generate children from a data list.", + "properties": { + "explicitList": { + "type": "array", + "items": { + "type": "string" + } + }, + "template": { + "type": "object", + "description": "A template for generating a dynamic list of children from a data model list. `componentId` is the component to use as a template, and `dataBinding` is the path to the map of components in the data model. Values in the map will define the list of children.", + "properties": { + "componentId": { + "type": "string" + }, + "dataBinding": { + "type": "string" + } + }, + "required": ["componentId", "dataBinding"] + } + } + }, + "distribution": { + "type": "string", + "description": "Defines the arrangement of children along the main axis (horizontally). This corresponds to the CSS 'justify-content' property.", + "enum": [ + "center", + "end", + "spaceAround", + "spaceBetween", + "spaceEvenly", + "start" + ] + }, + "alignment": { + "type": "string", + "description": "Defines the alignment of children along the cross axis (vertically). This corresponds to the CSS 'align-items' property.", + "enum": ["start", "center", "end", "stretch"] + } + }, + "required": ["children"] + }, + "Column": { + "type": "object", + "properties": { + "children": { + "type": "object", + "description": "Defines the children. Use 'explicitList' for a fixed set of children, or 'template' to generate children from a data list.", + "properties": { + "explicitList": { + "type": "array", + "items": { + "type": "string" + } + }, + "template": { + "type": "object", + "description": "A template for generating a dynamic list of children from a data model list. `componentId` is the component to use as a template, and `dataBinding` is the path to the map of components in the data model. Values in the map will define the list of children.", + "properties": { + "componentId": { + "type": "string" + }, + "dataBinding": { + "type": "string" + } + }, + "required": ["componentId", "dataBinding"] + } + } + }, + "distribution": { + "type": "string", + "description": "Defines the arrangement of children along the main axis (vertically). This corresponds to the CSS 'justify-content' property.", + "enum": [ + "start", + "center", + "end", + "spaceBetween", + "spaceAround", + "spaceEvenly" + ] + }, + "alignment": { + "type": "string", + "description": "Defines the alignment of children along the cross axis (horizontally). This corresponds to the CSS 'align-items' property.", + "enum": ["center", "end", "start", "stretch"] + } + }, + "required": ["children"] + }, + "List": { + "type": "object", + "properties": { + "children": { + "type": "object", + "description": "Defines the children. Use 'explicitList' for a fixed set of children, or 'template' to generate children from a data list.", + "properties": { + "explicitList": { + "type": "array", + "items": { + "type": "string" + } + }, + "template": { + "type": "object", + "description": "A template for generating a dynamic list of children from a data model list. `componentId` is the component to use as a template, and `dataBinding` is the path to the map of components in the data model. Values in the map will define the list of children.", + "properties": { + "componentId": { + "type": "string" + }, + "dataBinding": { + "type": "string" + } + }, + "required": ["componentId", "dataBinding"] + } + } + }, + "direction": { + "type": "string", + "description": "The direction in which the list items are laid out.", + "enum": ["vertical", "horizontal"] + }, + "alignment": { + "type": "string", + "description": "Defines the alignment of children along the cross axis.", + "enum": ["start", "center", "end", "stretch"] + } + }, + "required": ["children"] + }, + "Card": { + "type": "object", + "properties": { + "child": { + "type": "string", + "description": "The ID of the component to be rendered inside the card." + } + }, + "required": ["child"] + }, + "Tabs": { + "type": "object", + "properties": { + "tabItems": { + "type": "array", + "description": "An array of objects, where each object defines a tab with a title and a child component.", + "items": { + "type": "object", + "properties": { + "title": { + "type": "object", + "description": "The tab title. Defines the value as either a literal value or a path to data model value (e.g. '/options/title').", + "properties": { + "literalString": { + "type": "string" + }, + "path": { + "type": "string" + } + } + }, + "child": { + "type": "string" + } + }, + "required": ["title", "child"] + } + } + }, + "required": ["tabItems"] + }, + "Divider": { + "type": "object", + "properties": { + "axis": { + "type": "string", + "description": "The orientation of the divider.", + "enum": ["horizontal", "vertical"] + } + } + }, + "Modal": { + "type": "object", + "properties": { + "entryPointChild": { + "type": "string", + "description": "The ID of the component that opens the modal when interacted with (e.g., a button)." + }, + "contentChild": { + "type": "string", + "description": "The ID of the component to be displayed inside the modal." + } + }, + "required": ["entryPointChild", "contentChild"] + }, + "Button": { + "type": "object", + "properties": { + "child": { + "type": "string", + "description": "The ID of the component to display in the button, typically a Text component." + }, + "primary": { + "type": "boolean", + "description": "Indicates if this button should be styled as the primary action." + }, + "action": { + "type": "object", + "description": "The client-side action to be dispatched when the button is clicked. It includes the action's name and an optional context payload.", + "properties": { + "name": { + "type": "string" + }, + "context": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "object", + "description": "Defines the value to be included in the context as either a literal value or a path to a data model value (e.g. '/user/name').", + "properties": { + "path": { + "type": "string" + }, + "literalString": { + "type": "string" + }, + "literalNumber": { + "type": "number" + }, + "literalBoolean": { + "type": "boolean" + } + } + } + }, + "required": ["key", "value"] + } + } + }, + "required": ["name"] + } + }, + "required": ["child", "action"] + }, + "CheckBox": { + "type": "object", + "properties": { + "label": { + "type": "object", + "description": "The text to display next to the checkbox. Defines the value as either a literal value or a path to data model ('path', e.g. '/option/label').", + "properties": { + "literalString": { + "type": "string" + }, + "path": { + "type": "string" + } + } + }, + "value": { + "type": "object", + "description": "The current state of the checkbox (true for checked, false for unchecked). This can be a literal boolean ('literalBoolean') or a reference to a value in the data model ('path', e.g. '/filter/open').", + "properties": { + "literalBoolean": { + "type": "boolean" + }, + "path": { + "type": "string" + } + } + } + }, + "required": ["label", "value"] + }, + "TextField": { + "type": "object", + "properties": { + "label": { + "type": "object", + "description": "The text label for the input field. This can be a literal string or a reference to a value in the data model ('path, e.g. '/user/name').", + "properties": { + "literalString": { + "type": "string" + }, + "path": { + "type": "string" + } + } + }, + "text": { + "type": "object", + "description": "The value of the text field. This can be a literal string or a reference to a value in the data model ('path', e.g. '/user/name').", + "properties": { + "literalString": { + "type": "string" + }, + "path": { + "type": "string" + } + } + }, + "textFieldType": { + "type": "string", + "description": "The type of input field to display.", + "enum": [ + "date", + "longText", + "number", + "shortText", + "obscured" + ] + }, + "validationRegexp": { + "type": "string", + "description": "A regular expression used for client-side validation of the input." + } + }, + "required": ["label"] + }, + "DateTimeInput": { + "type": "object", + "properties": { + "value": { + "type": "object", + "description": "The selected date and/or time value in ISO 8601 format. This can be a literal string ('literalString') or a reference to a value in the data model ('path', e.g. '/user/dob').", + "properties": { + "literalString": { + "type": "string" + }, + "path": { + "type": "string" + } + } + }, + "enableDate": { + "type": "boolean", + "description": "If true, allows the user to select a date." + }, + "enableTime": { + "type": "boolean", + "description": "If true, allows the user to select a time." + } + }, + "required": ["value"] + }, + "MultipleChoice": { + "type": "object", + "properties": { + "selections": { + "type": "object", + "description": "The currently selected values for the component. This can be a literal array of strings or a path to an array in the data model('path', e.g. '/hotel/options').", + "properties": { + "literalArray": { + "type": "array", + "items": { + "type": "string" + } + }, + "path": { + "type": "string" + } + } + }, + "options": { + "type": "array", + "description": "An array of available options for the user to choose from.", + "items": { + "type": "object", + "properties": { + "label": { + "type": "object", + "description": "The text to display for this option. This can be a literal string or a reference to a value in the data model (e.g. '/option/label').", + "properties": { + "literalString": { + "type": "string" + }, + "path": { + "type": "string" + } + } + }, + "value": { + "type": "string", + "description": "The value to be associated with this option when selected." + } + }, + "required": ["label", "value"] + } + }, + "maxAllowedSelections": { + "type": "integer", + "description": "The maximum number of options that the user is allowed to select." + } + }, + "required": ["selections", "options"] + }, + "Slider": { + "type": "object", + "properties": { + "value": { + "type": "object", + "description": "The current value of the slider. This can be a literal number ('literalNumber') or a reference to a value in the data model ('path', e.g. '/restaurant/cost').", + "properties": { + "literalNumber": { + "type": "number" + }, + "path": { + "type": "string" + } + } + }, + "minValue": { + "type": "number", + "description": "The minimum value of the slider." + }, + "maxValue": { + "type": "number", + "description": "The maximum value of the slider." + } + }, + "required": ["value"] + }, + "WebFrame": { + "type": "object", + "properties": { + "url": { + "type": "object", + "description": "The URL of the web page to display. This can be a literal string ('literalString') or a reference to a value in the data model ('path', e.g. '/page/url').", + "properties": { + "literalString": { + "type": "string" + }, + "path": { + "type": "string" + } + } + }, + "htmlContent": { + "type": "object", + "description": "The HTML content to display. This can be a literal string ('literalString') or a reference to a value in the data model ('path', e.g. '/page/html').", + "properties": { + "literalString": { + "type": "string" + }, + "path": { + "type": "string" + } + } + }, + "height": { + "type": "number", + "description": "The height of the web frame in pixels. If not provided, the frame will use a default aspect ratio." + }, + "interactionMode": { + "type": "string", + "description": "The interaction mode of the web frame. One of 'readOnly' or 'interactive'. Defaults to 'readOnly'.", + "enum": ["readOnly", "interactive"] + }, + "allowedEvents": { + "type": "array", + "description": "A list of event names that are allowed to be sent from the web frame to the agent.", + "items": { + "type": "string" + } + } + } + } + } + } + }, + "required": ["id", "component"] + } + } + }, + "required": ["surfaceId", "components"] + }, + "dataModelUpdate": { + "type": "object", + "description": "Updates the data model for a surface.", + "properties": { + "surfaceId": { + "type": "string", + "description": "The unique identifier for the UI surface this data model update applies to." + }, + "path": { + "type": "string", + "description": "An optional path to a location within the data model (e.g., '/user/name'). If omitted, or set to '/', the entire data model will be replaced." + }, + "contents": { + "type": "array", + "description": "An array of data entries. Each entry must contain a 'key' and exactly one corresponding typed 'value*' property.", + "items": { + "type": "object", + "description": "A single data entry. Exactly one 'value*' property should be provided alongside the key.", + "properties": { + "key": { + "type": "string", + "description": "The key for this data entry." + }, + "valueString": { + "type": "string" + }, + "valueNumber": { + "type": "number" + }, + "valueBoolean": { + "type": "boolean" + }, + "valueMap": { + "description": "Represents a map as an adjacency list.", + "type": "array", + "items": { + "type": "object", + "description": "One entry in the map. Exactly one 'value*' property should be provided alongside the key.", + "properties": { + "key": { + "type": "string" + }, + "valueString": { + "type": "string" + }, + "valueNumber": { + "type": "number" + }, + "valueBoolean": { + "type": "boolean" + } + }, + "required": ["key"] + } + } + }, + "required": ["key"] + } + } + }, + "required": ["contents", "surfaceId"] + }, + "deleteSurface": { + "type": "object", + "description": "Signals the client to delete the surface identified by 'surfaceId'.", + "properties": { + "surfaceId": { + "type": "string", + "description": "The unique identifier for the UI surface to be deleted." + } + }, + "required": ["surfaceId"] + } + } +} +""" 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..6ce175396 --- /dev/null +++ b/samples/agent/adk/gemini_enterprise/cloud_run/agent.py @@ -0,0 +1,9 @@ +"""Agent entry point for ADK web.""" + +import dotenv +from a2a_a2ui_sample import gemini_agent + +dotenv.load_dotenv() + +# ADK web looks for 'root_agent' in this file. +root_agent = gemini_agent.GeminiAgent() 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..0710b381f --- /dev/null +++ b/samples/agent/adk/gemini_enterprise/cloud_run/agent_executor.py @@ -0,0 +1,238 @@ +"""Agent executor for ADK agents with A2UI validation.""" + +import json +import logging + +import a2ui_schema +import gemini_agent +import jsonschema +from a2a import types, utils +from a2a.server import agent_execution, events, tasks +from a2a.utils import errors as a2a_errors +from google.adk import runners +from google.adk.artifacts import in_memory_artifact_service +from google.adk.memory import in_memory_memory_service +from google.adk.sessions import in_memory_session_service +from google.genai import types as genai_types + +logger = logging.getLogger(__name__) + + +class AdkAgentToA2AExecutor(agent_execution.AgentExecutor): + """An agent executor for ADK agents.""" + + _runner: runners.Runner + + def __init__( + self, + ): + # Prepare A2UI schema validator + try: + single_message_schema = json.loads(a2ui_schema.A2UI_SCHEMA) + self.a2ui_schema_object = { + "type": "array", + "items": single_message_schema, + } + logger.info("[DEBUG]A2UI_SCHEMA successfully loaded.") + except Exception as e: # pylint: disable=broad-except + logger.error("[DEBUG] Failed to parse A2UI_SCHEMA: %s", e) + self.a2ui_schema_object = None + + self._agent = gemini_agent.GeminiAgent() + self._runner = runners.Runner( + app_name=self._agent.name, + agent=self._agent, + session_service=in_memory_session_service.InMemorySessionService(), + artifact_service=in_memory_artifact_service.InMemoryArtifactService(), + memory_service=in_memory_memory_service.InMemoryMemoryService(), + ) + self._user_id = "remote_agent" + + async def execute( + self, + context: agent_execution.RequestContext, + event_queue: events.EventQueue, + ) -> None: + query = context.get_user_input() + task = context.current_task + logger.info("[DEBUG] Query: %s", query) + + if not task: + if not context.message: + return + + task = utils.new_task(context.message) + await event_queue.enqueue_event(task) + + updater = tasks.TaskUpdater(event_queue, task.id, task.context_id) + session_id = task.context_id + + session = await self._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 self._runner.session_service.create_session( + app_name=self._agent.name, + user_id=self._user_id, + state={}, + session_id=session_id, + ) + + current_query_text = query + max_retries = 1 + attempt = 0 + + # Working status + await updater.start_work() + + while attempt <= max_retries: + attempt += 1 + content = genai_types.Content( + role="user", parts=[{"text": current_query_text}] + ) + + final_response_content = None + + logger.info("[DEBUG] attempt: %s", attempt) + + try: + async for event in self._runner.run_async( + user_id=self._user_id, session_id=session.id, new_message=content + ): + # For intermediate thought updates/streaming, you might want to + # process them here. But A2UI usually requires the full JSON to be + # valid before rendering. + if event.is_final_response(): + if ( + event.content + and event.content.parts + and event.content.parts[0].text + ): + final_response_content = "\n".join( + [p.text for p in event.content.parts if p.text] + ) + logger.info( + "[DEBUG] Final response content: %s", + final_response_content, + ) + + except Exception as e: # pylint: disable=broad-except + await updater.failed( + message=utils.new_agent_text_message( + f"Task failed with error: {e!s}" + ) + ) + return + + if final_response_content is None: + if attempt <= max_retries: + current_query_text = "I received no response. Please try again." + continue + await updater.failed( + message=utils.new_agent_text_message("No response generated.") + ) + return + + logger.info("[DEBUG]Final response content: %s", final_response_content) + # Validate A2UI + is_valid = False + error_message = "" + json_string_cleaned = "[]" + text_part = final_response_content + + if "---a2ui_JSON---" not in final_response_content: + error_message = "Delimiter '---a2ui_JSON---' not found." + else: + try: + text_part, json_string = final_response_content.split( + "---a2ui_JSON---", 1 + ) + json_string_cleaned = ( + json_string.strip().lstrip("```json").rstrip("```").strip() + ) + + if not json_string_cleaned: + json_string_cleaned = "[]" + + parsed_json = json.loads(json_string_cleaned) + logger.info("[DEBUG] Parsed JSON: %s", parsed_json) + if self.a2ui_schema_object: + jsonschema.validate( + instance=parsed_json, schema=self.a2ui_schema_object + ) + + is_valid = True + except Exception as e: # pylint: disable=broad-except + error_message = f"Validation failed: {e!s}" + + if is_valid: + # Construct the A2A response + parts = [] + if text_part.strip(): + parts.append( + types.Part(root=types.TextPart(text=text_part.strip())) + ) + + logger.info("[DEBUG]UI JSON: %s", json_string_cleaned) + + json_data = json.loads(json_string_cleaned) + if isinstance(json_data, list): + for message in json_data: + ui_data_part = types.Part( + root=types.DataPart( + data=message, + metadata={"mimeType": "application/json+a2ui"}, + ) + ) + parts.append(ui_data_part) + else: + ui_data_part = types.Part( + root=types.DataPart( + data=json_data, + metadata={"mimeType": "application/json+a2ui"}, + ) + ) + parts.append(ui_data_part) + logger.info("[DEBUG] Parts: %s", parts) + + await updater.add_artifact(parts, name="response") + await updater.complete() + return + + # Retry logic + if attempt <= max_retries: + current_query_text = ( + f"Your previous response was invalid. {error_message} You MUST" + " generate a valid response that strictly follows the A2UI JSON" + f" SCHEMA. Please retry the original request: '{query}'" + ) + logger.warning( + "[DEBUG] Retrying due to validation error: %s", error_message + ) + continue + # Fallback to text only error + await updater.add_artifact( + [ + types.Part( + root=types.TextPart( + text=( + "I encountered an error generating the UI:" + f" {error_message}. Here is the raw response:" + f" {final_response_content}" + ) + ) + ) + ], + name="error_response", + ) + await updater.complete() + return + + async def cancel( + self, + context: agent_execution.RequestContext, + event_queue: events.EventQueue, + ) -> None: + raise a2a_errors.ServerError(error=types.UnsupportedOperationError()) 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..b0f8aeaa1 --- /dev/null +++ b/samples/agent/adk/gemini_enterprise/cloud_run/deploy.sh @@ -0,0 +1,65 @@ +#!/bin/bash + +# 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" + + +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/gemini_agent.py b/samples/agent/adk/gemini_enterprise/cloud_run/gemini_agent.py new file mode 100644 index 000000000..bef3faf6a --- /dev/null +++ b/samples/agent/adk/gemini_enterprise/cloud_run/gemini_agent.py @@ -0,0 +1,158 @@ +"""Gemini agent for A2UI sample.""" + +import os + +import a2ui_schema +from a2a import types +from a2ui_examples import CONTACT_UI_EXAMPLES +from google.adk import agents + + +# --- DEFINE YOUR TOOLS HERE --- +def get_contact_info(name: str = None) -> str: + """Gets contact information for a person. + + Args: + name: The name of the person to look up. If None, returns a list of + suggested contacts. + + Returns: + JSON string containing contact details. + """ + # Mock data + if name and "alex" in name.lower(): + return """ + { + "name": "Alex Jordan", + "title": "Software Engineer", + "team": "Cloud AI", + "location": "Sunnyvale, CA", + "email": "alexj@example.com", + "mobile": "+1-555-0102", + "calendar": "Available until 4PM", + "imageUrl": "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=100&h=100&fit=crop" + } + """ + if ( + name and "sarah" in name.lower() + ): # Match "sarah" to Sarah Chen as well for robustness + return """ + { + "name": "Sarah Chen", + "title": "Product Manager", + "team": "Cloud UI", + "location": "New York, NY", + "email": "caseys@example.com", + "mobile": "+1-555-0103", + "calendar": "In a meeting", + "imageUrl": "https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=150&h=150&fit=crop" + } + """ + + # Default list if no specific name match or no name provided + return """ + [ + { + "name": "Alex Jordan", + "title": "Software Engineer", + "team": "Cloud AI", + "location": "Sunnyvale, CA", + "email": "alexj@example.com", + "mobile": "+1-555-0102", + "calendar": "Available until 4PM", + "imageUrl": "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=100&h=100&fit=crop" + }, + { + "name": "Sarah Chen", + "title": "Product Manager", + "team": "Cloud UI", + "location": "New York, NY", + "email": "caseys@example.com", + "mobile": "+1-555-0103", + "calendar": "In a meeting", + "imageUrl": "https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=150&h=150&fit=crop" + } + ] + """ + + +def get_ui_prompt(examples: str) -> str: + """Constructs the full prompt with UI instructions, rules, examples, and schema.""" + formatted_examples = examples + + return f""" + You are a helpful contact lookup assistant. Your final output MUST be a a2ui UI JSON response. + + To generate the response, you MUST follow these rules: + 1. Your response MUST be in two parts, separated by the delimiter: `---a2ui_JSON---`. + 2. The first part is your conversational text response (e.g., "Here is the contact you requested..."). + 3. The second part is a single, raw JSON object which is a list of A2UI messages. + 4. The JSON part MUST validate against the A2UI JSON SCHEMA provided below. + 5. Buttons that represent the main action on a card or view (e.g., 'Follow', 'Email', 'Search') SHOULD include the `"primary": true` attribute. + + --- UI TEMPLATE RULES --- + - **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_JSON---[]" + + - **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 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. + + {formatted_examples} + + ---BEGIN A2UI JSON SCHEMA--- + {a2ui_schema.A2UI_SCHEMA} + ---END A2UI JSON SCHEMA--- + """ + + +class GeminiAgent(agents.LlmAgent): + """An agent powered by the Gemini model via Vertex AI.""" + + # --- AGENT IDENTITY --- + name: str = "a2ui_contact_agent" + description: str = "A contact lookup assistant with rich UI." + + def __init__(self, **kwargs): + print("Initializing A2UI GeminiAgent...") + + # In a real deployment, base_url might come from env or config + instructions = get_ui_prompt(CONTACT_UI_EXAMPLES) + + # --- REGISTER YOUR TOOLS HERE --- + tools = [get_contact_info] + + super().__init__( + model=os.environ.get("MODEL", "gemini-2.5-flash"), + instruction=instructions, + tools=tools, + **kwargs, + ) + + def create_agent_card(self, agent_url: str) -> "AgentCard": + return types.AgentCard( + name=self.name, + description=self.description, + url=agent_url, + version="1.0.0", + default_input_modes=["text/plain"], + default_output_modes=["text/plain"], + capabilities=types.AgentCapabilities(streaming=True), + skills=[ + types.AgentSkill( + id="contact_lookup", + name="Contact Lookup", + description="Find contacts and view their details.", + tags=["contact", "directory"], + examples=["Who is Alex Jordan?", "Find software engineers"], + ) + ], + ) 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..90e0d454b --- /dev/null +++ b/samples/agent/adk/gemini_enterprise/cloud_run/main.py @@ -0,0 +1,35 @@ +"""Main entry point for the A2A A2UI sample agent.""" + +import os + +import dotenv +import uvicorn +from a2a.server import tasks +from a2a.server.apps.jsonrpc import starlette_app +from a2a.server.request_handlers import default_request_handler +from agent_executor import AdkAgentToA2AExecutor +from gemini_agent import GeminiAgent + +dotenv.load_dotenv() + +# The URL of your deployed Cloud Function. +# It's best to set this as an environment variable in your deployment. +AGENT_URL = os.environ.get("AGENT_URL", "http://127.0.0.1:8000") + +# 1. Create the AgentCard, RequestHandler, and App at the global scope. +agent = GeminiAgent() +agent_card = agent.create_agent_card(AGENT_URL) + +request_handler = default_request_handler.DefaultRequestHandler( + agent_executor=AdkAgentToA2AExecutor(), + task_store=tasks.InMemoryTaskStore(), +) + +# 2. The Functions Framework will automatically look for this 'app' variable. +app = starlette_app.A2AStarletteApplication( + agent_card=agent_card, + http_handler=request_handler, +).build() + +if __name__ == "__main__": + uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("PORT", 8000))) diff --git a/samples/agent/adk/gemini_enterprise/cloud_run/requirements.txt b/samples/agent/adk/gemini_enterprise/cloud_run/requirements.txt new file mode 100644 index 000000000..5e05566fb --- /dev/null +++ b/samples/agent/adk/gemini_enterprise/cloud_run/requirements.txt @@ -0,0 +1,9 @@ +a2a-sdk +google-generativeai +uvicorn +python-dotenv +asyncclick + +gunicorn +google-cloud-aiplatform +google-adk From df239c4812152e7706301f99194f958ec713e345 Mon Sep 17 00:00:00 2001 From: Yuan Tian Date: Wed, 1 Apr 2026 04:38:37 +0000 Subject: [PATCH 2/8] Add license --- .../adk/gemini_enterprise/agent_engine/__init__.py | 14 ++++++++++++++ .../adk/gemini_enterprise/agent_engine/agent.py | 14 ++++++++++++++ .../agent_engine/agent_executor.py | 14 ++++++++++++++ .../gemini_enterprise/agent_engine/gemini_agent.py | 14 ++++++++++++++ .../adk/gemini_enterprise/agent_engine/main.py | 14 ++++++++++++++ .../gemini_enterprise/agent_engine/pyproject.toml | 14 ++++++++++++++ .../agent/adk/gemini_enterprise/cloud_run/agent.py | 14 ++++++++++++++ .../gemini_enterprise/cloud_run/agent_executor.py | 14 ++++++++++++++ .../adk/gemini_enterprise/cloud_run/deploy.sh | 14 ++++++++++++++ .../gemini_enterprise/cloud_run/gemini_agent.py | 14 ++++++++++++++ .../agent/adk/gemini_enterprise/cloud_run/main.py | 14 ++++++++++++++ 11 files changed, 154 insertions(+) diff --git a/samples/agent/adk/gemini_enterprise/agent_engine/__init__.py b/samples/agent/adk/gemini_enterprise/agent_engine/__init__.py index 256eabb5b..2309508cb 100644 --- a/samples/agent/adk/gemini_enterprise/agent_engine/__init__.py +++ b/samples/agent/adk/gemini_enterprise/agent_engine/__init__.py @@ -1 +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 index 488f9b6b8..aadc01ec7 100644 --- a/samples/agent/adk/gemini_enterprise/agent_engine/agent.py +++ b/samples/agent/adk/gemini_enterprise/agent_engine/agent.py @@ -1,3 +1,17 @@ +# 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. + """Agent entry point for ADK web.""" import dotenv diff --git a/samples/agent/adk/gemini_enterprise/agent_engine/agent_executor.py b/samples/agent/adk/gemini_enterprise/agent_engine/agent_executor.py index 0710b381f..baa6d9c79 100644 --- a/samples/agent/adk/gemini_enterprise/agent_engine/agent_executor.py +++ b/samples/agent/adk/gemini_enterprise/agent_engine/agent_executor.py @@ -1,3 +1,17 @@ +# 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. + """Agent executor for ADK agents with A2UI validation.""" import json diff --git a/samples/agent/adk/gemini_enterprise/agent_engine/gemini_agent.py b/samples/agent/adk/gemini_enterprise/agent_engine/gemini_agent.py index f622b2c3b..90088d577 100644 --- a/samples/agent/adk/gemini_enterprise/agent_engine/gemini_agent.py +++ b/samples/agent/adk/gemini_enterprise/agent_engine/gemini_agent.py @@ -1,3 +1,17 @@ +# 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. + """Gemini agent for A2UI sample.""" import os diff --git a/samples/agent/adk/gemini_enterprise/agent_engine/main.py b/samples/agent/adk/gemini_enterprise/agent_engine/main.py index 335b14d85..78f217c28 100644 --- a/samples/agent/adk/gemini_enterprise/agent_engine/main.py +++ b/samples/agent/adk/gemini_enterprise/agent_engine/main.py @@ -1,3 +1,17 @@ +# 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 diff --git a/samples/agent/adk/gemini_enterprise/agent_engine/pyproject.toml b/samples/agent/adk/gemini_enterprise/agent_engine/pyproject.toml index b3c3a9af3..233a2aba2 100644 --- a/samples/agent/adk/gemini_enterprise/agent_engine/pyproject.toml +++ b/samples/agent/adk/gemini_enterprise/agent_engine/pyproject.toml @@ -1,3 +1,17 @@ +# 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-on-agentengine" version = "0.1.0" diff --git a/samples/agent/adk/gemini_enterprise/cloud_run/agent.py b/samples/agent/adk/gemini_enterprise/cloud_run/agent.py index 6ce175396..dbb8c38c2 100644 --- a/samples/agent/adk/gemini_enterprise/cloud_run/agent.py +++ b/samples/agent/adk/gemini_enterprise/cloud_run/agent.py @@ -1,3 +1,17 @@ +# 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. + """Agent entry point for ADK web.""" import dotenv diff --git a/samples/agent/adk/gemini_enterprise/cloud_run/agent_executor.py b/samples/agent/adk/gemini_enterprise/cloud_run/agent_executor.py index 0710b381f..baa6d9c79 100644 --- a/samples/agent/adk/gemini_enterprise/cloud_run/agent_executor.py +++ b/samples/agent/adk/gemini_enterprise/cloud_run/agent_executor.py @@ -1,3 +1,17 @@ +# 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. + """Agent executor for ADK agents with A2UI validation.""" import json diff --git a/samples/agent/adk/gemini_enterprise/cloud_run/deploy.sh b/samples/agent/adk/gemini_enterprise/cloud_run/deploy.sh index b0f8aeaa1..1cdad165b 100755 --- a/samples/agent/adk/gemini_enterprise/cloud_run/deploy.sh +++ b/samples/agent/adk/gemini_enterprise/cloud_run/deploy.sh @@ -1,4 +1,18 @@ #!/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 diff --git a/samples/agent/adk/gemini_enterprise/cloud_run/gemini_agent.py b/samples/agent/adk/gemini_enterprise/cloud_run/gemini_agent.py index bef3faf6a..5f017f4a2 100644 --- a/samples/agent/adk/gemini_enterprise/cloud_run/gemini_agent.py +++ b/samples/agent/adk/gemini_enterprise/cloud_run/gemini_agent.py @@ -1,3 +1,17 @@ +# 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. + """Gemini agent for A2UI sample.""" import os diff --git a/samples/agent/adk/gemini_enterprise/cloud_run/main.py b/samples/agent/adk/gemini_enterprise/cloud_run/main.py index 90e0d454b..2e06a91b6 100644 --- a/samples/agent/adk/gemini_enterprise/cloud_run/main.py +++ b/samples/agent/adk/gemini_enterprise/cloud_run/main.py @@ -1,3 +1,17 @@ +# 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 entry point for the A2A A2UI sample agent.""" import os From 1263159a11d7ba04b0d2f166d333cf495f08a773 Mon Sep 17 00:00:00 2001 From: Yuan Tian Date: Wed, 1 Apr 2026 04:45:16 +0000 Subject: [PATCH 3/8] Remove extra ' from script --- samples/agent/adk/gemini_enterprise/agent_engine/README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/samples/agent/adk/gemini_enterprise/agent_engine/README.md b/samples/agent/adk/gemini_enterprise/agent_engine/README.md index ea7538886..f41ab18d6 100644 --- a/samples/agent/adk/gemini_enterprise/agent_engine/README.md +++ b/samples/agent/adk/gemini_enterprise/agent_engine/README.md @@ -64,7 +64,6 @@ curl -X POST \ "tokenUri": "" } }' -' ``` Replace the following: From 4b1534d7d617db14229c2aa078236044872541a9 Mon Sep 17 00:00:00 2001 From: Yuan Tian Date: Wed, 1 Apr 2026 04:51:58 +0000 Subject: [PATCH 4/8] correct package name --- samples/agent/adk/gemini_enterprise/cloud_run/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/samples/agent/adk/gemini_enterprise/cloud_run/requirements.txt b/samples/agent/adk/gemini_enterprise/cloud_run/requirements.txt index 5e05566fb..583cd94e4 100644 --- a/samples/agent/adk/gemini_enterprise/cloud_run/requirements.txt +++ b/samples/agent/adk/gemini_enterprise/cloud_run/requirements.txt @@ -1,5 +1,5 @@ a2a-sdk -google-generativeai +google-genai uvicorn python-dotenv asyncclick From 08e2d3df044e207cb6615091eb44956e8e4b7961 Mon Sep 17 00:00:00 2001 From: Yuan Tian Date: Mon, 6 Apr 2026 18:56:03 +0000 Subject: [PATCH 5/8] Rewrite with new A2UI SDK --- .../agent_engine/.env.example | 6 +- .../gemini_enterprise/agent_engine/README.md | 30 +- .../agent_engine/a2ui_schema.py | 834 ------------------ .../gemini_enterprise/agent_engine/agent.py | 383 +++++++- .../agent_engine/agent_executor.py | 375 +++----- .../agent_engine/contact_data.json | 38 + .../gemini_enterprise/agent_engine/deploy.py | 263 ++++++ .../examples/0.8/action_confirmation.json | 112 +++ .../0.8/contact_card.json} | 129 +-- .../examples/0.8/contact_list.json | 232 +++++ .../examples/0.8/follow_success.json | 58 ++ .../agent_engine/gemini_agent.py | 172 ---- .../gemini_enterprise/agent_engine/main.py | 248 ------ .../agent_engine/prompt_builder.py | 64 ++ .../agent_engine/pyproject.toml | 7 +- .../gemini_enterprise/agent_engine/tools.py | 69 ++ .../adk/gemini_enterprise/cloud_run/Procfile | 1 - .../adk/gemini_enterprise/cloud_run/README.md | 164 +--- .../gemini_enterprise/cloud_run/__init__.py | 13 + .../cloud_run/a2ui_schema.py | 834 ------------------ .../adk/gemini_enterprise/cloud_run/agent.py | 383 +++++++- .../cloud_run/agent_executor.py | 375 +++----- .../cloud_run/contact_data.json | 38 + .../adk/gemini_enterprise/cloud_run/deploy.sh | 2 +- .../examples/0.8/action_confirmation.json | 112 +++ .../0.8/contact_card.json} | 129 +-- .../cloud_run/examples/0.8/contact_list.json | 232 +++++ .../examples/0.8/follow_success.json | 58 ++ .../cloud_run/gemini_agent.py | 172 ---- .../adk/gemini_enterprise/cloud_run/main.py | 66 +- .../cloud_run/prompt_builder.py | 64 ++ .../cloud_run/pyproject.toml | 47 + .../cloud_run/requirements.txt | 9 - .../adk/gemini_enterprise/cloud_run/tools.py | 69 ++ 34 files changed, 2611 insertions(+), 3177 deletions(-) delete mode 100644 samples/agent/adk/gemini_enterprise/agent_engine/a2ui_schema.py create mode 100644 samples/agent/adk/gemini_enterprise/agent_engine/contact_data.json create mode 100644 samples/agent/adk/gemini_enterprise/agent_engine/deploy.py create mode 100644 samples/agent/adk/gemini_enterprise/agent_engine/examples/0.8/action_confirmation.json rename samples/agent/adk/gemini_enterprise/agent_engine/{a2ui_examples.py => examples/0.8/contact_card.json} (64%) create mode 100644 samples/agent/adk/gemini_enterprise/agent_engine/examples/0.8/contact_list.json create mode 100644 samples/agent/adk/gemini_enterprise/agent_engine/examples/0.8/follow_success.json delete mode 100644 samples/agent/adk/gemini_enterprise/agent_engine/gemini_agent.py delete mode 100644 samples/agent/adk/gemini_enterprise/agent_engine/main.py create mode 100644 samples/agent/adk/gemini_enterprise/agent_engine/prompt_builder.py create mode 100644 samples/agent/adk/gemini_enterprise/agent_engine/tools.py delete mode 100644 samples/agent/adk/gemini_enterprise/cloud_run/Procfile create mode 100644 samples/agent/adk/gemini_enterprise/cloud_run/__init__.py delete mode 100644 samples/agent/adk/gemini_enterprise/cloud_run/a2ui_schema.py create mode 100644 samples/agent/adk/gemini_enterprise/cloud_run/contact_data.json create mode 100644 samples/agent/adk/gemini_enterprise/cloud_run/examples/0.8/action_confirmation.json rename samples/agent/adk/gemini_enterprise/cloud_run/{a2ui_examples.py => examples/0.8/contact_card.json} (64%) create mode 100644 samples/agent/adk/gemini_enterprise/cloud_run/examples/0.8/contact_list.json create mode 100644 samples/agent/adk/gemini_enterprise/cloud_run/examples/0.8/follow_success.json delete mode 100644 samples/agent/adk/gemini_enterprise/cloud_run/gemini_agent.py create mode 100644 samples/agent/adk/gemini_enterprise/cloud_run/prompt_builder.py create mode 100644 samples/agent/adk/gemini_enterprise/cloud_run/pyproject.toml delete mode 100644 samples/agent/adk/gemini_enterprise/cloud_run/requirements.txt create mode 100644 samples/agent/adk/gemini_enterprise/cloud_run/tools.py diff --git a/samples/agent/adk/gemini_enterprise/agent_engine/.env.example b/samples/agent/adk/gemini_enterprise/agent_engine/.env.example index 0e99519e5..6ccb90ae1 100644 --- a/samples/agent/adk/gemini_enterprise/agent_engine/.env.example +++ b/samples/agent/adk/gemini_enterprise/agent_engine/.env.example @@ -1,5 +1,5 @@ -PROJECT_ID= -LOCATION= +PROJECT_ID= +LOCATION= STORAGE_BUCKET= -GEMINI_ENTERPRISE_APP_ID= +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 index f41ab18d6..0cf4d2ef2 100644 --- a/samples/agent/adk/gemini_enterprise/agent_engine/README.md +++ b/samples/agent/adk/gemini_enterprise/agent_engine/README.md @@ -102,18 +102,19 @@ NOTE: if you already have an agent that is deployed to Agent Engine, skip to 1. **Copy `.env.example`:** Duplicate the `.env.example` file and rename it to `.env`. - - `cd /path/to/a2ui_on_agentengine` + - `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. + * `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. + 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 @@ -130,14 +131,14 @@ The `main.py` script performs the following actions: To run the script using `uv`: 1. **Navigate to the script directory:** - - `cd /path/to/a2ui_on_agentengine` + - `cd /path/to/agent_engine` 2. **Create and activate a virtual environment:** - - `uv venv source` - - `.venv/bin/activate` + - `uv venv` + - `source .venv/bin/activate` 3. **Install dependencies:** - `uv sync` 4. **Run the script:** - - `uv run main.py` + - `uv run deploy.py` - It may take 5-10 minutes to finish. ## Customization @@ -183,7 +184,7 @@ register it on Gemini Enterprise without running "main.py" script. ], "version": "1.0.0", "capabilities": { - "streaming": false, + "streaming": true, "extensions": [ { "uri": "https://a2ui.org/a2a-extension/a2ui/v0.8", @@ -212,9 +213,9 @@ register it on Gemini Enterprise without running "main.py" script. 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**. + - 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. @@ -232,7 +233,8 @@ register it on Gemini Enterprise without running "main.py" script. ## Test Your Agent -1. Open Google Cloud Console and search for **"Gemini Enterprise"** and click on it. +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 diff --git a/samples/agent/adk/gemini_enterprise/agent_engine/a2ui_schema.py b/samples/agent/adk/gemini_enterprise/agent_engine/a2ui_schema.py deleted file mode 100644 index 4fd49890c..000000000 --- a/samples/agent/adk/gemini_enterprise/agent_engine/a2ui_schema.py +++ /dev/null @@ -1,834 +0,0 @@ -# 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. - -"""Contains A2UI schema definitions.""" - -# a2ui_schema.py - -A2UI_SCHEMA = r""" -{ - "title": "A2UI Message Schema", - "description": "Describes a JSON payload for an A2UI (Agent to UI) message, which is used to dynamically construct and update user interfaces. A message MUST contain exactly ONE of the action properties: 'beginRendering', 'surfaceUpdate', 'dataModelUpdate', or 'deleteSurface'.", - "type": "object", - "properties": { - "beginRendering": { - "type": "object", - "description": "Signals the client to begin rendering a surface with a root component and specific styles.", - "properties": { - "surfaceId": { - "type": "string", - "description": "The unique identifier for the UI surface to be rendered." - }, - "root": { - "type": "string", - "description": "The ID of the root component to render." - }, - "styles": { - "type": "object", - "description": "Styling information for the UI.", - "properties": { - "font": { - "type": "string", - "description": "The primary font for the UI." - }, - "primaryColor": { - "type": "string", - "description": "The primary UI color as a hexadecimal code (e.g., '#00BFFF').", - "pattern": "^#[0-9a-fA-F]{6}$" - } - } - } - }, - "required": ["root", "surfaceId"] - }, - "surfaceUpdate": { - "type": "object", - "description": "Updates a surface with a new set of components.", - "properties": { - "surfaceId": { - "type": "string", - "description": "The unique identifier for the UI surface to be updated. If you are adding a new surface this *must* be a new, unique identified that has never been used for any existing surfaces shown." - }, - "components": { - "type": "array", - "description": "A list containing all UI components for the surface.", - "minItems": 1, - "items": { - "type": "object", - "description": "Represents a *single* component in a UI widget tree. This component could be one of many supported types.", - "properties": { - "id": { - "type": "string", - "description": "The unique identifier for this component." - }, - "weight": { - "type": "number", - "description": "The relative weight of this component within a Row or Column. This corresponds to the CSS 'flex-grow' property. Note: this may ONLY be set when the component is a direct descendant of a Row or Column." - }, - "component": { - "type": "object", - "description": "A wrapper object that MUST contain exactly one key, which is the name of the component type (e.g., 'Heading'). The value is an object containing the properties for that specific component.", - "properties": { - "Text": { - "type": "object", - "properties": { - "text": { - "type": "object", - "description": "The text content to display. This can be a literal string or a reference to a value in the data model ('path', e.g., '/doc/title'). While simple Markdown formatting is supported (i.e. without HTML, images, or links), utilizing dedicated UI components is generally preferred for a richer and more structured presentation.", - "properties": { - "literalString": { - "type": "string" - }, - "path": { - "type": "string" - } - } - }, - "usageHint": { - "type": "string", - "description": "A hint for the base text style. One of:\n- `h1`: Largest heading.\n- `h2`: Second largest heading.\n- `h3`: Third largest heading.\n- `h4`: Fourth largest heading.\n- `h5`: Fifth largest heading.\n- `caption`: Small text for captions.\n- `body`: Standard body text.", - "enum": [ - "h1", - "h2", - "h3", - "h4", - "h5", - "caption", - "body" - ] - } - }, - "required": ["text"] - }, - "Image": { - "type": "object", - "properties": { - "url": { - "type": "object", - "description": "The URL of the image to display. This can be a literal string ('literal') or a reference to a value in the data model ('path', e.g. '/thumbnail/url').", - "properties": { - "literalString": { - "type": "string" - }, - "path": { - "type": "string" - } - } - }, - "fit": { - "type": "string", - "description": "Specifies how the image should be resized to fit its container. This corresponds to the CSS 'object-fit' property.", - "enum": [ - "contain", - "cover", - "fill", - "none", - "scale-down" - ] - }, - "usageHint": { - "type": "string", - "description": "A hint for the image size and style. One of:\n- `icon`: Small square icon.\n- `avatar`: Circular avatar image.\n- `smallFeature`: Small feature image.\n- `mediumFeature`: Medium feature image.\n- `largeFeature`: Large feature image.\n- `header`: Full-width, full bleed, header image.", - "enum": [ - "icon", - "avatar", - "smallFeature", - "mediumFeature", - "largeFeature", - "header" - ] - } - }, - "required": ["url"] - }, - "Icon": { - "type": "object", - "properties": { - "name": { - "type": "object", - "description": "The name of the icon to display. This can be a literal string or a reference to a value in the data model ('path', e.g. '/form/submit').", - "properties": { - "literalString": { - "type": "string", - "enum": [ - "accountCircle", - "add", - "arrowBack", - "arrowForward", - "attachFile", - "calendarToday", - "call", - "camera", - "check", - "close", - "delete", - "download", - "edit", - "event", - "error", - "favorite", - "favoriteOff", - "folder", - "help", - "home", - "info", - "locationOn", - "lock", - "lockOpen", - "mail", - "menu", - "moreVert", - "moreHoriz", - "notificationsOff", - "notifications", - "payment", - "person", - "phone", - "photo", - "print", - "refresh", - "search", - "send", - "settings", - "share", - "shoppingCart", - "star", - "starHalf", - "starOff", - "upload", - "visibility", - "visibilityOff", - "warning" - ] - }, - "path": { - "type": "string" - } - } - } - }, - "required": ["name"] - }, - "Video": { - "type": "object", - "properties": { - "url": { - "type": "object", - "description": "The URL of the video to display. This can be a literal string or a reference to a value in the data model ('path', e.g. '/video/url').", - "properties": { - "literalString": { - "type": "string" - }, - "path": { - "type": "string" - } - } - } - }, - "required": ["url"] - }, - "AudioPlayer": { - "type": "object", - "properties": { - "url": { - "type": "object", - "description": "The URL of the audio to be played. This can be a literal string ('literal') or a reference to a value in the data model ('path', e.g. '/song/url').", - "properties": { - "literalString": { - "type": "string" - }, - "path": { - "type": "string" - } - } - }, - "description": { - "type": "object", - "description": "A description of the audio, such as a title or summary. This can be a literal string or a reference to a value in the data model ('path', e.g. '/song/title').", - "properties": { - "literalString": { - "type": "string" - }, - "path": { - "type": "string" - } - } - } - }, - "required": ["url"] - }, - "Row": { - "type": "object", - "properties": { - "children": { - "type": "object", - "description": "Defines the children. Use 'explicitList' for a fixed set of children, or 'template' to generate children from a data list.", - "properties": { - "explicitList": { - "type": "array", - "items": { - "type": "string" - } - }, - "template": { - "type": "object", - "description": "A template for generating a dynamic list of children from a data model list. `componentId` is the component to use as a template, and `dataBinding` is the path to the map of components in the data model. Values in the map will define the list of children.", - "properties": { - "componentId": { - "type": "string" - }, - "dataBinding": { - "type": "string" - } - }, - "required": ["componentId", "dataBinding"] - } - } - }, - "distribution": { - "type": "string", - "description": "Defines the arrangement of children along the main axis (horizontally). This corresponds to the CSS 'justify-content' property.", - "enum": [ - "center", - "end", - "spaceAround", - "spaceBetween", - "spaceEvenly", - "start" - ] - }, - "alignment": { - "type": "string", - "description": "Defines the alignment of children along the cross axis (vertically). This corresponds to the CSS 'align-items' property.", - "enum": ["start", "center", "end", "stretch"] - } - }, - "required": ["children"] - }, - "Column": { - "type": "object", - "properties": { - "children": { - "type": "object", - "description": "Defines the children. Use 'explicitList' for a fixed set of children, or 'template' to generate children from a data list.", - "properties": { - "explicitList": { - "type": "array", - "items": { - "type": "string" - } - }, - "template": { - "type": "object", - "description": "A template for generating a dynamic list of children from a data model list. `componentId` is the component to use as a template, and `dataBinding` is the path to the map of components in the data model. Values in the map will define the list of children.", - "properties": { - "componentId": { - "type": "string" - }, - "dataBinding": { - "type": "string" - } - }, - "required": ["componentId", "dataBinding"] - } - } - }, - "distribution": { - "type": "string", - "description": "Defines the arrangement of children along the main axis (vertically). This corresponds to the CSS 'justify-content' property.", - "enum": [ - "start", - "center", - "end", - "spaceBetween", - "spaceAround", - "spaceEvenly" - ] - }, - "alignment": { - "type": "string", - "description": "Defines the alignment of children along the cross axis (horizontally). This corresponds to the CSS 'align-items' property.", - "enum": ["center", "end", "start", "stretch"] - } - }, - "required": ["children"] - }, - "List": { - "type": "object", - "properties": { - "children": { - "type": "object", - "description": "Defines the children. Use 'explicitList' for a fixed set of children, or 'template' to generate children from a data list.", - "properties": { - "explicitList": { - "type": "array", - "items": { - "type": "string" - } - }, - "template": { - "type": "object", - "description": "A template for generating a dynamic list of children from a data model list. `componentId` is the component to use as a template, and `dataBinding` is the path to the map of components in the data model. Values in the map will define the list of children.", - "properties": { - "componentId": { - "type": "string" - }, - "dataBinding": { - "type": "string" - } - }, - "required": ["componentId", "dataBinding"] - } - } - }, - "direction": { - "type": "string", - "description": "The direction in which the list items are laid out.", - "enum": ["vertical", "horizontal"] - }, - "alignment": { - "type": "string", - "description": "Defines the alignment of children along the cross axis.", - "enum": ["start", "center", "end", "stretch"] - } - }, - "required": ["children"] - }, - "Card": { - "type": "object", - "properties": { - "child": { - "type": "string", - "description": "The ID of the component to be rendered inside the card." - } - }, - "required": ["child"] - }, - "Tabs": { - "type": "object", - "properties": { - "tabItems": { - "type": "array", - "description": "An array of objects, where each object defines a tab with a title and a child component.", - "items": { - "type": "object", - "properties": { - "title": { - "type": "object", - "description": "The tab title. Defines the value as either a literal value or a path to data model value (e.g. '/options/title').", - "properties": { - "literalString": { - "type": "string" - }, - "path": { - "type": "string" - } - } - }, - "child": { - "type": "string" - } - }, - "required": ["title", "child"] - } - } - }, - "required": ["tabItems"] - }, - "Divider": { - "type": "object", - "properties": { - "axis": { - "type": "string", - "description": "The orientation of the divider.", - "enum": ["horizontal", "vertical"] - } - } - }, - "Modal": { - "type": "object", - "properties": { - "entryPointChild": { - "type": "string", - "description": "The ID of the component that opens the modal when interacted with (e.g., a button)." - }, - "contentChild": { - "type": "string", - "description": "The ID of the component to be displayed inside the modal." - } - }, - "required": ["entryPointChild", "contentChild"] - }, - "Button": { - "type": "object", - "properties": { - "child": { - "type": "string", - "description": "The ID of the component to display in the button, typically a Text component." - }, - "primary": { - "type": "boolean", - "description": "Indicates if this button should be styled as the primary action." - }, - "action": { - "type": "object", - "description": "The client-side action to be dispatched when the button is clicked. It includes the action's name and an optional context payload.", - "properties": { - "name": { - "type": "string" - }, - "context": { - "type": "array", - "items": { - "type": "object", - "properties": { - "key": { - "type": "string" - }, - "value": { - "type": "object", - "description": "Defines the value to be included in the context as either a literal value or a path to a data model value (e.g. '/user/name').", - "properties": { - "path": { - "type": "string" - }, - "literalString": { - "type": "string" - }, - "literalNumber": { - "type": "number" - }, - "literalBoolean": { - "type": "boolean" - } - } - } - }, - "required": ["key", "value"] - } - } - }, - "required": ["name"] - } - }, - "required": ["child", "action"] - }, - "CheckBox": { - "type": "object", - "properties": { - "label": { - "type": "object", - "description": "The text to display next to the checkbox. Defines the value as either a literal value or a path to data model ('path', e.g. '/option/label').", - "properties": { - "literalString": { - "type": "string" - }, - "path": { - "type": "string" - } - } - }, - "value": { - "type": "object", - "description": "The current state of the checkbox (true for checked, false for unchecked). This can be a literal boolean ('literalBoolean') or a reference to a value in the data model ('path', e.g. '/filter/open').", - "properties": { - "literalBoolean": { - "type": "boolean" - }, - "path": { - "type": "string" - } - } - } - }, - "required": ["label", "value"] - }, - "TextField": { - "type": "object", - "properties": { - "label": { - "type": "object", - "description": "The text label for the input field. This can be a literal string or a reference to a value in the data model ('path, e.g. '/user/name').", - "properties": { - "literalString": { - "type": "string" - }, - "path": { - "type": "string" - } - } - }, - "text": { - "type": "object", - "description": "The value of the text field. This can be a literal string or a reference to a value in the data model ('path', e.g. '/user/name').", - "properties": { - "literalString": { - "type": "string" - }, - "path": { - "type": "string" - } - } - }, - "textFieldType": { - "type": "string", - "description": "The type of input field to display.", - "enum": [ - "date", - "longText", - "number", - "shortText", - "obscured" - ] - }, - "validationRegexp": { - "type": "string", - "description": "A regular expression used for client-side validation of the input." - } - }, - "required": ["label"] - }, - "DateTimeInput": { - "type": "object", - "properties": { - "value": { - "type": "object", - "description": "The selected date and/or time value in ISO 8601 format. This can be a literal string ('literalString') or a reference to a value in the data model ('path', e.g. '/user/dob').", - "properties": { - "literalString": { - "type": "string" - }, - "path": { - "type": "string" - } - } - }, - "enableDate": { - "type": "boolean", - "description": "If true, allows the user to select a date." - }, - "enableTime": { - "type": "boolean", - "description": "If true, allows the user to select a time." - } - }, - "required": ["value"] - }, - "MultipleChoice": { - "type": "object", - "properties": { - "selections": { - "type": "object", - "description": "The currently selected values for the component. This can be a literal array of strings or a path to an array in the data model('path', e.g. '/hotel/options').", - "properties": { - "literalArray": { - "type": "array", - "items": { - "type": "string" - } - }, - "path": { - "type": "string" - } - } - }, - "options": { - "type": "array", - "description": "An array of available options for the user to choose from.", - "items": { - "type": "object", - "properties": { - "label": { - "type": "object", - "description": "The text to display for this option. This can be a literal string or a reference to a value in the data model (e.g. '/option/label').", - "properties": { - "literalString": { - "type": "string" - }, - "path": { - "type": "string" - } - } - }, - "value": { - "type": "string", - "description": "The value to be associated with this option when selected." - } - }, - "required": ["label", "value"] - } - }, - "maxAllowedSelections": { - "type": "integer", - "description": "The maximum number of options that the user is allowed to select." - } - }, - "required": ["selections", "options"] - }, - "Slider": { - "type": "object", - "properties": { - "value": { - "type": "object", - "description": "The current value of the slider. This can be a literal number ('literalNumber') or a reference to a value in the data model ('path', e.g. '/restaurant/cost').", - "properties": { - "literalNumber": { - "type": "number" - }, - "path": { - "type": "string" - } - } - }, - "minValue": { - "type": "number", - "description": "The minimum value of the slider." - }, - "maxValue": { - "type": "number", - "description": "The maximum value of the slider." - } - }, - "required": ["value"] - }, - "WebFrame": { - "type": "object", - "properties": { - "url": { - "type": "object", - "description": "The URL of the web page to display. This can be a literal string ('literalString') or a reference to a value in the data model ('path', e.g. '/page/url').", - "properties": { - "literalString": { - "type": "string" - }, - "path": { - "type": "string" - } - } - }, - "htmlContent": { - "type": "object", - "description": "The HTML content to display. This can be a literal string ('literalString') or a reference to a value in the data model ('path', e.g. '/page/html').", - "properties": { - "literalString": { - "type": "string" - }, - "path": { - "type": "string" - } - } - }, - "height": { - "type": "number", - "description": "The height of the web frame in pixels. If not provided, the frame will use a default aspect ratio." - }, - "interactionMode": { - "type": "string", - "description": "The interaction mode of the web frame. One of 'readOnly' or 'interactive'. Defaults to 'readOnly'.", - "enum": ["readOnly", "interactive"] - }, - "allowedEvents": { - "type": "array", - "description": "A list of event names that are allowed to be sent from the web frame to the agent.", - "items": { - "type": "string" - } - } - } - } - } - } - }, - "required": ["id", "component"] - } - } - }, - "required": ["surfaceId", "components"] - }, - "dataModelUpdate": { - "type": "object", - "description": "Updates the data model for a surface.", - "properties": { - "surfaceId": { - "type": "string", - "description": "The unique identifier for the UI surface this data model update applies to." - }, - "path": { - "type": "string", - "description": "An optional path to a location within the data model (e.g., '/user/name'). If omitted, or set to '/', the entire data model will be replaced." - }, - "contents": { - "type": "array", - "description": "An array of data entries. Each entry must contain a 'key' and exactly one corresponding typed 'value*' property.", - "items": { - "type": "object", - "description": "A single data entry. Exactly one 'value*' property should be provided alongside the key.", - "properties": { - "key": { - "type": "string", - "description": "The key for this data entry." - }, - "valueString": { - "type": "string" - }, - "valueNumber": { - "type": "number" - }, - "valueBoolean": { - "type": "boolean" - }, - "valueMap": { - "description": "Represents a map as an adjacency list.", - "type": "array", - "items": { - "type": "object", - "description": "One entry in the map. Exactly one 'value*' property should be provided alongside the key.", - "properties": { - "key": { - "type": "string" - }, - "valueString": { - "type": "string" - }, - "valueNumber": { - "type": "number" - }, - "valueBoolean": { - "type": "boolean" - } - }, - "required": ["key"] - } - } - }, - "required": ["key"] - } - } - }, - "required": ["contents", "surfaceId"] - }, - "deleteSurface": { - "type": "object", - "description": "Signals the client to delete the surface identified by 'surfaceId'.", - "properties": { - "surfaceId": { - "type": "string", - "description": "The unique identifier for the UI surface to be deleted." - } - }, - "required": ["surfaceId"] - } - } -} -""" diff --git a/samples/agent/adk/gemini_enterprise/agent_engine/agent.py b/samples/agent/adk/gemini_enterprise/agent_engine/agent.py index aadc01ec7..f2db3b419 100644 --- a/samples/agent/adk/gemini_enterprise/agent_engine/agent.py +++ b/samples/agent/adk/gemini_enterprise/agent_engine/agent.py @@ -1,10 +1,10 @@ -# Copyright 2026 Google LLC +# 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 # -# http://www.apache.org/licenses/LICENSE-2.0 +# 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, @@ -12,12 +12,383 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Agent entry point for ADK web.""" +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 -import gemini_agent +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() -# ADK web looks for 'root_agent' in this file. -root_agent = gemini_agent.GeminiAgent() + +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] = {} + + schema_manager = self._build_schema_manager() + # Gemini Enerprise only supports VERSION_0_8 for now. + self._schema_managers[VERSION_0_8] = schema_manager + agent = self._build_llm_agent(schema_manager) + self._ui_runners[VERSION_0_8] = 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) -> A2uiSchemaManager: + # Gemini Enerprise only supports VERSION_0_8 for now. + return A2uiSchemaManager( + version=VERSION_0_8, + catalogs=[ + BasicCatalog.get_config( + version=VERSION_0_8, + examples_path=os.path.join( + os.path.dirname(__file__), f"examples/{VERSION_0_8}" + ), + ) + ], + 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=False, # Use invalid examples to test retry logic + ) + 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( + "--- Failed response content:" + f" {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 index baa6d9c79..48d348e78 100644 --- a/samples/agent/adk/gemini_enterprise/agent_engine/agent_executor.py +++ b/samples/agent/adk/gemini_enterprise/agent_engine/agent_executor.py @@ -1,10 +1,10 @@ -# Copyright 2026 Google LLC +# 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 # -# http://www.apache.org/licenses/LICENSE-2.0 +# 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, @@ -12,241 +12,148 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Agent executor for ADK agents with A2UI validation.""" - -import json import logging -import a2ui_schema -import gemini_agent -import jsonschema -from a2a import types, utils -from a2a.server import agent_execution, events, tasks -from a2a.utils import errors as a2a_errors -from google.adk import runners -from google.adk.artifacts import in_memory_artifact_service -from google.adk.memory import in_memory_memory_service -from google.adk.sessions import in_memory_session_service -from google.genai import types as genai_types +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 AdkAgentToA2AExecutor(agent_execution.AgentExecutor): - """An agent executor for ADK agents.""" - - _runner: runners.Runner - - def __init__( - self, - ): - # Prepare A2UI schema validator - try: - single_message_schema = json.loads(a2ui_schema.A2UI_SCHEMA) - self.a2ui_schema_object = { - "type": "array", - "items": single_message_schema, - } - logger.info("[DEBUG]A2UI_SCHEMA successfully loaded.") - except Exception as e: # pylint: disable=broad-except - logger.error("[DEBUG] Failed to parse A2UI_SCHEMA: %s", e) - self.a2ui_schema_object = None - - self._agent = gemini_agent.GeminiAgent() - self._runner = runners.Runner( - app_name=self._agent.name, - agent=self._agent, - session_service=in_memory_session_service.InMemorySessionService(), - artifact_service=in_memory_artifact_service.InMemoryArtifactService(), - memory_service=in_memory_memory_service.InMemoryMemoryService(), - ) - self._user_id = "remote_agent" - - async def execute( - self, - context: agent_execution.RequestContext, - event_queue: events.EventQueue, - ) -> None: - query = context.get_user_input() - task = context.current_task - logger.info("[DEBUG] Query: %s", query) - - if not task: - if not context.message: - return - - task = utils.new_task(context.message) - await event_queue.enqueue_event(task) - - updater = tasks.TaskUpdater(event_queue, task.id, task.context_id) - session_id = task.context_id - - session = await self._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 self._runner.session_service.create_session( - app_name=self._agent.name, - user_id=self._user_id, - state={}, - session_id=session_id, - ) - - current_query_text = query - max_retries = 1 - attempt = 0 - - # Working status - await updater.start_work() - - while attempt <= max_retries: - attempt += 1 - content = genai_types.Content( - role="user", parts=[{"text": current_query_text}] - ) - - final_response_content = None - - logger.info("[DEBUG] attempt: %s", attempt) - - try: - async for event in self._runner.run_async( - user_id=self._user_id, session_id=session.id, new_message=content - ): - # For intermediate thought updates/streaming, you might want to - # process them here. But A2UI usually requires the full JSON to be - # valid before rendering. - if event.is_final_response(): - if ( - event.content - and event.content.parts - and event.content.parts[0].text - ): - final_response_content = "\n".join( - [p.text for p in event.content.parts if p.text] - ) - logger.info( - "[DEBUG] Final response content: %s", - final_response_content, - ) - - except Exception as e: # pylint: disable=broad-except - await updater.failed( - message=utils.new_agent_text_message( - f"Task failed with error: {e!s}" - ) - ) - return - - if final_response_content is None: - if attempt <= max_retries: - current_query_text = "I received no response. Please try again." - continue - await updater.failed( - message=utils.new_agent_text_message("No response generated.") - ) - return - - logger.info("[DEBUG]Final response content: %s", final_response_content) - # Validate A2UI - is_valid = False - error_message = "" - json_string_cleaned = "[]" - text_part = final_response_content - - if "---a2ui_JSON---" not in final_response_content: - error_message = "Delimiter '---a2ui_JSON---' not found." - else: - try: - text_part, json_string = final_response_content.split( - "---a2ui_JSON---", 1 - ) - json_string_cleaned = ( - json_string.strip().lstrip("```json").rstrip("```").strip() - ) - - if not json_string_cleaned: - json_string_cleaned = "[]" - - parsed_json = json.loads(json_string_cleaned) - logger.info("[DEBUG] Parsed JSON: %s", parsed_json) - if self.a2ui_schema_object: - jsonschema.validate( - instance=parsed_json, schema=self.a2ui_schema_object - ) - - is_valid = True - except Exception as e: # pylint: disable=broad-except - error_message = f"Validation failed: {e!s}" - - if is_valid: - # Construct the A2A response - parts = [] - if text_part.strip(): - parts.append( - types.Part(root=types.TextPart(text=text_part.strip())) - ) - - logger.info("[DEBUG]UI JSON: %s", json_string_cleaned) - - json_data = json.loads(json_string_cleaned) - if isinstance(json_data, list): - for message in json_data: - ui_data_part = types.Part( - root=types.DataPart( - data=message, - metadata={"mimeType": "application/json+a2ui"}, - ) - ) - parts.append(ui_data_part) - else: - ui_data_part = types.Part( - root=types.DataPart( - data=json_data, - metadata={"mimeType": "application/json+a2ui"}, - ) - ) - parts.append(ui_data_part) - logger.info("[DEBUG] Parts: %s", parts) - - await updater.add_artifact(parts, name="response") - await updater.complete() - return - - # Retry logic - if attempt <= max_retries: - current_query_text = ( - f"Your previous response was invalid. {error_message} You MUST" - " generate a valid response that strictly follows the A2UI JSON" - f" SCHEMA. Please retry the original request: '{query}'" - ) - logger.warning( - "[DEBUG] Retrying due to validation error: %s", error_message - ) - continue - # Fallback to text only error - await updater.add_artifact( - [ - types.Part( - root=types.TextPart( - text=( - "I encountered an error generating the UI:" - f" {error_message}. Here is the raw response:" - f" {final_response_content}" - ) - ) - ) - ], - name="error_response", - ) - await updater.complete() - return - - async def cancel( - self, - context: agent_execution.RequestContext, - event_queue: events.EventQueue, - ) -> None: - raise a2a_errors.ServerError(error=types.UnsupportedOperationError()) +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..e785e26ad --- /dev/null +++ b/samples/agent/adk/gemini_enterprise/agent_engine/deploy.py @@ -0,0 +1,263 @@ +# 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/a2ui_examples.py b/samples/agent/adk/gemini_enterprise/agent_engine/examples/0.8/contact_card.json similarity index 64% rename from samples/agent/adk/gemini_enterprise/agent_engine/a2ui_examples.py rename to samples/agent/adk/gemini_enterprise/agent_engine/examples/0.8/contact_card.json index 631004379..c783dce5e 100644 --- a/samples/agent/adk/gemini_enterprise/agent_engine/a2ui_examples.py +++ b/samples/agent/adk/gemini_enterprise/agent_engine/examples/0.8/contact_card.json @@ -1,69 +1,3 @@ -# 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. - -"""Contains UI examples for A2UI sample.""" - -# a2ui_examples.py - -CONTACT_UI_EXAMPLES = """ ----BEGIN CONTACT_LIST_EXAMPLE--- -[ - { "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", "distribution": "spaceBetween" } } }, - { "id": "template-image", "component": { "Image": { "url": { "path": "imageUrl" }, "fit": "cover", "usageHint": "avatar" } } }, - { "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://lh3.googleusercontent.com/a/default-user=s80-cc" }, - { "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://lh3.googleusercontent.com/a/default-user=s80-cc" }, - { "key": "title", "valueString": "Construction" }, - { "key": "department", "valueString": "Building" } - ] } - ] } - ] - } } -] ----END CONTACT_LIST_EXAMPLE--- - ----BEGIN CONTACT_CARD_EXAMPLE--- [ { "beginRendering": { @@ -119,7 +53,7 @@ "component": { "Image": { "url": { - "path": "imageUrl" + "path": "/imageUrl" }, "usageHint": "avatar", "fit": "cover" @@ -132,7 +66,7 @@ "component": { "Text": { "text": { - "path": "name" + "path": "/name" }, "usageHint": "h2" } @@ -143,7 +77,7 @@ "component": { "Text": { "text": { - "path": "title" + "path": "/title" }, "usageHint": "h4" } @@ -154,7 +88,7 @@ "component": { "Text": { "text": { - "path": "team" + "path": "/team" }, "usageHint": "caption" } @@ -180,7 +114,7 @@ "component": { "Icon": { "name": { - "literalString": "calendar_today" + "literalString": "calendarToday" } } } @@ -191,7 +125,7 @@ "Text": { "usageHint": "h5", "text": { - "path": "calendar" + "path": "/calendar" } } } @@ -242,7 +176,7 @@ "component": { "Icon": { "name": { - "literalString": "location_on" + "literalString": "locationOn" } } } @@ -253,7 +187,7 @@ "Text": { "usageHint": "h5", "text": { - "path": "location" + "path": "/location" } } } @@ -315,7 +249,7 @@ "Text": { "usageHint": "h5", "text": { - "path": "email" + "path": "/email" } } } @@ -383,7 +317,7 @@ "Text": { "usageHint": "h5", "text": { - "path": "mobile" + "path": "/mobile" } } } @@ -573,46 +507,3 @@ } } ] ----END CONTACT_CARD_EXAMPLE--- - ----BEGIN ACTION_CONFIRMATION_EXAMPLE--- -[ - { "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." } - ] - } } -] ----END ACTION_CONFIRMATION_EXAMPLE--- - ----BEGIN FOLLOW_SUCCESS_EXAMPLE--- -[ - { "beginRendering": { "surfaceId": "contact-card", "root": "success_card"} }, - { "surfaceUpdate": { - "surfaceId": "contact-card", - "components": [ - { "id": "success_card", "component": { "Card": { "child": "success_column"} } }, - { "id": "success_icon", "component": { "Icon": { "name": { "literalString": "check_circle"}, "size": 48.0, "color": "#4CAF50"} } } , - { "id": "success_text", "component": { "Text": { "text": { "literalString": "Successfully Followed"}, "usageHint": "h2"} } } , - { "id": "success_column", "component": { "Column": { "children": { "explicitList": ["success_icon", "success_text"]} , "alignment": "center"} } } - ] - } } -] ----END FOLLOW_SUCCESS_EXAMPLE--- -""" 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/gemini_agent.py b/samples/agent/adk/gemini_enterprise/agent_engine/gemini_agent.py deleted file mode 100644 index 90088d577..000000000 --- a/samples/agent/adk/gemini_enterprise/agent_engine/gemini_agent.py +++ /dev/null @@ -1,172 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Gemini agent for A2UI sample.""" - -import os - -import a2ui_examples -import a2ui_schema -from a2a import types -from google.adk import agents - - -# --- DEFINE YOUR TOOLS HERE --- -def get_contact_info(name: str = None) -> str: - """Gets contact information for a person. - - Args: - name: The name of the person to look up. If None, returns a list of - suggested contacts. - - Returns: - JSON string containing contact details. - """ - # Mock data - if name and "alex" in name.lower(): - return """ - { - "name": "Alex Jordan", - "title": "Software Engineer", - "team": "Cloud AI", - "location": "Sunnyvale, CA", - "email": "alexj@example.com", - "mobile": "+1-555-0102", - "calendar": "Available until 4PM", - "imageUrl": "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=100&h=100&fit=crop" - } - """ - if ( - name and "sarah" in name.lower() - ): # Match "sarah" to Sarah Chen as well for robustness - return """ - { - "name": "Sarah Chen", - "title": "Product Manager", - "team": "Cloud UI", - "location": "New York, NY", - "email": "caseys@example.com", - "mobile": "+1-555-0103", - "calendar": "In a meeting", - "imageUrl": "https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=150&h=150&fit=crop" - } - """ - - # Default list if no specific name match or no name provided - return """ - [ - { - "name": "Alex Jordan", - "title": "Software Engineer", - "team": "Cloud AI", - "location": "Sunnyvale, CA", - "email": "alexj@example.com", - "mobile": "+1-555-0102", - "calendar": "Available until 4PM", - "imageUrl": "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=100&h=100&fit=crop" - }, - { - "name": "Sarah Chen", - "title": "Product Manager", - "team": "Cloud UI", - "location": "New York, NY", - "email": "caseys@example.com", - "mobile": "+1-555-0103", - "calendar": "In a meeting", - "imageUrl": "https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=150&h=150&fit=crop" - } - ] - """ - - -def get_ui_prompt(examples: str) -> str: - """Constructs the full prompt with UI instructions, rules, examples, and schema.""" - formatted_examples = examples - - return f""" - You are a helpful contact lookup assistant. Your final output MUST be a a2ui UI JSON response. - - To generate the response, you MUST follow these rules: - 1. Your response MUST be in two parts, separated by the delimiter: `---a2ui_JSON---`. - 2. The first part is your conversational text response (e.g., "Here is the contact you requested..."). - 3. The second part is a single, raw JSON object which is a list of A2UI messages. - 4. The JSON part MUST validate against the A2UI JSON SCHEMA provided below. - 5. Buttons that represent the main action on a card or view (e.g., 'Follow', 'Email', 'Search') SHOULD include the `"primary": true` attribute. - - --- UI TEMPLATE RULES --- - - **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_JSON---[]" - - - **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 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. - - {formatted_examples} - - ---BEGIN A2UI JSON SCHEMA--- - {a2ui_schema.A2UI_SCHEMA} - ---END A2UI JSON SCHEMA--- - """ - - -class GeminiAgent(agents.LlmAgent): - """An agent powered by the Gemini model via Vertex AI.""" - - # --- AGENT IDENTITY --- - name: str = "a2ui_contact_agent" - description: str = "A contact lookup assistant with rich UI." - - def __init__(self, **kwargs): - print("Initializing A2UI GeminiAgent...") - - # In a real deployment, base_url might come from env or config - instructions = get_ui_prompt(a2ui_examples.CONTACT_UI_EXAMPLES) - - # --- REGISTER YOUR TOOLS HERE --- - tools = [get_contact_info] - - super().__init__( - model=os.environ.get("MODEL", "gemini-3-flash-preview"), - instruction=instructions, - tools=tools, - **kwargs, - ) - - def create_agent_card(self, agent_url: str) -> "AgentCard": - return types.AgentCard( - name=self.name, - description=self.description, - url=agent_url, - version="1.0.0", - default_input_modes=["text/plain"], - default_output_modes=["text/plain"], - capabilities=types.AgentCapabilities(streaming=True), - skills=[ - types.AgentSkill( - id="contact_lookup", - name="Contact Lookup", - description="Find contacts and view their details.", - tags=["contact", "directory"], - examples=["Who is Alex Jordan?", "Find software engineers"], - ) - ], - ) diff --git a/samples/agent/adk/gemini_enterprise/agent_engine/main.py b/samples/agent/adk/gemini_enterprise/agent_engine/main.py deleted file mode 100644 index 78f217c28..000000000 --- a/samples/agent/adk/gemini_enterprise/agent_engine/main.py +++ /dev/null @@ -1,248 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Main file for creating and managing A2UI agents on Agent Engine.""" - -import json -import os - -import agent_executor -import httpx -import requests -import vertexai -from a2a.types import AgentSkill -from dotenv import load_dotenv -from google.auth import default -from google.auth.transport.requests import Request -from google.genai import types -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 card s.", - skills=[agent_skill], - ) - print(f"✓ Contact Card agent card created. {cc_agent_card}") - - a2a_agent = A2aAgent( - agent_card=cc_agent_card, - agent_executor_builder=agent_executor.AdkAgentToA2AExecutor, - ) - print("✓ Local Contact Card agent created.") - - config = { - "display_name": "A2UI Contact Card Agent (Demo New Agent Card)", - "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]", - "a2a-sdk >= 0.3.4", - "cloudpickle >= 3.1.2", - "pydantic", - ], - "http_options": { - "api_version": "v1beta1", - }, - "max_instances": 1, - "extra_packages": [ - "agent_executor.py", - "a2ui_examples.py", - "a2ui_schema.py", - "agent.py", - "gemini_agent.py", - ], - "env_vars": { - "NUM_WORKERS": "1", - }, - } - - remote_agent = client.agent_engines.create(agent=a2a_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": False, - "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/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 index 233a2aba2..1c6225353 100644 --- a/samples/agent/adk/gemini_enterprise/agent_engine/pyproject.toml +++ b/samples/agent/adk/gemini_enterprise/agent_engine/pyproject.toml @@ -13,12 +13,13 @@ # limitations under the License. [project] -name = "a2ui-on-agentengine" +name = "a2ui-contactcard-agentengine" version = "0.1.0" -description = "A2UI Contact Card Agent on Agent Engine for Demo purposes" +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.10" +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..3b60218ef --- /dev/null +++ b/samples/agent/adk/gemini_enterprise/agent_engine/tools.py @@ -0,0 +1,69 @@ +# 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/Procfile b/samples/agent/adk/gemini_enterprise/cloud_run/Procfile deleted file mode 100644 index 2948102ee..000000000 --- a/samples/agent/adk/gemini_enterprise/cloud_run/Procfile +++ /dev/null @@ -1 +0,0 @@ -web: gunicorn -w 2 -k uvicorn.workers.UvicornWorker main:app --bind 0.0.0.0:$PORT \ No newline at end of file diff --git a/samples/agent/adk/gemini_enterprise/cloud_run/README.md b/samples/agent/adk/gemini_enterprise/cloud_run/README.md index 817018e1c..930e59f82 100644 --- a/samples/agent/adk/gemini_enterprise/cloud_run/README.md +++ b/samples/agent/adk/gemini_enterprise/cloud_run/README.md @@ -10,129 +10,16 @@ other agents. ## Introduction This project provides a template for creating and deploying a powerful, -Gemini-based agent that can communicate with other agents using the A2A protocol -and can display A2UI components. By the end of this guide, you will have a -publicly accessible agent running on Cloud Run and can display A2UI components -on Gemini Enterprise UI. - -## Prerequisites - -Before you begin, ensure you have the following installed and configured: - -* **Google Cloud SDK:** - [Install the gcloud CLI](https://cloud.google.com/sdk/docs/install). -* **A Google Cloud Project:** You will need a project with billing enabled to - deploy to Cloud Run. -* **Authentication:** Log in to your Google Cloud account and set up - application default credentials: - ```bash - gcloud config set project - gcloud auth login - gcloud auth application-default login - ``` - -## Project Structure - -This directory contains all the necessary files for deploying the agent: - -* `main.py`: The main entry point for the application. It initializes and runs - the FastAPI web server. -* `gemini_agent.py`: Contains the core logic and definition of the Gemini - agent, including its system instructions and tools. -* `agent_executor.py`: Handles the execution of agent tasks by interfacing - with the Google Agent Development Kit (ADK). -* `requirements.txt`: A list of all the Python dependencies required for the - agent to run. -* `Procfile`: Specifies the command to start the web server, used by Google - Cloud Run during deployment. -* `deploy.sh`: A shell script that automates the entire deployment process. - -## The ADK Agent (`gemini_agent.py`) - -The heart of our application is the `GeminiAgent` class in `gemini_agent.py`. -This class inherits from the `LlmAgent` provided by the Google ADK, and it's -where you define your agent's identity, capabilities, and tools. - -### Agent Identity - -The agent's identity is defined by its `name` and `description`. You can -customize these to reflect your agent's purpose: - -```python -class GeminiAgent(LlmAgent): - """An agent powered by the Gemini model via Vertex AI.""" - - # --- AGENT IDENTITY --- - name: str = "gemini_agent" - description: str = "A helpful assistant powered by Gemini." -``` - -### System Instructions - -The `instructions` variable within the `get_ui_prompt` method sets the agent's -system prompt. This is where you can define the agent's personality, its role, -and any constraints on its behavior. - -```python -class GeminiAgent(LlmAgent): - def __init__(self, **kwargs): - # --- SET YOUR SYSTEM INSTRUCTIONS HERE --- - instructions = """ - You are a helpful contact lookup assistant. Your final output MUST be a a2ui UI JSON response. +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 - You can use the get_contact_info tool to find the contact card of a person. - """ -``` - -### Tools - -The ADK allows you to extend your agent's capabilities by giving it tools. In -this example, we have a `get_contact_info` function that the agent can call. You -can add your own tools by defining a Python function and registering it in the -`tools` list. - -```python -# --- DEFINE YOUR TOOLS HERE --- -def get_contact_info(name: str = None) -> str: - """Gets contact information for a person. - - Args: - name: The name of the person to look up. If None, returns a list of - suggested contacts. - - Returns: - JSON string containing contact details. - """ - ...... - -class GeminiAgent(LlmAgent): - def __init__(self, **kwargs): - # --- REGISTER YOUR TOOLS HERE --- - tools = [ - get_weather - ] -``` +There are 2 steps: -## The A2A Executor (`agent_executor.py`) - -The `AdkAgentToA2AExecutor` class in `agent_executor.py` is the bridge between -the A2A framework and your ADK agent. It implements the `AgentExecutor` -interface from the A2A library and is responsible for handling incoming requests -and invoking your agent. - -The `execute` method is the core of this class. It performs the following steps: - -1. **Retrieves the user's query** from the `RequestContext`. -2. **Manages the task lifecycle**, creating a new task if one doesn't exist. -3. **Manages the session**, creating a new session if one doesn't exist. -4. **Invokes the ADK Runner** by calling `self._runner.run_async()`, passing - the user's query. -5. **Streams the response** back to the A2A framework, updating the task with - the final result. - -This executor ensures that your ADK-based agent can seamlessly communicate -within the A2A protocol. +1. **Deployment**: Deploy an A2UI agent to Google Cloud Run from source code. +2. **Registration**: Register the deploy agent in Gemini Enterprise. ## Deployment @@ -153,7 +40,6 @@ For example: ```bash # Deploy with the default gemini-2.5-flash model -chmod +x deploy.sh ./deploy.sh my-gcp-project my-gemini-agent # Deploy with the gemini-2.5-pro model @@ -168,9 +54,10 @@ The script will: 4. **Set environment variables**, including the `MODEL` and the public `AGENT_URL` of the service itself. -Once the script completes, it will print the public URL of your deployed agent. +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 with Gemini Enterprise +## 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 @@ -178,7 +65,8 @@ Engine API. **1. Get your Gemini Enterprise Engine ID:** -You can find your Engine ID in the Google Cloud Console. +You can create or find an existing Engine ID (a.k.a. App ID) in the Google Cloud +Console. **2. Register the agent:** @@ -191,7 +79,7 @@ curl -X POST -H "Authorization: Bearer $(gcloud auth print-access-token)" -H "Co "displayName": "AGENT_DISPLAY_NAME", "description": "AGENT_DESCRIPTION", "a2aAgentDefinition": { - "jsonAgentCard": "{\"protocolVersion\": \"v1.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\"], \"authentication\": {\"type\": \"http\", \"scheme\": \"bearer\", \"tokenFromEnv\": \"MY_AGENT_TOKEN\"}}" + "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\"]}" } }' ``` @@ -200,26 +88,20 @@ curl -X POST -H "Authorization: Bearer $(gcloud auth print-access-token)" -H "Co * `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. +* `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 public URL of your deployed agent. -* `CREDENTIAL_KEY`: The key for your authentication credentials (e.g., -* `MY_AGENT_TOKEN`: The name of an environment variable that Gemini Enterprise - will read to get the bearer token for authentication. **Note on - Credentials:** At execution time, when Gemini Enterprise talks to the agent, - **Note on Authentication:** This example uses bearer token authentication. - Gemini Enterprise will read the environment variable specified in - `tokenFromEnv` (e.g., `MY_AGENT_TOKEN`) to get the token. It will then send - an HTTP `Authorization` header to your agent with the value `Bearer - `. **3. Locate the agent on the Gemini Enterprise - UI:** - -Your agent can be found in the Gemini Enterprise UI. Once you click it, you can -interact with the agent. Send queries like "Find Alex contact card", or "List -all contacts" and you will see A2UI components being rendered. +* `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 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/a2ui_schema.py b/samples/agent/adk/gemini_enterprise/cloud_run/a2ui_schema.py deleted file mode 100644 index 4fd49890c..000000000 --- a/samples/agent/adk/gemini_enterprise/cloud_run/a2ui_schema.py +++ /dev/null @@ -1,834 +0,0 @@ -# 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. - -"""Contains A2UI schema definitions.""" - -# a2ui_schema.py - -A2UI_SCHEMA = r""" -{ - "title": "A2UI Message Schema", - "description": "Describes a JSON payload for an A2UI (Agent to UI) message, which is used to dynamically construct and update user interfaces. A message MUST contain exactly ONE of the action properties: 'beginRendering', 'surfaceUpdate', 'dataModelUpdate', or 'deleteSurface'.", - "type": "object", - "properties": { - "beginRendering": { - "type": "object", - "description": "Signals the client to begin rendering a surface with a root component and specific styles.", - "properties": { - "surfaceId": { - "type": "string", - "description": "The unique identifier for the UI surface to be rendered." - }, - "root": { - "type": "string", - "description": "The ID of the root component to render." - }, - "styles": { - "type": "object", - "description": "Styling information for the UI.", - "properties": { - "font": { - "type": "string", - "description": "The primary font for the UI." - }, - "primaryColor": { - "type": "string", - "description": "The primary UI color as a hexadecimal code (e.g., '#00BFFF').", - "pattern": "^#[0-9a-fA-F]{6}$" - } - } - } - }, - "required": ["root", "surfaceId"] - }, - "surfaceUpdate": { - "type": "object", - "description": "Updates a surface with a new set of components.", - "properties": { - "surfaceId": { - "type": "string", - "description": "The unique identifier for the UI surface to be updated. If you are adding a new surface this *must* be a new, unique identified that has never been used for any existing surfaces shown." - }, - "components": { - "type": "array", - "description": "A list containing all UI components for the surface.", - "minItems": 1, - "items": { - "type": "object", - "description": "Represents a *single* component in a UI widget tree. This component could be one of many supported types.", - "properties": { - "id": { - "type": "string", - "description": "The unique identifier for this component." - }, - "weight": { - "type": "number", - "description": "The relative weight of this component within a Row or Column. This corresponds to the CSS 'flex-grow' property. Note: this may ONLY be set when the component is a direct descendant of a Row or Column." - }, - "component": { - "type": "object", - "description": "A wrapper object that MUST contain exactly one key, which is the name of the component type (e.g., 'Heading'). The value is an object containing the properties for that specific component.", - "properties": { - "Text": { - "type": "object", - "properties": { - "text": { - "type": "object", - "description": "The text content to display. This can be a literal string or a reference to a value in the data model ('path', e.g., '/doc/title'). While simple Markdown formatting is supported (i.e. without HTML, images, or links), utilizing dedicated UI components is generally preferred for a richer and more structured presentation.", - "properties": { - "literalString": { - "type": "string" - }, - "path": { - "type": "string" - } - } - }, - "usageHint": { - "type": "string", - "description": "A hint for the base text style. One of:\n- `h1`: Largest heading.\n- `h2`: Second largest heading.\n- `h3`: Third largest heading.\n- `h4`: Fourth largest heading.\n- `h5`: Fifth largest heading.\n- `caption`: Small text for captions.\n- `body`: Standard body text.", - "enum": [ - "h1", - "h2", - "h3", - "h4", - "h5", - "caption", - "body" - ] - } - }, - "required": ["text"] - }, - "Image": { - "type": "object", - "properties": { - "url": { - "type": "object", - "description": "The URL of the image to display. This can be a literal string ('literal') or a reference to a value in the data model ('path', e.g. '/thumbnail/url').", - "properties": { - "literalString": { - "type": "string" - }, - "path": { - "type": "string" - } - } - }, - "fit": { - "type": "string", - "description": "Specifies how the image should be resized to fit its container. This corresponds to the CSS 'object-fit' property.", - "enum": [ - "contain", - "cover", - "fill", - "none", - "scale-down" - ] - }, - "usageHint": { - "type": "string", - "description": "A hint for the image size and style. One of:\n- `icon`: Small square icon.\n- `avatar`: Circular avatar image.\n- `smallFeature`: Small feature image.\n- `mediumFeature`: Medium feature image.\n- `largeFeature`: Large feature image.\n- `header`: Full-width, full bleed, header image.", - "enum": [ - "icon", - "avatar", - "smallFeature", - "mediumFeature", - "largeFeature", - "header" - ] - } - }, - "required": ["url"] - }, - "Icon": { - "type": "object", - "properties": { - "name": { - "type": "object", - "description": "The name of the icon to display. This can be a literal string or a reference to a value in the data model ('path', e.g. '/form/submit').", - "properties": { - "literalString": { - "type": "string", - "enum": [ - "accountCircle", - "add", - "arrowBack", - "arrowForward", - "attachFile", - "calendarToday", - "call", - "camera", - "check", - "close", - "delete", - "download", - "edit", - "event", - "error", - "favorite", - "favoriteOff", - "folder", - "help", - "home", - "info", - "locationOn", - "lock", - "lockOpen", - "mail", - "menu", - "moreVert", - "moreHoriz", - "notificationsOff", - "notifications", - "payment", - "person", - "phone", - "photo", - "print", - "refresh", - "search", - "send", - "settings", - "share", - "shoppingCart", - "star", - "starHalf", - "starOff", - "upload", - "visibility", - "visibilityOff", - "warning" - ] - }, - "path": { - "type": "string" - } - } - } - }, - "required": ["name"] - }, - "Video": { - "type": "object", - "properties": { - "url": { - "type": "object", - "description": "The URL of the video to display. This can be a literal string or a reference to a value in the data model ('path', e.g. '/video/url').", - "properties": { - "literalString": { - "type": "string" - }, - "path": { - "type": "string" - } - } - } - }, - "required": ["url"] - }, - "AudioPlayer": { - "type": "object", - "properties": { - "url": { - "type": "object", - "description": "The URL of the audio to be played. This can be a literal string ('literal') or a reference to a value in the data model ('path', e.g. '/song/url').", - "properties": { - "literalString": { - "type": "string" - }, - "path": { - "type": "string" - } - } - }, - "description": { - "type": "object", - "description": "A description of the audio, such as a title or summary. This can be a literal string or a reference to a value in the data model ('path', e.g. '/song/title').", - "properties": { - "literalString": { - "type": "string" - }, - "path": { - "type": "string" - } - } - } - }, - "required": ["url"] - }, - "Row": { - "type": "object", - "properties": { - "children": { - "type": "object", - "description": "Defines the children. Use 'explicitList' for a fixed set of children, or 'template' to generate children from a data list.", - "properties": { - "explicitList": { - "type": "array", - "items": { - "type": "string" - } - }, - "template": { - "type": "object", - "description": "A template for generating a dynamic list of children from a data model list. `componentId` is the component to use as a template, and `dataBinding` is the path to the map of components in the data model. Values in the map will define the list of children.", - "properties": { - "componentId": { - "type": "string" - }, - "dataBinding": { - "type": "string" - } - }, - "required": ["componentId", "dataBinding"] - } - } - }, - "distribution": { - "type": "string", - "description": "Defines the arrangement of children along the main axis (horizontally). This corresponds to the CSS 'justify-content' property.", - "enum": [ - "center", - "end", - "spaceAround", - "spaceBetween", - "spaceEvenly", - "start" - ] - }, - "alignment": { - "type": "string", - "description": "Defines the alignment of children along the cross axis (vertically). This corresponds to the CSS 'align-items' property.", - "enum": ["start", "center", "end", "stretch"] - } - }, - "required": ["children"] - }, - "Column": { - "type": "object", - "properties": { - "children": { - "type": "object", - "description": "Defines the children. Use 'explicitList' for a fixed set of children, or 'template' to generate children from a data list.", - "properties": { - "explicitList": { - "type": "array", - "items": { - "type": "string" - } - }, - "template": { - "type": "object", - "description": "A template for generating a dynamic list of children from a data model list. `componentId` is the component to use as a template, and `dataBinding` is the path to the map of components in the data model. Values in the map will define the list of children.", - "properties": { - "componentId": { - "type": "string" - }, - "dataBinding": { - "type": "string" - } - }, - "required": ["componentId", "dataBinding"] - } - } - }, - "distribution": { - "type": "string", - "description": "Defines the arrangement of children along the main axis (vertically). This corresponds to the CSS 'justify-content' property.", - "enum": [ - "start", - "center", - "end", - "spaceBetween", - "spaceAround", - "spaceEvenly" - ] - }, - "alignment": { - "type": "string", - "description": "Defines the alignment of children along the cross axis (horizontally). This corresponds to the CSS 'align-items' property.", - "enum": ["center", "end", "start", "stretch"] - } - }, - "required": ["children"] - }, - "List": { - "type": "object", - "properties": { - "children": { - "type": "object", - "description": "Defines the children. Use 'explicitList' for a fixed set of children, or 'template' to generate children from a data list.", - "properties": { - "explicitList": { - "type": "array", - "items": { - "type": "string" - } - }, - "template": { - "type": "object", - "description": "A template for generating a dynamic list of children from a data model list. `componentId` is the component to use as a template, and `dataBinding` is the path to the map of components in the data model. Values in the map will define the list of children.", - "properties": { - "componentId": { - "type": "string" - }, - "dataBinding": { - "type": "string" - } - }, - "required": ["componentId", "dataBinding"] - } - } - }, - "direction": { - "type": "string", - "description": "The direction in which the list items are laid out.", - "enum": ["vertical", "horizontal"] - }, - "alignment": { - "type": "string", - "description": "Defines the alignment of children along the cross axis.", - "enum": ["start", "center", "end", "stretch"] - } - }, - "required": ["children"] - }, - "Card": { - "type": "object", - "properties": { - "child": { - "type": "string", - "description": "The ID of the component to be rendered inside the card." - } - }, - "required": ["child"] - }, - "Tabs": { - "type": "object", - "properties": { - "tabItems": { - "type": "array", - "description": "An array of objects, where each object defines a tab with a title and a child component.", - "items": { - "type": "object", - "properties": { - "title": { - "type": "object", - "description": "The tab title. Defines the value as either a literal value or a path to data model value (e.g. '/options/title').", - "properties": { - "literalString": { - "type": "string" - }, - "path": { - "type": "string" - } - } - }, - "child": { - "type": "string" - } - }, - "required": ["title", "child"] - } - } - }, - "required": ["tabItems"] - }, - "Divider": { - "type": "object", - "properties": { - "axis": { - "type": "string", - "description": "The orientation of the divider.", - "enum": ["horizontal", "vertical"] - } - } - }, - "Modal": { - "type": "object", - "properties": { - "entryPointChild": { - "type": "string", - "description": "The ID of the component that opens the modal when interacted with (e.g., a button)." - }, - "contentChild": { - "type": "string", - "description": "The ID of the component to be displayed inside the modal." - } - }, - "required": ["entryPointChild", "contentChild"] - }, - "Button": { - "type": "object", - "properties": { - "child": { - "type": "string", - "description": "The ID of the component to display in the button, typically a Text component." - }, - "primary": { - "type": "boolean", - "description": "Indicates if this button should be styled as the primary action." - }, - "action": { - "type": "object", - "description": "The client-side action to be dispatched when the button is clicked. It includes the action's name and an optional context payload.", - "properties": { - "name": { - "type": "string" - }, - "context": { - "type": "array", - "items": { - "type": "object", - "properties": { - "key": { - "type": "string" - }, - "value": { - "type": "object", - "description": "Defines the value to be included in the context as either a literal value or a path to a data model value (e.g. '/user/name').", - "properties": { - "path": { - "type": "string" - }, - "literalString": { - "type": "string" - }, - "literalNumber": { - "type": "number" - }, - "literalBoolean": { - "type": "boolean" - } - } - } - }, - "required": ["key", "value"] - } - } - }, - "required": ["name"] - } - }, - "required": ["child", "action"] - }, - "CheckBox": { - "type": "object", - "properties": { - "label": { - "type": "object", - "description": "The text to display next to the checkbox. Defines the value as either a literal value or a path to data model ('path', e.g. '/option/label').", - "properties": { - "literalString": { - "type": "string" - }, - "path": { - "type": "string" - } - } - }, - "value": { - "type": "object", - "description": "The current state of the checkbox (true for checked, false for unchecked). This can be a literal boolean ('literalBoolean') or a reference to a value in the data model ('path', e.g. '/filter/open').", - "properties": { - "literalBoolean": { - "type": "boolean" - }, - "path": { - "type": "string" - } - } - } - }, - "required": ["label", "value"] - }, - "TextField": { - "type": "object", - "properties": { - "label": { - "type": "object", - "description": "The text label for the input field. This can be a literal string or a reference to a value in the data model ('path, e.g. '/user/name').", - "properties": { - "literalString": { - "type": "string" - }, - "path": { - "type": "string" - } - } - }, - "text": { - "type": "object", - "description": "The value of the text field. This can be a literal string or a reference to a value in the data model ('path', e.g. '/user/name').", - "properties": { - "literalString": { - "type": "string" - }, - "path": { - "type": "string" - } - } - }, - "textFieldType": { - "type": "string", - "description": "The type of input field to display.", - "enum": [ - "date", - "longText", - "number", - "shortText", - "obscured" - ] - }, - "validationRegexp": { - "type": "string", - "description": "A regular expression used for client-side validation of the input." - } - }, - "required": ["label"] - }, - "DateTimeInput": { - "type": "object", - "properties": { - "value": { - "type": "object", - "description": "The selected date and/or time value in ISO 8601 format. This can be a literal string ('literalString') or a reference to a value in the data model ('path', e.g. '/user/dob').", - "properties": { - "literalString": { - "type": "string" - }, - "path": { - "type": "string" - } - } - }, - "enableDate": { - "type": "boolean", - "description": "If true, allows the user to select a date." - }, - "enableTime": { - "type": "boolean", - "description": "If true, allows the user to select a time." - } - }, - "required": ["value"] - }, - "MultipleChoice": { - "type": "object", - "properties": { - "selections": { - "type": "object", - "description": "The currently selected values for the component. This can be a literal array of strings or a path to an array in the data model('path', e.g. '/hotel/options').", - "properties": { - "literalArray": { - "type": "array", - "items": { - "type": "string" - } - }, - "path": { - "type": "string" - } - } - }, - "options": { - "type": "array", - "description": "An array of available options for the user to choose from.", - "items": { - "type": "object", - "properties": { - "label": { - "type": "object", - "description": "The text to display for this option. This can be a literal string or a reference to a value in the data model (e.g. '/option/label').", - "properties": { - "literalString": { - "type": "string" - }, - "path": { - "type": "string" - } - } - }, - "value": { - "type": "string", - "description": "The value to be associated with this option when selected." - } - }, - "required": ["label", "value"] - } - }, - "maxAllowedSelections": { - "type": "integer", - "description": "The maximum number of options that the user is allowed to select." - } - }, - "required": ["selections", "options"] - }, - "Slider": { - "type": "object", - "properties": { - "value": { - "type": "object", - "description": "The current value of the slider. This can be a literal number ('literalNumber') or a reference to a value in the data model ('path', e.g. '/restaurant/cost').", - "properties": { - "literalNumber": { - "type": "number" - }, - "path": { - "type": "string" - } - } - }, - "minValue": { - "type": "number", - "description": "The minimum value of the slider." - }, - "maxValue": { - "type": "number", - "description": "The maximum value of the slider." - } - }, - "required": ["value"] - }, - "WebFrame": { - "type": "object", - "properties": { - "url": { - "type": "object", - "description": "The URL of the web page to display. This can be a literal string ('literalString') or a reference to a value in the data model ('path', e.g. '/page/url').", - "properties": { - "literalString": { - "type": "string" - }, - "path": { - "type": "string" - } - } - }, - "htmlContent": { - "type": "object", - "description": "The HTML content to display. This can be a literal string ('literalString') or a reference to a value in the data model ('path', e.g. '/page/html').", - "properties": { - "literalString": { - "type": "string" - }, - "path": { - "type": "string" - } - } - }, - "height": { - "type": "number", - "description": "The height of the web frame in pixels. If not provided, the frame will use a default aspect ratio." - }, - "interactionMode": { - "type": "string", - "description": "The interaction mode of the web frame. One of 'readOnly' or 'interactive'. Defaults to 'readOnly'.", - "enum": ["readOnly", "interactive"] - }, - "allowedEvents": { - "type": "array", - "description": "A list of event names that are allowed to be sent from the web frame to the agent.", - "items": { - "type": "string" - } - } - } - } - } - } - }, - "required": ["id", "component"] - } - } - }, - "required": ["surfaceId", "components"] - }, - "dataModelUpdate": { - "type": "object", - "description": "Updates the data model for a surface.", - "properties": { - "surfaceId": { - "type": "string", - "description": "The unique identifier for the UI surface this data model update applies to." - }, - "path": { - "type": "string", - "description": "An optional path to a location within the data model (e.g., '/user/name'). If omitted, or set to '/', the entire data model will be replaced." - }, - "contents": { - "type": "array", - "description": "An array of data entries. Each entry must contain a 'key' and exactly one corresponding typed 'value*' property.", - "items": { - "type": "object", - "description": "A single data entry. Exactly one 'value*' property should be provided alongside the key.", - "properties": { - "key": { - "type": "string", - "description": "The key for this data entry." - }, - "valueString": { - "type": "string" - }, - "valueNumber": { - "type": "number" - }, - "valueBoolean": { - "type": "boolean" - }, - "valueMap": { - "description": "Represents a map as an adjacency list.", - "type": "array", - "items": { - "type": "object", - "description": "One entry in the map. Exactly one 'value*' property should be provided alongside the key.", - "properties": { - "key": { - "type": "string" - }, - "valueString": { - "type": "string" - }, - "valueNumber": { - "type": "number" - }, - "valueBoolean": { - "type": "boolean" - } - }, - "required": ["key"] - } - } - }, - "required": ["key"] - } - } - }, - "required": ["contents", "surfaceId"] - }, - "deleteSurface": { - "type": "object", - "description": "Signals the client to delete the surface identified by 'surfaceId'.", - "properties": { - "surfaceId": { - "type": "string", - "description": "The unique identifier for the UI surface to be deleted." - } - }, - "required": ["surfaceId"] - } - } -} -""" diff --git a/samples/agent/adk/gemini_enterprise/cloud_run/agent.py b/samples/agent/adk/gemini_enterprise/cloud_run/agent.py index dbb8c38c2..f2db3b419 100644 --- a/samples/agent/adk/gemini_enterprise/cloud_run/agent.py +++ b/samples/agent/adk/gemini_enterprise/cloud_run/agent.py @@ -1,10 +1,10 @@ -# Copyright 2026 Google LLC +# 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 # -# http://www.apache.org/licenses/LICENSE-2.0 +# 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, @@ -12,12 +12,383 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Agent entry point for ADK web.""" +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 a2a_a2ui_sample import gemini_agent +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() -# ADK web looks for 'root_agent' in this file. -root_agent = gemini_agent.GeminiAgent() + +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] = {} + + schema_manager = self._build_schema_manager() + # Gemini Enerprise only supports VERSION_0_8 for now. + self._schema_managers[VERSION_0_8] = schema_manager + agent = self._build_llm_agent(schema_manager) + self._ui_runners[VERSION_0_8] = 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) -> A2uiSchemaManager: + # Gemini Enerprise only supports VERSION_0_8 for now. + return A2uiSchemaManager( + version=VERSION_0_8, + catalogs=[ + BasicCatalog.get_config( + version=VERSION_0_8, + examples_path=os.path.join( + os.path.dirname(__file__), f"examples/{VERSION_0_8}" + ), + ) + ], + 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=False, # Use invalid examples to test retry logic + ) + 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( + "--- Failed response content:" + f" {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 index baa6d9c79..b0e4ccf09 100644 --- a/samples/agent/adk/gemini_enterprise/cloud_run/agent_executor.py +++ b/samples/agent/adk/gemini_enterprise/cloud_run/agent_executor.py @@ -1,10 +1,10 @@ -# Copyright 2026 Google LLC +# 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 # -# http://www.apache.org/licenses/LICENSE-2.0 +# 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, @@ -12,241 +12,148 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Agent executor for ADK agents with A2UI validation.""" - -import json import logging -import a2ui_schema -import gemini_agent -import jsonschema -from a2a import types, utils -from a2a.server import agent_execution, events, tasks -from a2a.utils import errors as a2a_errors -from google.adk import runners -from google.adk.artifacts import in_memory_artifact_service -from google.adk.memory import in_memory_memory_service -from google.adk.sessions import in_memory_session_service -from google.genai import types as genai_types +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 AdkAgentToA2AExecutor(agent_execution.AgentExecutor): - """An agent executor for ADK agents.""" - - _runner: runners.Runner - - def __init__( - self, - ): - # Prepare A2UI schema validator - try: - single_message_schema = json.loads(a2ui_schema.A2UI_SCHEMA) - self.a2ui_schema_object = { - "type": "array", - "items": single_message_schema, - } - logger.info("[DEBUG]A2UI_SCHEMA successfully loaded.") - except Exception as e: # pylint: disable=broad-except - logger.error("[DEBUG] Failed to parse A2UI_SCHEMA: %s", e) - self.a2ui_schema_object = None - - self._agent = gemini_agent.GeminiAgent() - self._runner = runners.Runner( - app_name=self._agent.name, - agent=self._agent, - session_service=in_memory_session_service.InMemorySessionService(), - artifact_service=in_memory_artifact_service.InMemoryArtifactService(), - memory_service=in_memory_memory_service.InMemoryMemoryService(), - ) - self._user_id = "remote_agent" - - async def execute( - self, - context: agent_execution.RequestContext, - event_queue: events.EventQueue, - ) -> None: - query = context.get_user_input() - task = context.current_task - logger.info("[DEBUG] Query: %s", query) - - if not task: - if not context.message: - return - - task = utils.new_task(context.message) - await event_queue.enqueue_event(task) - - updater = tasks.TaskUpdater(event_queue, task.id, task.context_id) - session_id = task.context_id - - session = await self._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 self._runner.session_service.create_session( - app_name=self._agent.name, - user_id=self._user_id, - state={}, - session_id=session_id, - ) - - current_query_text = query - max_retries = 1 - attempt = 0 - - # Working status - await updater.start_work() - - while attempt <= max_retries: - attempt += 1 - content = genai_types.Content( - role="user", parts=[{"text": current_query_text}] - ) - - final_response_content = None - - logger.info("[DEBUG] attempt: %s", attempt) - - try: - async for event in self._runner.run_async( - user_id=self._user_id, session_id=session.id, new_message=content - ): - # For intermediate thought updates/streaming, you might want to - # process them here. But A2UI usually requires the full JSON to be - # valid before rendering. - if event.is_final_response(): - if ( - event.content - and event.content.parts - and event.content.parts[0].text - ): - final_response_content = "\n".join( - [p.text for p in event.content.parts if p.text] - ) - logger.info( - "[DEBUG] Final response content: %s", - final_response_content, - ) - - except Exception as e: # pylint: disable=broad-except - await updater.failed( - message=utils.new_agent_text_message( - f"Task failed with error: {e!s}" - ) - ) - return - - if final_response_content is None: - if attempt <= max_retries: - current_query_text = "I received no response. Please try again." - continue - await updater.failed( - message=utils.new_agent_text_message("No response generated.") - ) - return - - logger.info("[DEBUG]Final response content: %s", final_response_content) - # Validate A2UI - is_valid = False - error_message = "" - json_string_cleaned = "[]" - text_part = final_response_content - - if "---a2ui_JSON---" not in final_response_content: - error_message = "Delimiter '---a2ui_JSON---' not found." - else: - try: - text_part, json_string = final_response_content.split( - "---a2ui_JSON---", 1 - ) - json_string_cleaned = ( - json_string.strip().lstrip("```json").rstrip("```").strip() - ) - - if not json_string_cleaned: - json_string_cleaned = "[]" - - parsed_json = json.loads(json_string_cleaned) - logger.info("[DEBUG] Parsed JSON: %s", parsed_json) - if self.a2ui_schema_object: - jsonschema.validate( - instance=parsed_json, schema=self.a2ui_schema_object - ) - - is_valid = True - except Exception as e: # pylint: disable=broad-except - error_message = f"Validation failed: {e!s}" - - if is_valid: - # Construct the A2A response - parts = [] - if text_part.strip(): - parts.append( - types.Part(root=types.TextPart(text=text_part.strip())) - ) - - logger.info("[DEBUG]UI JSON: %s", json_string_cleaned) - - json_data = json.loads(json_string_cleaned) - if isinstance(json_data, list): - for message in json_data: - ui_data_part = types.Part( - root=types.DataPart( - data=message, - metadata={"mimeType": "application/json+a2ui"}, - ) - ) - parts.append(ui_data_part) - else: - ui_data_part = types.Part( - root=types.DataPart( - data=json_data, - metadata={"mimeType": "application/json+a2ui"}, - ) - ) - parts.append(ui_data_part) - logger.info("[DEBUG] Parts: %s", parts) - - await updater.add_artifact(parts, name="response") - await updater.complete() - return - - # Retry logic - if attempt <= max_retries: - current_query_text = ( - f"Your previous response was invalid. {error_message} You MUST" - " generate a valid response that strictly follows the A2UI JSON" - f" SCHEMA. Please retry the original request: '{query}'" - ) - logger.warning( - "[DEBUG] Retrying due to validation error: %s", error_message - ) - continue - # Fallback to text only error - await updater.add_artifact( - [ - types.Part( - root=types.TextPart( - text=( - "I encountered an error generating the UI:" - f" {error_message}. Here is the raw response:" - f" {final_response_content}" - ) - ) - ) - ], - name="error_response", - ) - await updater.complete() - return - - async def cancel( - self, - context: agent_execution.RequestContext, - event_queue: events.EventQueue, - ) -> None: - raise a2a_errors.ServerError(error=types.UnsupportedOperationError()) +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 index 1cdad165b..e87fd8121 100755 --- a/samples/agent/adk/gemini_enterprise/cloud_run/deploy.sh +++ b/samples/agent/adk/gemini_enterprise/cloud_run/deploy.sh @@ -51,7 +51,7 @@ gcloud run deploy "$SERVICE_NAME" \ --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" + --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." 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/a2ui_examples.py b/samples/agent/adk/gemini_enterprise/cloud_run/examples/0.8/contact_card.json similarity index 64% rename from samples/agent/adk/gemini_enterprise/cloud_run/a2ui_examples.py rename to samples/agent/adk/gemini_enterprise/cloud_run/examples/0.8/contact_card.json index 631004379..c783dce5e 100644 --- a/samples/agent/adk/gemini_enterprise/cloud_run/a2ui_examples.py +++ b/samples/agent/adk/gemini_enterprise/cloud_run/examples/0.8/contact_card.json @@ -1,69 +1,3 @@ -# 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. - -"""Contains UI examples for A2UI sample.""" - -# a2ui_examples.py - -CONTACT_UI_EXAMPLES = """ ----BEGIN CONTACT_LIST_EXAMPLE--- -[ - { "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", "distribution": "spaceBetween" } } }, - { "id": "template-image", "component": { "Image": { "url": { "path": "imageUrl" }, "fit": "cover", "usageHint": "avatar" } } }, - { "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://lh3.googleusercontent.com/a/default-user=s80-cc" }, - { "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://lh3.googleusercontent.com/a/default-user=s80-cc" }, - { "key": "title", "valueString": "Construction" }, - { "key": "department", "valueString": "Building" } - ] } - ] } - ] - } } -] ----END CONTACT_LIST_EXAMPLE--- - ----BEGIN CONTACT_CARD_EXAMPLE--- [ { "beginRendering": { @@ -119,7 +53,7 @@ "component": { "Image": { "url": { - "path": "imageUrl" + "path": "/imageUrl" }, "usageHint": "avatar", "fit": "cover" @@ -132,7 +66,7 @@ "component": { "Text": { "text": { - "path": "name" + "path": "/name" }, "usageHint": "h2" } @@ -143,7 +77,7 @@ "component": { "Text": { "text": { - "path": "title" + "path": "/title" }, "usageHint": "h4" } @@ -154,7 +88,7 @@ "component": { "Text": { "text": { - "path": "team" + "path": "/team" }, "usageHint": "caption" } @@ -180,7 +114,7 @@ "component": { "Icon": { "name": { - "literalString": "calendar_today" + "literalString": "calendarToday" } } } @@ -191,7 +125,7 @@ "Text": { "usageHint": "h5", "text": { - "path": "calendar" + "path": "/calendar" } } } @@ -242,7 +176,7 @@ "component": { "Icon": { "name": { - "literalString": "location_on" + "literalString": "locationOn" } } } @@ -253,7 +187,7 @@ "Text": { "usageHint": "h5", "text": { - "path": "location" + "path": "/location" } } } @@ -315,7 +249,7 @@ "Text": { "usageHint": "h5", "text": { - "path": "email" + "path": "/email" } } } @@ -383,7 +317,7 @@ "Text": { "usageHint": "h5", "text": { - "path": "mobile" + "path": "/mobile" } } } @@ -573,46 +507,3 @@ } } ] ----END CONTACT_CARD_EXAMPLE--- - ----BEGIN ACTION_CONFIRMATION_EXAMPLE--- -[ - { "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." } - ] - } } -] ----END ACTION_CONFIRMATION_EXAMPLE--- - ----BEGIN FOLLOW_SUCCESS_EXAMPLE--- -[ - { "beginRendering": { "surfaceId": "contact-card", "root": "success_card"} }, - { "surfaceUpdate": { - "surfaceId": "contact-card", - "components": [ - { "id": "success_card", "component": { "Card": { "child": "success_column"} } }, - { "id": "success_icon", "component": { "Icon": { "name": { "literalString": "check_circle"}, "size": 48.0, "color": "#4CAF50"} } } , - { "id": "success_text", "component": { "Text": { "text": { "literalString": "Successfully Followed"}, "usageHint": "h2"} } } , - { "id": "success_column", "component": { "Column": { "children": { "explicitList": ["success_icon", "success_text"]} , "alignment": "center"} } } - ] - } } -] ----END FOLLOW_SUCCESS_EXAMPLE--- -""" 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/gemini_agent.py b/samples/agent/adk/gemini_enterprise/cloud_run/gemini_agent.py deleted file mode 100644 index 5f017f4a2..000000000 --- a/samples/agent/adk/gemini_enterprise/cloud_run/gemini_agent.py +++ /dev/null @@ -1,172 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Gemini agent for A2UI sample.""" - -import os - -import a2ui_schema -from a2a import types -from a2ui_examples import CONTACT_UI_EXAMPLES -from google.adk import agents - - -# --- DEFINE YOUR TOOLS HERE --- -def get_contact_info(name: str = None) -> str: - """Gets contact information for a person. - - Args: - name: The name of the person to look up. If None, returns a list of - suggested contacts. - - Returns: - JSON string containing contact details. - """ - # Mock data - if name and "alex" in name.lower(): - return """ - { - "name": "Alex Jordan", - "title": "Software Engineer", - "team": "Cloud AI", - "location": "Sunnyvale, CA", - "email": "alexj@example.com", - "mobile": "+1-555-0102", - "calendar": "Available until 4PM", - "imageUrl": "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=100&h=100&fit=crop" - } - """ - if ( - name and "sarah" in name.lower() - ): # Match "sarah" to Sarah Chen as well for robustness - return """ - { - "name": "Sarah Chen", - "title": "Product Manager", - "team": "Cloud UI", - "location": "New York, NY", - "email": "caseys@example.com", - "mobile": "+1-555-0103", - "calendar": "In a meeting", - "imageUrl": "https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=150&h=150&fit=crop" - } - """ - - # Default list if no specific name match or no name provided - return """ - [ - { - "name": "Alex Jordan", - "title": "Software Engineer", - "team": "Cloud AI", - "location": "Sunnyvale, CA", - "email": "alexj@example.com", - "mobile": "+1-555-0102", - "calendar": "Available until 4PM", - "imageUrl": "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=100&h=100&fit=crop" - }, - { - "name": "Sarah Chen", - "title": "Product Manager", - "team": "Cloud UI", - "location": "New York, NY", - "email": "caseys@example.com", - "mobile": "+1-555-0103", - "calendar": "In a meeting", - "imageUrl": "https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=150&h=150&fit=crop" - } - ] - """ - - -def get_ui_prompt(examples: str) -> str: - """Constructs the full prompt with UI instructions, rules, examples, and schema.""" - formatted_examples = examples - - return f""" - You are a helpful contact lookup assistant. Your final output MUST be a a2ui UI JSON response. - - To generate the response, you MUST follow these rules: - 1. Your response MUST be in two parts, separated by the delimiter: `---a2ui_JSON---`. - 2. The first part is your conversational text response (e.g., "Here is the contact you requested..."). - 3. The second part is a single, raw JSON object which is a list of A2UI messages. - 4. The JSON part MUST validate against the A2UI JSON SCHEMA provided below. - 5. Buttons that represent the main action on a card or view (e.g., 'Follow', 'Email', 'Search') SHOULD include the `"primary": true` attribute. - - --- UI TEMPLATE RULES --- - - **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_JSON---[]" - - - **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 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. - - {formatted_examples} - - ---BEGIN A2UI JSON SCHEMA--- - {a2ui_schema.A2UI_SCHEMA} - ---END A2UI JSON SCHEMA--- - """ - - -class GeminiAgent(agents.LlmAgent): - """An agent powered by the Gemini model via Vertex AI.""" - - # --- AGENT IDENTITY --- - name: str = "a2ui_contact_agent" - description: str = "A contact lookup assistant with rich UI." - - def __init__(self, **kwargs): - print("Initializing A2UI GeminiAgent...") - - # In a real deployment, base_url might come from env or config - instructions = get_ui_prompt(CONTACT_UI_EXAMPLES) - - # --- REGISTER YOUR TOOLS HERE --- - tools = [get_contact_info] - - super().__init__( - model=os.environ.get("MODEL", "gemini-2.5-flash"), - instruction=instructions, - tools=tools, - **kwargs, - ) - - def create_agent_card(self, agent_url: str) -> "AgentCard": - return types.AgentCard( - name=self.name, - description=self.description, - url=agent_url, - version="1.0.0", - default_input_modes=["text/plain"], - default_output_modes=["text/plain"], - capabilities=types.AgentCapabilities(streaming=True), - skills=[ - types.AgentSkill( - id="contact_lookup", - name="Contact Lookup", - description="Find contacts and view their details.", - tags=["contact", "directory"], - examples=["Who is Alex Jordan?", "Find software engineers"], - ) - ], - ) diff --git a/samples/agent/adk/gemini_enterprise/cloud_run/main.py b/samples/agent/adk/gemini_enterprise/cloud_run/main.py index 2e06a91b6..3adbe50b1 100644 --- a/samples/agent/adk/gemini_enterprise/cloud_run/main.py +++ b/samples/agent/adk/gemini_enterprise/cloud_run/main.py @@ -1,10 +1,10 @@ -# Copyright 2026 Google LLC +# 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 # -# http://www.apache.org/licenses/LICENSE-2.0 +# 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, @@ -12,38 +12,50 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Main entry point for the A2A A2UI sample agent.""" - +import logging import os -import dotenv +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 -from a2a.server import tasks -from a2a.server.apps.jsonrpc import starlette_app -from a2a.server.request_handlers import default_request_handler -from agent_executor import AdkAgentToA2AExecutor -from gemini_agent import GeminiAgent -dotenv.load_dotenv() +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 + ) -# The URL of your deployed Cloud Function. -# It's best to set this as an environment variable in your deployment. -AGENT_URL = os.environ.get("AGENT_URL", "http://127.0.0.1:8000") + app = server.build() -# 1. Create the AgentCard, RequestHandler, and App at the global scope. -agent = GeminiAgent() -agent_card = agent.create_agent_card(AGENT_URL) + print(f"Running server on {host}:{port}") + uvicorn.run(app, host=host, port=port) -request_handler = default_request_handler.DefaultRequestHandler( - agent_executor=AdkAgentToA2AExecutor(), - task_store=tasks.InMemoryTaskStore(), -) + except Exception as e: + logger.error(f"An error occurred during server startup: {e}") + exit(1) -# 2. The Functions Framework will automatically look for this 'app' variable. -app = starlette_app.A2AStarletteApplication( - agent_card=agent_card, - http_handler=request_handler, -).build() if __name__ == "__main__": - uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("PORT", 8000))) + 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/requirements.txt b/samples/agent/adk/gemini_enterprise/cloud_run/requirements.txt deleted file mode 100644 index 583cd94e4..000000000 --- a/samples/agent/adk/gemini_enterprise/cloud_run/requirements.txt +++ /dev/null @@ -1,9 +0,0 @@ -a2a-sdk -google-genai -uvicorn -python-dotenv -asyncclick - -gunicorn -google-cloud-aiplatform -google-adk 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..3b60218ef --- /dev/null +++ b/samples/agent/adk/gemini_enterprise/cloud_run/tools.py @@ -0,0 +1,69 @@ +# 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) From 9c406ceb7c29516a5165fdc0f03e266bd46ab427 Mon Sep 17 00:00:00 2001 From: Yuan Tian Date: Mon, 6 Apr 2026 19:06:22 +0000 Subject: [PATCH 6/8] udpate README.md --- samples/agent/adk/gemini_enterprise/README.md | 42 +++++++++++++------ .../gemini_enterprise/agent_engine/README.md | 9 ---- 2 files changed, 30 insertions(+), 21 deletions(-) diff --git a/samples/agent/adk/gemini_enterprise/README.md b/samples/agent/adk/gemini_enterprise/README.md index be08624cf..0e9ae31a3 100644 --- a/samples/agent/adk/gemini_enterprise/README.md +++ b/samples/agent/adk/gemini_enterprise/README.md @@ -1,25 +1,43 @@ # A2UI on Gemini Enterprise -This folder contains examples and scripts to develop and use A2UI agents 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: +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`. +- `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. - - `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`. + - 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 -- `a2ui_schema.py`: Contains the JSON schema for A2UI messages, used for validation during development and at runtime. -- `a2ui_examples.py`: Provides several complete A2UI payload examples, such as Contact Cards and Action Confirmation modals. -- `agent_executor.py`: A base implementation of an A2A (Agent-to-Agent) executor that handles A2UI validation and response formatting. +- `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**. +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/README.md b/samples/agent/adk/gemini_enterprise/agent_engine/README.md index 0cf4d2ef2..02eab7f94 100644 --- a/samples/agent/adk/gemini_enterprise/agent_engine/README.md +++ b/samples/agent/adk/gemini_enterprise/agent_engine/README.md @@ -141,15 +141,6 @@ To run the script using `uv`: - `uv run deploy.py` - It may take 5-10 minutes to finish. -## Customization - -To build your own agent, you will need to: - -* Implement your agent's logic, by modifying or replacing `agent_executor.py` - and the `AdkAgentToA2AExecutor` class. -* Adjust the `agent_name`, `display_name`, and `description` when calling - `_register_agent_on_gemini_enterprise` in `main.py`. - ## Manually Register An Agent If you have an Agent that is already deployed to Agent Engine, you can manually From a877366cb8338db52e19c4897f934eecba2b0c44 Mon Sep 17 00:00:00 2001 From: Yuan Tian Date: Mon, 6 Apr 2026 20:07:41 +0000 Subject: [PATCH 7/8] Update according to PR feedback --- .../gemini_enterprise/agent_engine/agent.py | 19 ++++++++++--------- .../adk/gemini_enterprise/cloud_run/agent.py | 19 ++++++++++--------- 2 files changed, 20 insertions(+), 18 deletions(-) diff --git a/samples/agent/adk/gemini_enterprise/agent_engine/agent.py b/samples/agent/adk/gemini_enterprise/agent_engine/agent.py index f2db3b419..e01de5774 100644 --- a/samples/agent/adk/gemini_enterprise/agent_engine/agent.py +++ b/samples/agent/adk/gemini_enterprise/agent_engine/agent.py @@ -67,11 +67,12 @@ def __init__(self, base_url: str): self._schema_managers: Dict[str, A2uiSchemaManager] = {} self._ui_runners: Dict[str, Runner] = {} - schema_manager = self._build_schema_manager() # Gemini Enerprise only supports VERSION_0_8 for now. - self._schema_managers[VERSION_0_8] = schema_manager - agent = self._build_llm_agent(schema_manager) - self._ui_runners[VERSION_0_8] = self._build_runner(agent) + 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() @@ -79,15 +80,15 @@ def __init__(self, base_url: str): def agent_card(self) -> AgentCard: return self._agent_card - def _build_schema_manager(self) -> A2uiSchemaManager: + def _build_schema_manager(self, version: str) -> A2uiSchemaManager: # Gemini Enerprise only supports VERSION_0_8 for now. return A2uiSchemaManager( - version=VERSION_0_8, + version=version, catalogs=[ BasicCatalog.get_config( - version=VERSION_0_8, + version=version, examples_path=os.path.join( - os.path.dirname(__file__), f"examples/{VERSION_0_8}" + os.path.dirname(__file__), f"examples/{version}" ), ) ], @@ -163,7 +164,7 @@ def _build_llm_agent( ui_description=UI_DESCRIPTION, include_schema=True, include_examples=True, - validate_examples=False, # Use invalid examples to test retry logic + validate_examples=True, ) if schema_manager else get_text_prompt() diff --git a/samples/agent/adk/gemini_enterprise/cloud_run/agent.py b/samples/agent/adk/gemini_enterprise/cloud_run/agent.py index f2db3b419..e01de5774 100644 --- a/samples/agent/adk/gemini_enterprise/cloud_run/agent.py +++ b/samples/agent/adk/gemini_enterprise/cloud_run/agent.py @@ -67,11 +67,12 @@ def __init__(self, base_url: str): self._schema_managers: Dict[str, A2uiSchemaManager] = {} self._ui_runners: Dict[str, Runner] = {} - schema_manager = self._build_schema_manager() # Gemini Enerprise only supports VERSION_0_8 for now. - self._schema_managers[VERSION_0_8] = schema_manager - agent = self._build_llm_agent(schema_manager) - self._ui_runners[VERSION_0_8] = self._build_runner(agent) + 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() @@ -79,15 +80,15 @@ def __init__(self, base_url: str): def agent_card(self) -> AgentCard: return self._agent_card - def _build_schema_manager(self) -> A2uiSchemaManager: + def _build_schema_manager(self, version: str) -> A2uiSchemaManager: # Gemini Enerprise only supports VERSION_0_8 for now. return A2uiSchemaManager( - version=VERSION_0_8, + version=version, catalogs=[ BasicCatalog.get_config( - version=VERSION_0_8, + version=version, examples_path=os.path.join( - os.path.dirname(__file__), f"examples/{VERSION_0_8}" + os.path.dirname(__file__), f"examples/{version}" ), ) ], @@ -163,7 +164,7 @@ def _build_llm_agent( ui_description=UI_DESCRIPTION, include_schema=True, include_examples=True, - validate_examples=False, # Use invalid examples to test retry logic + validate_examples=True, ) if schema_manager else get_text_prompt() From 12d58296da585227c74bf3a8e3fe12459ff24af0 Mon Sep 17 00:00:00 2001 From: Yuan Tian Date: Mon, 6 Apr 2026 21:31:43 +0000 Subject: [PATCH 8/8] Reformat with uv run pyink . --- .../gemini_enterprise/agent_engine/agent.py | 27 +++++-------------- .../agent_engine/agent_executor.py | 11 +++----- .../gemini_enterprise/agent_engine/deploy.py | 16 +++-------- .../gemini_enterprise/agent_engine/tools.py | 8 ++---- .../adk/gemini_enterprise/cloud_run/agent.py | 27 +++++-------------- .../cloud_run/agent_executor.py | 11 +++----- .../adk/gemini_enterprise/cloud_run/tools.py | 8 ++---- 7 files changed, 28 insertions(+), 80 deletions(-) diff --git a/samples/agent/adk/gemini_enterprise/agent_engine/agent.py b/samples/agent/adk/gemini_enterprise/agent_engine/agent.py index e01de5774..27c8d7538 100644 --- a/samples/agent/adk/gemini_enterprise/agent_engine/agent.py +++ b/samples/agent/adk/gemini_enterprise/agent_engine/agent.py @@ -60,9 +60,7 @@ 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._text_runner: Optional[Runner] = self._build_runner(self._build_llm_agent()) self._schema_managers: Dict[str, A2uiSchemaManager] = {} self._ui_runners: Dict[str, Runner] = {} @@ -128,8 +126,7 @@ def _build_agent_card(self) -> AgentCard: return AgentCard( name="Contact Lookup Agent", description=( - "This agent helps find contact info for people in your" - " organization." + "This agent helps find contact info for people in your organization." ), url=self.base_url, version="1.0.0", @@ -217,9 +214,7 @@ async def fetch_response( current_query_text = query # Ensure catalog schema was loaded - if ui_version and ( - not selected_catalog or not selected_catalog.catalog_schema - ): + 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. ---" @@ -255,14 +250,8 @@ async def fetch_response( 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] - ) + 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) @@ -282,8 +271,7 @@ async def fetch_response( 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." + "I'm sorry, I encountered an error and couldn't process your request." ) # Fall through to send this as a text-only error @@ -342,8 +330,7 @@ async def fetch_response( f" (Attempt {attempt}) ---" ) logger.warning( - "--- Failed response content:" - f" {final_response_content[:500]}... ---" + f"--- Failed response content: {final_response_content[:500]}... ---" ) error_message = f"Validation failed: {e}." diff --git a/samples/agent/adk/gemini_enterprise/agent_engine/agent_executor.py b/samples/agent/adk/gemini_enterprise/agent_engine/agent_executor.py index 48d348e78..d6e57ffc4 100644 --- a/samples/agent/adk/gemini_enterprise/agent_engine/agent_executor.py +++ b/samples/agent/adk/gemini_enterprise/agent_engine/agent_executor.py @@ -51,12 +51,8 @@ async def execute( 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 - ) + 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( @@ -65,8 +61,7 @@ async def execute( ) else: logger.info( - "--- AGENT_EXECUTOR: A2UI extension is not active. Using text" - " runner. ---" + "--- AGENT_EXECUTOR: A2UI extension is not active. Using text runner. ---" ) if context.message and context.message.parts: diff --git a/samples/agent/adk/gemini_enterprise/agent_engine/deploy.py b/samples/agent/adk/gemini_enterprise/agent_engine/deploy.py index e785e26ad..5b673cfb3 100644 --- a/samples/agent/adk/gemini_enterprise/agent_engine/deploy.py +++ b/samples/agent/adk/gemini_enterprise/agent_engine/deploy.py @@ -34,9 +34,7 @@ def _get_bearer_token(): """Gets a bearer token for authenticating with Google Cloud.""" try: - credentials, _ = default( - scopes=["https://www.googleapis.com/auth/cloud-platform"] - ) + credentials, _ = default(scopes=["https://www.googleapis.com/auth/cloud-platform"]) request = Request() credentials.refresh(request) return credentials.token @@ -86,9 +84,7 @@ def _register_agent_on_gemini_enterprise( } if agent_authorization: - payload["authorization_config"] = { - "agent_authorization": agent_authorization - } + payload["authorization_config"] = {"agent_authorization": agent_authorization} # Get access token bearer_token = _get_bearer_token() @@ -210,9 +206,7 @@ def main(): 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" - ) + a2a_endpoint = f"https://{api_endpoint}/v1beta1/{remote_engine_resource}/a2a/v1/card" bearer_token = _get_bearer_token() headers = { "Authorization": f"Bearer {bearer_token}", @@ -248,9 +242,7 @@ def main(): 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." - ), + description="A helpful assistant agent that uses A2UI to render contact cards.", agent_authorization=os.environ.get("AGENT_AUTHORIZATION"), ) diff --git a/samples/agent/adk/gemini_enterprise/agent_engine/tools.py b/samples/agent/adk/gemini_enterprise/agent_engine/tools.py index 3b60218ef..2ba665c38 100644 --- a/samples/agent/adk/gemini_enterprise/agent_engine/tools.py +++ b/samples/agent/adk/gemini_enterprise/agent_engine/tools.py @@ -46,17 +46,13 @@ def get_contact_info(name: str = None, department: str = "") -> str: # Filter by name results = [ - contact - for contact in all_contacts - if name_lower in contact["name"].lower() + 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() + contact for contact in results if dept_lower in contact["department"].lower() ] logger.info(f" - Success: Found {len(results)} matching contacts.") diff --git a/samples/agent/adk/gemini_enterprise/cloud_run/agent.py b/samples/agent/adk/gemini_enterprise/cloud_run/agent.py index e01de5774..27c8d7538 100644 --- a/samples/agent/adk/gemini_enterprise/cloud_run/agent.py +++ b/samples/agent/adk/gemini_enterprise/cloud_run/agent.py @@ -60,9 +60,7 @@ 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._text_runner: Optional[Runner] = self._build_runner(self._build_llm_agent()) self._schema_managers: Dict[str, A2uiSchemaManager] = {} self._ui_runners: Dict[str, Runner] = {} @@ -128,8 +126,7 @@ def _build_agent_card(self) -> AgentCard: return AgentCard( name="Contact Lookup Agent", description=( - "This agent helps find contact info for people in your" - " organization." + "This agent helps find contact info for people in your organization." ), url=self.base_url, version="1.0.0", @@ -217,9 +214,7 @@ async def fetch_response( current_query_text = query # Ensure catalog schema was loaded - if ui_version and ( - not selected_catalog or not selected_catalog.catalog_schema - ): + 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. ---" @@ -255,14 +250,8 @@ async def fetch_response( 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] - ) + 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) @@ -282,8 +271,7 @@ async def fetch_response( 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." + "I'm sorry, I encountered an error and couldn't process your request." ) # Fall through to send this as a text-only error @@ -342,8 +330,7 @@ async def fetch_response( f" (Attempt {attempt}) ---" ) logger.warning( - "--- Failed response content:" - f" {final_response_content[:500]}... ---" + f"--- Failed response content: {final_response_content[:500]}... ---" ) error_message = f"Validation failed: {e}." diff --git a/samples/agent/adk/gemini_enterprise/cloud_run/agent_executor.py b/samples/agent/adk/gemini_enterprise/cloud_run/agent_executor.py index b0e4ccf09..a8c01232c 100644 --- a/samples/agent/adk/gemini_enterprise/cloud_run/agent_executor.py +++ b/samples/agent/adk/gemini_enterprise/cloud_run/agent_executor.py @@ -51,12 +51,8 @@ async def execute( 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 - ) + 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( @@ -65,8 +61,7 @@ async def execute( ) else: logger.info( - "--- AGENT_EXECUTOR: A2UI extension is not active. Using text" - " runner. ---" + "--- AGENT_EXECUTOR: A2UI extension is not active. Using text runner. ---" ) if context.message and context.message.parts: diff --git a/samples/agent/adk/gemini_enterprise/cloud_run/tools.py b/samples/agent/adk/gemini_enterprise/cloud_run/tools.py index 3b60218ef..2ba665c38 100644 --- a/samples/agent/adk/gemini_enterprise/cloud_run/tools.py +++ b/samples/agent/adk/gemini_enterprise/cloud_run/tools.py @@ -46,17 +46,13 @@ def get_contact_info(name: str = None, department: str = "") -> str: # Filter by name results = [ - contact - for contact in all_contacts - if name_lower in contact["name"].lower() + 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() + contact for contact in results if dept_lower in contact["department"].lower() ] logger.info(f" - Success: Found {len(results)} matching contacts.")