chore: import upstream snapshot with attribution
Continuous Integration / Pre-commit Linter (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.10) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.11) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.12) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.12) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.14) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.12) (push) Has been cancelled
Copybara PR Handler / close-imported-pr (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.14) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:25:13 +08:00
commit ec2b666284
2231 changed files with 491535 additions and 0 deletions
@@ -0,0 +1,184 @@
# GCP Auth Sample
## Overview
Demonstrates the use of Agent Identity auth manager with an agent that queries
Spotify and Google Maps using auth providers.
Use `adk web` to run API key and 2-legged oauth flows, while use the included
custom agent web client to run 3-legged oauth flows.
## Setup
### 1. Activate environment
```bash
cd adk-python
python3 -m venv .venv
source .venv/bin/activate
```
### 2. Install dependencies
```bash
pip install "google-adk[agent-identity]"
```
### 3. Authenticate your environment
```bash
gcloud auth application-default login
export GOOGLE_CLOUD_PROJECT="YOUR_GOOGLE_CLOUD_PROJECT"
gcloud auth application-default set-quota-project $GOOGLE_CLOUD_PROJECT
```
### 4. Create auth providers
Refer to the [public documentation](https://cloud.google.com/iam/docs/manage-auth-providers) to create the following Agent Identity auth providers.
> **Note:**
> The identity running the agent (via Application Default Credentials) must have
> the necessary [permissions](https://docs.cloud.google.com/iam/docs/roles-permissions/iamconnectors#iamconnectors.user)
> to retrieve credentials from these connectors. Ensure your account has the
> necessary role to access these resources.
```bash
export GOOGLE_CLOUD_LOCATION="YOUR_GOOGLE_CLOUD_LOCATION"
export MAPS_API_AUTH_PROVIDER_ID="YOUR_MAPS_API_AUTH_PROVIDER_ID"
export SPOTIFY_2LO_AUTH_PROVIDER_ID="YOUR_SPOTIFY_2LO_AUTH_PROVIDER_ID"
export SPOTIFY_3LO_AUTH_PROVIDER_ID="YOUR_SPOTIFY_3LO_AUTH_PROVIDER_ID"
gcloud alpha agent-identity connectors create $MAPS_API_AUTH_PROVIDER_ID \
--project=$GOOGLE_CLOUD_PROJECT \
--location=$GOOGLE_CLOUD_LOCATION \
--api-key=YOUR_API_KEY
gcloud alpha agent-identity connectors create $SPOTIFY_2LO_AUTH_PROVIDER_ID \
--project=$GOOGLE_CLOUD_PROJECT \
--location=$GOOGLE_CLOUD_LOCATION \
--two-legged-oauth-client-id=OAUTH_CLIENT_ID \
--two-legged-oauth-client-secret=OAUTH_CLIENT_SECRET \
--two-legged-oauth-token-endpoint=OAUTH_TOKEN_ENDPOINT
gcloud alpha agent-identity connectors create $SPOTIFY_3LO_AUTH_PROVIDER_ID \
--project=$GOOGLE_CLOUD_PROJECT \
--location=$GOOGLE_CLOUD_LOCATION \
--three-legged-oauth-client-id=OAUTH_CLIENT_ID \
--three-legged-oauth-client-secret=OAUTH_CLIENT_SECRET \
--three-legged-oauth-authorization-url=AUTHORIZATION_URL \
--three-legged-oauth-token-url=TOKEN_URL \
--allowed-scopes=ALLOWED_SCOPES
```
## Sample Inputs
- `What is the current weather in New York?`
*Tests the API key auth provider using the Google Maps tool.*
- `Tell me about the song: Waving Flag`
*Tests the 2-legged OAuth (2LO) auth provider using the Spotify search track
tool.*
- `Get my private playlists`
*Tests the 3-legged OAuth (3LO) auth provider using the custom web client and
the Spotify get playlists tool.*
## How To
### 1. Register the GCP Auth Provider
Register the Agent Identity authentication provider with the credential manager
so it can resolve GCP auth provider connector schemes.
```python
CredentialManager.register_auth_provider(GcpAuthProvider())
```
### 2. Configure 2-Legged OAuth (2LO)
Define an `AuthConfig` utilizing `GcpAuthProviderScheme` pointing to the 2LO
connector resource name. Attach it to an `AuthenticatedFunctionTool`.
```python
spotify_auth_config_2lo = AuthConfig(
auth_scheme=GcpAuthProviderScheme(name=SPOTIFY_2LO_AUTH_PROVIDER)
)
spotify_search_track_tool = AuthenticatedFunctionTool(
func=spotify_search_track,
auth_config=spotify_auth_config_2lo,
)
```
See https://docs.cloud.google.com/iam/docs/auth-with-2lo for more details.
### 3. Configure 3-Legged OAuth (3LO)
For interactive user authorization flows, configure `GcpAuthProviderScheme` with
required `scopes` and a `continue_uri` where the OAuth callback will redirect
upon completion.
```python
spotify_auth_config_3lo = AuthConfig(
auth_scheme=GcpAuthProviderScheme(
name=SPOTIFY_3LO_AUTH_PROVIDER,
scopes=["playlist-read-private"],
continue_uri=CONTINUE_URI,
)
)
spotify_get_playlist_tool = AuthenticatedFunctionTool(
func=spotify_get_playlists,
auth_config=spotify_auth_config_3lo,
)
```
See https://docs.cloud.google.com/iam/docs/auth-with-3lo for more details.
### 4. Configure Auth for MCP Toolsets
When utilizing an `McpToolset`, supply the `auth_scheme` directly to enable
automatic authentication (such as API key injection) during MCP server
communication.
```python
maps_tools = McpToolset(
connection_params=StreamableHTTPConnectionParams(url=MAPS_MCP_ENDPOINT),
auth_scheme=GcpAuthProviderScheme(name=MAPS_API_AUTH_PROVIDER),
errlog=None, # Required for agent-freezing (pickling)
)
```
## Testing the Sample
### 1. Test API key and 2LO auth provider using ADK web client
```bash
adk web contributing/samples
```
- On the ADK web UI, select the agent named `gcp_auth` from the dropdown.
- Try the sample queries from the **Sample Inputs** section for API key
(Google Maps) and 2LO (Spotify).
### 2. Test 3LO auth provider using custom web client
- Navigate to the client directory and install dependencies:
```bash
cd contributing/samples/integrations/gcp_auth/client
pip install -r requirements.txt
```
- Start the client application:
```bash
uvicorn main:app --port 8080 --reload
```
- Open `http://localhost:8080`. (**Note:** You must use `localhost` and not
`127.0.0.1`, as the OAuth redirect URL specifically requires it.)
- In the sidebar, configure your GCP Project ID and Location, click "Load Remote
Agents", choose an engine to query, and click "Save & Apply Settings".
- Try the 3LO sample query to fetch private playlists.
@@ -0,0 +1,175 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
import os
from google.adk.agents import Agent
from google.adk.apps import App
from google.adk.auth.auth_credential import AuthCredential
from google.adk.auth.auth_tool import AuthConfig
from google.adk.auth.credential_manager import CredentialManager
from google.adk.integrations.agent_identity import GcpAuthProvider
from google.adk.integrations.agent_identity import GcpAuthProviderScheme
from google.adk.tools.authenticated_function_tool import AuthenticatedFunctionTool
from google.adk.tools.mcp_tool.mcp_session_manager import StreamableHTTPConnectionParams
from google.adk.tools.mcp_tool.mcp_toolset import McpToolset
import httpx
PROJECT_ID = os.environ.get("GOOGLE_CLOUD_PROJECT")
LOCATION = os.environ.get("GOOGLE_CLOUD_LOCATION")
MAPS_API_AUTH_PROVIDER_ID = os.environ.get("MAPS_API_AUTH_PROVIDER_ID")
SPOTIFY_2LO_AUTH_PROVIDER_ID = os.environ.get("SPOTIFY_2LO_AUTH_PROVIDER_ID")
SPOTIFY_3LO_AUTH_PROVIDER_ID = os.environ.get("SPOTIFY_3LO_AUTH_PROVIDER_ID")
MAPS_API_AUTH_PROVIDER = (
f"projects/{PROJECT_ID}/locations/{LOCATION}/connectors/"
f"{MAPS_API_AUTH_PROVIDER_ID}"
)
SPOTIFY_2LO_AUTH_PROVIDER = (
f"projects/{PROJECT_ID}/locations/{LOCATION}/connectors/"
f"{SPOTIFY_2LO_AUTH_PROVIDER_ID}"
)
SPOTIFY_3LO_AUTH_PROVIDER = (
f"projects/{PROJECT_ID}/locations/{LOCATION}/connectors/"
f"{SPOTIFY_3LO_AUTH_PROVIDER_ID}"
)
MAPS_MCP_ENDPOINT = "https://mapstools.googleapis.com/mcp"
CONTINUE_URI = "http://localhost:8080/commit"
MODEL = "gemini-2.5-flash"
async def spotify_search_track(
credential: AuthCredential, query: str
) -> str | list:
"""Searches for a track on Spotify and returns its details."""
headers = {}
if http := credential.http:
if http.scheme and http.credentials and (token := http.credentials.token):
headers["Authorization"] = f"{http.scheme.title()} {token}"
if http.additional_headers:
headers.update(http.additional_headers)
if not headers:
return "Error: No authentication token available."
async with httpx.AsyncClient() as client:
response = await client.get(
"https://api.spotify.com/v1/search",
headers=headers,
params={"q": query, "type": "track", "limit": 1},
)
if response.status_code != 200:
return f"Error from Spotify API: {response.status_code} - {response.text}"
data = response.json()
items = data.get("tracks", {}).get("items", [])
if not items:
return f"No track found for query '{query}'."
return items
async def spotify_get_playlists(credential: AuthCredential) -> str | list:
"""Fetches the current user's private playlists on Spotify."""
headers = {}
if http := credential.http:
if http.scheme and http.credentials and (token := http.credentials.token):
headers["Authorization"] = f"{http.scheme.title()} {token}"
if http.additional_headers:
headers.update(http.additional_headers)
if not headers:
return "Error: No authentication token available."
async with httpx.AsyncClient() as client:
response = await client.get(
"https://api.spotify.com/v1/me/playlists",
headers=headers,
params={"limit": 10},
)
if response.status_code != 200:
return f"Error from Spotify API: {response.status_code} - {response.text}"
data = response.json()
items = data.get("items", [])
if not items:
return "No playlists found for the current user."
# Extract useful information
return [
{
"name": item.get("name"),
"public": item.get("public"),
"total_tracks": item.get("tracks", {}).get("total"),
}
for item in items
if item
]
spotify_auth_config_2lo = AuthConfig(
auth_scheme=GcpAuthProviderScheme(name=SPOTIFY_2LO_AUTH_PROVIDER)
)
spotify_search_track_tool = AuthenticatedFunctionTool(
func=spotify_search_track,
auth_config=spotify_auth_config_2lo,
)
spotify_auth_config_3lo = AuthConfig(
auth_scheme=GcpAuthProviderScheme(
name=SPOTIFY_3LO_AUTH_PROVIDER,
scopes=["playlist-read-private"],
continue_uri=CONTINUE_URI,
)
)
spotify_get_playlist_tool = AuthenticatedFunctionTool(
func=spotify_get_playlists,
auth_config=spotify_auth_config_3lo,
)
maps_tools = McpToolset(
connection_params=StreamableHTTPConnectionParams(url=MAPS_MCP_ENDPOINT),
auth_scheme=GcpAuthProviderScheme(name=MAPS_API_AUTH_PROVIDER),
errlog=None, # Required for agent freezing (pickling)
)
CredentialManager.register_auth_provider(GcpAuthProvider())
root_agent = Agent(
name="gcp_auth_agent",
model=MODEL,
instruction=(
"You are a Spotify and Google Maps assistant. Use your tools to "
"search for track details, fetch the user's private playlists, "
"and look up locations. Keep responses concise, friendly, and "
"emoji-filled!"
),
tools=[
spotify_search_track_tool,
spotify_get_playlist_tool,
maps_tools,
],
)
app = App(
name="gcp_auth",
root_agent=root_agent,
)
@@ -0,0 +1,421 @@
# 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.
"""A FastAPI client for interacting with ADK remote agents and handling GCP authentication."""
import base64
import importlib
import json
import os
import sys
import traceback
from typing import Optional
import uuid
from fastapi import FastAPI
from fastapi import Request
from fastapi import Response
from fastapi.responses import FileResponse
from fastapi.responses import HTMLResponse
from fastapi.responses import StreamingResponse
from fastapi.staticfiles import StaticFiles
from google.adk.auth import AuthConfig
import google.auth
import google.auth.transport.requests
from google.genai import types
import httpx
from pydantic import BaseModel
import uvicorn
import vertexai
TARGET_HOST = (
os.environ.get("IAM_CONNECTOR_CREDENTIALS_TARGET_HOST")
or "iamconnectorcredentials.googleapis.com"
)
app = FastAPI()
# Mount static files
try:
app.mount("/static", StaticFiles(directory="static"), name="static")
print("Successfully mounted /static")
except Exception as e:
print(f"Error mounting /static: {e}")
# Serve the index page for the root path
@app.get("/")
async def get_index():
try:
return FileResponse("static/index.html")
except Exception as e:
print(f"Error serving static/index.html: {e}")
return {"error": str(e)}, 500
# List remote agents in the given project and location.
@app.get("/list_agents")
async def list_remote_agents(project_id: str, location: str):
try:
client = vertexai.Client(
project=project_id,
location=location,
)
agents = client.agent_engines.list()
agent_list = []
for agent in agents:
name_parts = agent.api_resource.name.split("/")
agent_id = name_parts[-1] if len(name_parts) > 0 else ""
agent_list.append({
"id": agent_id,
"name": agent.api_resource.display_name,
"full_name": agent.api_resource.name,
})
return {"agents": agent_list}
except Exception as e:
print(f"Error listing agents: {e}")
return {"error": str(e)}
# Helper function to extract the auth URI and nonce from the auth config
def handle_adk_request_credential(auth_config):
if (
auth_config.exchanged_auth_credential
and auth_config.exchanged_auth_credential.oauth2
):
oauth2 = auth_config.exchanged_auth_credential.oauth2
return oauth2.auth_uri, oauth2.nonce
return None, None
try:
_, default_project = google.auth.default()
except Exception:
default_project = ""
class ChatRequest(BaseModel):
message: str = ""
agent_type: str = "remote"
local_agent: str = ""
project_id: str = os.environ.get(
"GOOGLE_CLOUD_PROJECT", default_project or ""
)
location: str = os.environ.get("GOOGLE_CLOUD_LOCATION", "")
agent_id: str = os.environ.get("AGENT_ID", "")
user_id: str = "default_user_id"
session_id: Optional[str] = None
is_auth_resume: Optional[bool] = False
auth_config: Optional[dict] = None
auth_request_function_call_id: Optional[str] = None
# Endpoint for querying the agent.
@app.post("/chat")
async def chat(request: ChatRequest, response: Response):
session_id = request.session_id or str(uuid.uuid4())
current_agent = None
client = None
client = vertexai.Client(
project=request.project_id,
location=request.location,
)
remote_agent_name = (
f"projects/{request.project_id}/locations/{request.location}"
f"/reasoningEngines/{request.agent_id}"
)
try:
current_agent = client.agent_engines.get(name=remote_agent_name)
except Exception as e:
import traceback
tb_str = traceback.format_exc()
err_str = str(e)
async def error_generator():
err_data = {
"error": f"Failed to load remote agent: {err_str}",
"traceback": tb_str,
}
yield f"data: {json.dumps(err_data)}\n\n"
return StreamingResponse(error_generator(), media_type="text/event-stream")
if not request.session_id and current_agent:
try:
if hasattr(current_agent, "async_create_session"):
print(f"DEBUG: Creating async session for {request.user_id}")
session_obj = await current_agent.async_create_session(
user_id=request.user_id
)
else:
session_obj = current_agent.create_session(user_id=request.user_id)
session_id = (
session_obj.id
if hasattr(session_obj, "id")
else session_obj.get("id")
)
client = vertexai.Client(
project=request.project_id,
location=request.location,
)
current_agent = client.agent_engines.get(name=remote_agent_name)
except Exception as e:
import traceback
print(f"Failed to create session: {e}")
tb_str = traceback.format_exc()
err_str = str(e)
async def error_generator():
err_data = {
"error": f"Failed to create session: {err_str}",
"traceback": tb_str,
}
yield f"data: {json.dumps(err_data)}\n\n"
return StreamingResponse(
error_generator(), media_type="text/event-stream"
)
response.set_cookie(
key="session_id", value=session_id, httponly=True, samesite="lax"
)
print(f"Set session_id cookie: {session_id}")
def process_agent_event(event):
# 1. Normalize the event object into a standard Python dictionary
# representation.
if hasattr(event, "model_dump"):
if "mode" in event.model_dump.__code__.co_varnames:
event_data = event.model_dump(mode="json")
else:
event_data = event.model_dump()
elif hasattr(event, "dict"):
event_data = event.dict()
elif hasattr(event, "to_dict"):
event_data = event.to_dict()
elif isinstance(event, dict):
event_data = event
else:
try:
event_data = json.loads(json.dumps(event, default=lambda o: o.__dict__))
except Exception:
event_data = {"text": str(event)}
# 2. Extract message content and check for long-running tool calls.
print(f"DEBUG: event_data: {event_data}")
content = event_data.get("content", {})
parts = content.get("parts", []) if isinstance(content, dict) else []
long_running = event_data.get("long_running_tool_ids") or event_data.get(
"longRunningToolIds", []
)
# 3. Scan tool calls for the special 'adk_request_credential' wrapper tool.
for part in parts:
fc = (
(part.get("function_call") or part.get("functionCall"))
if isinstance(part, dict)
else None
)
if fc and fc.get("name") == "adk_request_credential":
fc_id = fc.get("id")
if not long_running or fc_id in long_running:
print("--> Authentication required by agent.")
try:
args = fc.get("args", {})
cfg_data = args.get("authConfig") or args.get("auth_config")
if cfg_data:
# Parse auth configuration and extract OAuth URI/nonce for popup.
if isinstance(cfg_data, dict):
auth_config = AuthConfig.model_validate(cfg_data)
else:
auth_config = cfg_data
auth_uri, consent_nonce = handle_adk_request_credential(
auth_config
)
if auth_uri:
event_data["popup_auth_uri"] = auth_uri
event_data["auth_request_function_call_id"] = fc_id
if hasattr(auth_config, "model_dump"):
event_data["auth_config"] = auth_config.model_dump()
elif hasattr(auth_config, "dict"):
event_data["auth_config"] = auth_config.dict()
else:
event_data["auth_config"] = auth_config
event_data["consent_nonce"] = consent_nonce
except Exception as e:
print(f"Error processing auth wrapper: {e}")
break
return event_data
async def event_generator():
# Keep vertexai Client alive during async streaming to prevent httpx client
# from being closed by GC
_ = client
yield f"data: {json.dumps({'session_id': session_id})}\n\n"
message_to_send = request.message
if (
request.is_auth_resume
and request.auth_request_function_call_id
and request.auth_config
):
auth_content = types.Content(
role="user",
parts=[
types.Part(
function_response=types.FunctionResponse(
id=request.auth_request_function_call_id,
name="adk_request_credential",
response=request.auth_config,
)
)
],
)
message_to_send = auth_content
else:
message_to_send = types.Content(
role="user", parts=[types.Part(text=request.message)]
)
try:
if hasattr(message_to_send, "model_dump"):
dumped_msg = message_to_send.model_dump(exclude_none=True)
else:
dumped_msg = message_to_send.dict(exclude_none=True)
async for event in current_agent.async_stream_query(
user_id=request.user_id,
message=dumped_msg,
session_id=session_id,
):
event_data = process_agent_event(event)
yield f"data: {json.dumps(event_data)}\n\n"
except Exception as e:
import traceback
tb_str = traceback.format_exc()
err_data = {"error": str(e), "traceback": tb_str}
yield f"data: {json.dumps(err_data)}\n\n"
return StreamingResponse(event_generator(), media_type="text/event-stream")
@app.get("/validateUserId")
@app.get("/commit")
async def validate_user_id(request: Request):
# Session data stored in cookies
user_id = request.cookies.get("consent_user_id") or request.cookies.get(
"user_id"
)
consent_nonce = request.cookies.get("consent_nonce")
session_id = request.cookies.get("session_id")
# Query params
user_id_validation_state = request.query_params.get(
"user_id_validation_state"
)
auth_provider_name = request.query_params.get(
"connector_name"
) or request.query_params.get("auth_provider_name")
print(
f"Callback received: user_id_validation_state={user_id_validation_state},"
f" auth_provider_name={auth_provider_name}, user_id={user_id}"
)
# Note: In production, you should probably throw an if the below checks fail.
# For this example, we'll just return an error message to the user and 200 OK.
if not user_id:
return {
"status": "error",
"message": (
"user_id cookie not found. Please ensure cookies are enabled."
),
}
if not consent_nonce:
return {
"status": "error",
"message": (
"consent_nonce cookie not found. Please ensure cookies are enabled."
),
}
if not user_id_validation_state:
return {
"status": "error",
"message": "user_id_validation_state query param not found",
}
if not auth_provider_name:
return {
"status": "error",
"message": "connector_name or auth_provider_name query param not found",
}
try:
url = (
f"https://{TARGET_HOST}/v1alpha/{auth_provider_name}"
"/credentials:finalize"
)
headers = {
"Content-Type": "application/json",
}
payload = {
"userId": user_id,
"userIdValidationState": user_id_validation_state,
"consentNonce": consent_nonce,
}
print(f"Calling FinalizeCredentials via HTTP POST to: {url}")
print(f"Headers: {headers}")
print(f"Payload: {payload}")
async with httpx.AsyncClient() as client:
response = await client.post(url, json=payload, headers=headers)
print(f"HTTP Response Status: {response.status_code}")
print(f"HTTP Response Body: {response.text}")
if response.status_code == 200:
# Return a simple HTML page to indicate OAuth success
html_content = """
<!DOCTYPE html>
<html>
<head>
<title>Authorization Successful</title>
</head>
<body>
<p>Authorization successful! You can close this window.</p>
</body>
</html>
"""
return HTMLResponse(content=html_content)
else:
return {
"status": "error",
"message": f"HTTP Error {response.status_code}: {response.text}",
}
except Exception as e:
print(f"Error calling FinalizeCredentials via HTTP: {e}")
return {
"status": "error",
"message": f"Failed to finalize credentials: {str(e)}",
}
if __name__ == "__main__":
uvicorn.run(app, host="127.0.0.1", port=8080)
@@ -0,0 +1,6 @@
fastapi
google-adk[agent-engine,agent-identity]
google-auth
google-cloud-aiplatform
httpx
uvicorn
@@ -0,0 +1,135 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Agent Assistant</title>
<!-- Include custom styles -->
<link rel="stylesheet" href="/static/style.css" />
<!-- Google Material Fonts -->
<link
href="https://fonts.googleapis.com/css2?family=Outfit&family=Material+Symbols+Outlined"
rel="stylesheet"
/>
<!-- Import Google Material 3 Web Components -->
<!-- See https://m3.material.io/develop/web -->
<script type="module" src="https://esm.run/@material/web/all.js"></script>
</head>
<body>
<div class="app-container">
<!-- Sidebar for Settings (Material 3 styled form) -->
<div id="settings-sidebar" class="sidebar">
<div class="sidebar-header">
<h2>Configuration Settings</h2>
</div>
<div class="settings-form">
<!-- Remote Agent Settings -->
<div id="remote-settings">
<md-outlined-text-field
id="project-id"
label="Project ID"
placeholder="e.g. my-gcp-project"
required
></md-outlined-text-field>
<md-outlined-text-field
id="location"
label="Location"
placeholder="e.g. us-west1"
required
></md-outlined-text-field>
<md-filled-button id="load-agents-btn" class="btn-full">
<span class="material-symbols-outlined" slot="icon">sync</span>
Load Remote Agents
</md-filled-button>
<md-outlined-select id="agent-select" label="Select Engine">
<md-select-option value="">
<div slot="headline">Select an engine...</div>
</md-select-option>
</md-outlined-select>
<!-- Hidden hold for current target ID -->
<input type="hidden" id="agent-id" />
</div>
<!-- User ID for authentication tracking -->
<md-outlined-text-field
id="user-id"
label="User ID"
value="default_user_id"
required
></md-outlined-text-field>
<md-filled-button id="save-settings" class="btn-full">
<span class="material-symbols-outlined" slot="icon">save</span>
Save & Apply Settings
</md-filled-button>
<!-- Active Agent & Session Profile Pane -->
<div id="agent-info-pane" class="agent-info-pane">
<h4>Active Agent Profile</h4>
<div class="info-grid">
<div class="info-row">
<span class="info-label">Mode:</span>
<span class="info-value" id="info-agent-mode">Remote Vertex AI</span>
</div>
<div class="info-row">
<span class="info-label">Project ID:</span>
<span class="info-value" id="info-project-id">-</span>
</div>
<div class="info-row">
<span class="info-label">Location:</span>
<span class="info-value" id="info-location">-</span>
</div>
<div class="info-row">
<span class="info-label">Target ID:</span>
<span class="info-value" id="info-agent-id">-</span>
</div>
<div class="info-row">
<span class="info-label">Session ID:</span>
<span class="info-value" id="info-session-id">No active session</span>
</div>
<div class="info-row">
<span class="info-label">User ID:</span>
<span class="info-value" id="info-user-id">default_user_id</span>
</div>
</div>
</div>
</div>
</div>
<!-- Main Chat Area -->
<div class="chat-container">
<!-- Header with title and clear chat button -->
<header class="chat-header">
<div class="header-left">
<div class="agent-info">
<h1>Agent Playground</h1>
</div>
</div>
<div class="header-right"></div>
</header>
<!-- Message History Container -->
<div id="messages-container" class="messages-container"></div>
<!-- Input Area -->
<footer class="input-container">
<input
type="text"
id="user-input"
placeholder="Type a message to your agent..."
autofocus
autocomplete="off"
/>
<md-filled-icon-button id="send-btn" disabled>
<span class="material-symbols-outlined">send</span>
</md-filled-icon-button>
</footer>
</div>
</div>
<!-- jQuery Library -->
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<!-- Local JavaScript Hook -->
<script src="/static/script.js"></script>
</body>
</html>
@@ -0,0 +1,407 @@
// 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.
$(function() {
const $messagesContainer = $('#messages-container');
const $userInput = $('#user-input');
const $sendBtn = $('#send-btn');
let currentSessionId = null;
/**
* Updates the active agent profile information panel in the sidebar
* based on the currently selected remote agent and user ID configurations.
*/
const updateAgentInfoPane = () => {
const projectId = $('#project-id').val();
const location = $('#location').val();
const agentId = $('#agent-id').val() || $('#agent-select').val();
const userId = $('#user-id').val() || 'default_user_id';
$('#info-agent-mode').text('Remote Vertex AI');
$('#info-project-id').text(projectId || '-');
$('#info-location').text(location || '-');
$('#info-agent-id').text(agentId || '-');
$('#info-session-id').text(currentSessionId || 'No active session');
$('#info-user-id').text(userId || 'default_user_id');
};
/**
* Resets the message history container to display the initial greeting
* and instructions for the user.
*/
const resetChatFeed = () => {
$messagesContainer.html(`
<div class="message system-message">
<div class="message-content">
Hi! I am your AI Assistant. Configure your target remote agent in the panel on the left, and type a query below to load the sandbox stream.
</div>
</div>
`);
};
/**
* Asynchronously fetches the list of available remote agents from the backend
* API and populates the remote agent selection dropdown.
*/
function loadRemoteAgents(showAlert = false) {
const projectId = $('#project-id').val().trim();
const location = $('#location').val().trim();
updateAgentInfoPane();
if (!projectId || !location) {
if (showAlert) alert('Please configure both Project ID and Location first.');
return;
}
const $loadAgentsBtn = $('#load-agents-btn');
const $agentSelect = $('#agent-select');
$loadAgentsBtn.prop('disabled', true).html('<span class="material-symbols-outlined icon-spin" slot="icon">sync</span> Loading...');
$.getJSON('/list_agents', { project_id: projectId, location: location })
.done(data => {
if (data.error) {
if (showAlert) alert(`GCP Error: ${data.error}`);
console.error(`Remote agent fetch error: ${data.error}`);
} else if (data.agents) {
$agentSelect.html('<md-select-option value=""><div slot="headline">Select an engine...</div></md-select-option>');
data.agents.forEach(agent => {
$agentSelect.append(
$('<md-select-option>').val(agent.id).append(
$('<div>').attr('slot', 'headline').text(`${agent.name} (${agent.id})`)
)
);
});
const currentAgentId = $('#agent-id').val();
if (currentAgentId) {
$agentSelect.val(currentAgentId);
}
updateAgentInfoPane();
}
})
.fail((jqXHR, textStatus, errorThrown) => {
console.error('Failed to load remote agents:', errorThrown);
if (showAlert) alert('Failed to communicate with GCP reasoning engines.');
})
.always(() => {
$loadAgentsBtn.prop('disabled', false).html('<span class="material-symbols-outlined" slot="icon">sync</span> Load Remote Agents');
});
}
$('#agent-select').on('change', function() {
const selectedId = $(this).val();
$('#agent-id').val(selectedId);
currentSessionId = null;
resetChatFeed();
updateAgentInfoPane();
});
/**
* Initializes default configuration values on fresh page loads
* and updates the active agent profile panel accordingly.
*/
const loadSettings = () => {
const projectId = '';
const location = '';
const agentId = '';
const userId = 'default_user_id';
$('#project-id').val(projectId);
$('#location').val(location);
$('#agent-id').val(agentId);
$('#user-id').val(userId);
updateAgentInfoPane();
};
// Apply configs to active session
$('#save-settings').on('click', () => {
const selectVal = $('#agent-select').val();
if (selectVal) {
$('#agent-id').val(selectVal);
}
currentSessionId = null;
document.cookie =
'session_id=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; samesite=lax';
resetChatFeed();
updateAgentInfoPane();
alert('Settings applied and session reset successfully!');
});
$('#load-agents-btn').on('click', () => loadRemoteAgents(true));
// Initialize
loadSettings();
// Handle send button states on user inputs
$userInput.on('input', function() {
$sendBtn.prop('disabled', $(this).val().trim() === '');
});
$userInput.on('keydown', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
sendMessage();
}
});
$sendBtn.on('click', sendMessage);
/**
* Handles the user message submission workflow, disabling input controls
* during processing, appending the user's message to the chat container, and
* initiating the backend streaming request.
*/
async function sendMessage() {
const text = $userInput.val().trim();
if (!text) return;
$userInput.val('');
$sendBtn.prop('disabled', true);
appendMessage(text, 'user-message');
await executeChatRequest(text, false, null, null, null);
}
/**
* Executes a POST request to the /chat API endpoint and processes the
* Server-Sent Events (SSE) stream. Handles agent messages, tool execution
* progress, and OAuth popup authentication resumes.
*
* @param {?string} text - The user query or prompt to send to the agent.
* @param {?boolean} isAuthResume - Indicates whether the request is resuming
* from an OAuth popup authentication flow.
* @param {?string|null} authRequestId - The function call ID associated with
* the credentials request.
* @param {?object|null} authConfig - The authentication configuration
* parameters returned by the agent tool.
* @param {?HTMLElement|null=} existingAgentMessageDiv - An existing message
* container element to append streaming responses into.
*/
async function executeChatRequest(
text, isAuthResume, authRequestId, authConfig,
existingAgentMessageDiv = null) {
let $agentMessageDiv;
let $contentDiv;
let isFirstEvent = true;
if (existingAgentMessageDiv) {
$agentMessageDiv = $(existingAgentMessageDiv);
$contentDiv = $agentMessageDiv.find('.message-content');
isFirstEvent = false;
} else {
$agentMessageDiv = appendMessage(
'<div class="agent-loader"><span class="material-symbols-outlined icon-spin">sync</span> Thinking...</div>',
'agent-message');
$contentDiv = $agentMessageDiv.find('.message-content');
}
const agentType = 'remote';
const localAgent = '';
const projectId = $('#project-id').val();
const location = $('#location').val();
const agentId = $('#agent-id').val() || $('#agent-select').val();
const userId = $('#user-id').val();
const formatAgentText = (inputVal) => {
if (typeof inputVal !== 'string') return inputVal;
return inputVal.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
.replace(/\n/g, '<br>');
};
try {
const requestBody = {
message: text || '',
agent_type: agentType,
local_agent: localAgent,
project_id: projectId,
location: location,
agent_id: agentId,
user_id: userId,
is_auth_resume: isAuthResume,
auth_request_function_call_id: authRequestId,
auth_config: authConfig
};
if (currentSessionId) {
requestBody.session_id = currentSessionId;
}
const response = await fetch('/chat', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(requestBody),
});
if (!response.ok) {
let errorDetail = '';
try {
const errData = await response.json();
errorDetail = errData.detail ? JSON.stringify(errData.detail) :
JSON.stringify(errData);
} catch (e) {
try {
errorDetail = await response.text();
} catch (t) {
errorDetail = `Status ${response.status}`;
}
}
throw new Error(`HTTP connection error (Status ${response.status}): ${
errorDetail}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
while (true) {
const {value, done} = await reader.read();
if (done) break;
buffer += decoder.decode(value, {stream: true});
while (true) {
const eventEnd = buffer.indexOf('\n\n');
if (eventEnd === -1) break;
const event = buffer.substring(0, eventEnd);
buffer = buffer.substring(eventEnd + 2);
if (event.startsWith('data: ')) {
const dataStr = event.substring(6);
try {
const data = JSON.parse(dataStr);
if (data.session_id) {
currentSessionId = data.session_id;
document.cookie =
`session_id=${currentSessionId}; path=/; samesite=lax`;
updateAgentInfoPane();
continue;
}
if (isFirstEvent) {
$contentDiv.empty();
isFirstEvent = false;
}
if (data.popup_auth_uri) {
if (data.consent_nonce) {
document.cookie = `consent_nonce=${
data.consent_nonce}; path=/; samesite=lax`;
}
const currentUserId = $('#user-id').val();
document.cookie =
`consent_user_id=${currentUserId}; path=/; samesite=lax`;
const popup = window.open(data.popup_auth_uri, '_blank');
if (popup) {
const timer = setInterval(() => {
if (popup.closed) {
clearInterval(timer);
$contentDiv.append(
'<br><em>Authentication complete. Resuming session...</em><br>');
$messagesContainer.scrollTop(
$messagesContainer.prop('scrollHeight'));
executeChatRequest(
'', true, data.auth_request_function_call_id,
data.auth_config, $agentMessageDiv[0]);
}
}, 500);
}
$contentDiv.append(
`<span>Please log in to complete authorization in the popup. <a href="${
data.popup_auth_uri}" target="_blank">Open login window manually.</a></span>`);
}
const errorMsg = data.error || data.error_message ||
data.errorMessage || data.error_code || data.errorCode;
if (errorMsg) {
const $err = $('<div>')
.addClass('error-header')
.text(`Error: ${errorMsg}`);
$contentDiv.append($err);
if (data.traceback) {
const $pre = $('<pre>')
.addClass('error-traceback')
.text(data.traceback);
$contentDiv.append($pre);
}
$agentMessageDiv.addClass('error-message');
} else if (data.content && data.content.parts) {
data.content.parts.forEach(part => {
if (part.text) {
$contentDiv.append(
$('<div>').html(formatAgentText(part.text)));
}
});
} else if (data.text) {
$contentDiv.append($('<div>').html(formatAgentText(data.text)));
} else if (typeof data === 'string') {
$contentDiv.append($('<div>').html(formatAgentText(data)));
}
$messagesContainer.scrollTop(
$messagesContainer.prop('scrollHeight'));
} catch (err) {
console.error('Error parsing JSON event chunk:', dataStr, err);
}
}
}
}
} catch (error) {
console.error('Error during query stream processing:', error);
if (isFirstEvent) {
$contentDiv.empty();
}
$contentDiv.append(
$('<div>')
.addClass('error-header')
.text(`Network / connection error: ${error.message}`));
$agentMessageDiv.addClass('error-message');
$messagesContainer.scrollTop($messagesContainer.prop('scrollHeight'));
}
}
/**
* Helper utility to create and append a new message container element (user,
* agent, or system) to the chat history DOM, automatically scrolling the view
* to the latest message.
*
* @param {string} text - The HTML or plaintext content of the message.
* @param {string} type - The CSS class defining the message type (e.g.,
* 'user-message', 'agent-message').
* @returns {!jQuery} The jQuery wrapper representing the newly created message
* element.
*/
function appendMessage(text, type) {
const $messageDiv = $('<div>').addClass(`message ${type}`);
const $contentDiv = $('<div>')
.addClass('message-content')
.html(text ? text.replace(/\n/g, '<br>') : '');
$messageDiv.append($contentDiv);
$messagesContainer.append($messageDiv);
$messagesContainer.scrollTop($messagesContainer.prop('scrollHeight'));
return $messageDiv;
}
});
@@ -0,0 +1,291 @@
/* ==========================================================================
Root Variables & Theming
Defines the core Google Material 3 color palette, backgrounds, and borders.
========================================================================== */
:root {
--bg-color: #f8f9fa;
--text-color: #1f1f1f;
--text-muted: #5f6368;
--sidebar-bg: #f0f4f9;
--chat-bg: #ffffff;
--message-user-bg: #d3e3fd;
--message-user-text: #041e49;
--message-agent-bg: #f1f3f4;
--message-agent-text: #1f1f1f;
--border-color: #dadce0;
}
/* ==========================================================================
Global Resets & Base Layout
Establishes box-sizing, typography, and the full-bleed application container.
========================================================================== */
* {
box-sizing: border-box;
margin: 0;
padding: 0;
font-family: 'Google Sans', 'Segoe UI', Roboto, sans-serif;
}
body {
background-color: var(--bg-color);
color: var(--text-color);
overflow: hidden;
height: 100vh;
}
.app-container {
display: flex;
width: 100vw;
height: 100vh;
}
/* ==========================================================================
Sidebar Configuration Panel
Styles the pinned left navigation drawer housing agent and user settings.
========================================================================== */
.sidebar {
width: 320px;
background-color: var(--sidebar-bg);
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
flex-shrink: 0;
height: 100%;
}
.sidebar-header {
padding: 18px 24px;
border-bottom: 1px solid var(--border-color);
}
.sidebar-header h2 {
font-size: 1.15rem;
font-weight: 500;
}
.settings-form {
padding: 24px;
flex-grow: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 20px;
}
#remote-settings {
display: flex;
flex-direction: column;
gap: 16px;
}
.btn-full {
width: 100%;
}
/* ==========================================================================
Main Chat Playground Area
Configures the primary chat interface, header, and dynamic message history feed.
========================================================================== */
.chat-container {
flex-grow: 1;
display: flex;
flex-direction: column;
background-color: var(--chat-bg);
height: 100%;
min-width: 0;
}
.chat-header {
padding: 14px 24px;
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
background-color: var(--chat-bg);
}
.agent-info h1 {
font-size: 1.15rem;
font-weight: 500;
}
.messages-container {
flex-grow: 1;
overflow-y: auto;
padding: 24px;
display: flex;
flex-direction: column;
gap: 16px;
}
/* ==========================================================================
Message Bubbles & Formatting
Provides distinct visual styling for user, agent, system, and error bubbles.
========================================================================== */
.message {
max-width: 75%;
padding: 12px 18px;
border-radius: 18px;
line-height: 1.55;
word-wrap: break-word;
font-size: 0.92rem;
}
.system-message {
align-self: center;
background-color: var(--bg-color);
border: 1px solid var(--border-color);
color: var(--text-muted);
text-align: center;
max-width: 85%;
border-radius: 12px;
}
.user-message {
align-self: flex-end;
background-color: var(--message-user-bg);
color: var(--message-user-text);
border-bottom-right-radius: 4px;
}
.agent-message {
align-self: flex-start;
background-color: var(--message-agent-bg);
color: var(--message-agent-text);
border-bottom-left-radius: 4px;
border: 1px solid rgba(0, 0, 0, 0.04);
}
.error-message {
align-self: center;
background-color: #fce8e6;
color: #c5221f;
border: 1px solid rgba(197, 34, 31, 0.25);
border-radius: 12px;
}
.error-header {
font-weight: 600;
}
.error-traceback {
margin-top: 8px;
background-color: #fce8e6;
border: 1px solid rgba(197, 34, 31, 0.2);
color: #c5221f;
padding: 10px;
border-radius: 8px;
font-size: 0.8rem;
overflow-x: auto;
font-family: Consolas, Courier, monospace;
}
/* ==========================================================================
Input Bar & Actions
Designs the bottom prompt bar and send button.
========================================================================== */
.input-container {
padding: 18px 24px;
border-top: 1px solid var(--border-color);
display: flex;
align-items: center;
gap: 16px;
background-color: var(--chat-bg);
}
.input-container input {
flex-grow: 1;
padding: 14px 18px;
border-radius: 24px;
background-color: #f1f3f4;
border: 1px solid #transparent;
color: var(--text-color);
font-size: 0.95rem;
outline: none;
transition: background-color 150ms ease, border-color 150ms ease;
}
.input-container input:focus {
background-color: var(--chat-bg);
border-color: var(--border-color);
box-shadow: 0 1px 2px 0 rgba(60, 64, 67, 0.15);
}
/* Material symbols loading spins */
.agent-loader {
display: flex;
align-items: center;
gap: 8px;
color: var(--text-muted);
}
.icon-spin {
display: inline-block;
animation: spin 2s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* Active Agent Profile configurations */
.agent-info-pane {
margin-top: 20px;
padding: 16px;
border-radius: 12px;
background-color: var(--chat-bg);
border: 1px solid var(--border-color);
}
.agent-info-pane h4 {
font-size: 0.75rem;
font-weight: 500;
color: var(--text-muted);
margin-bottom: 10px;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.info-grid {
display: flex;
flex-direction: column;
gap: 8px;
}
.info-row {
display: flex;
justify-content: space-between;
font-size: 0.8rem;
gap: 8px;
}
.info-label {
color: var(--text-muted);
font-weight: 500;
}
.info-value {
color: var(--text-color);
font-weight: 600;
word-break: break-all;
}
/* Custom scrollbars */
::-webkit-scrollbar {
width: 5px;
height: 5px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background-color: rgba(60, 64, 67, 0.2);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background-color: rgba(60, 64, 67, 0.35);
}