diff --git a/Node/adk-wrapper/.gitignore b/Node/adk-wrapper/.gitignore new file mode 100644 index 0000000000..b17f631075 --- /dev/null +++ b/Node/adk-wrapper/.gitignore @@ -0,0 +1,69 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +firebase-debug.log* +firebase-debug.*.log* + +# Firebase cache +.firebase/ + +# Firebase config + +# Uncomment this if you'd like others to create their own Firebase project. +# For a team working on the same Firebase project(s), it is recommended to leave +# it commented so all members can deploy to the same project(s) in .firebaserc. +# .firebaserc + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env + +# dataconnect generated files +.dataconnect diff --git a/Node/adk-wrapper/README.md b/Node/adk-wrapper/README.md new file mode 100644 index 0000000000..e5a60842db --- /dev/null +++ b/Node/adk-wrapper/README.md @@ -0,0 +1,119 @@ +# Agent Engine (ADK) Wrapper Proxy + +This sample demonstrates how to create a proxy between a client application and Google Cloud's Agent Engine using Firebase Cloud Functions. It allows secure access to agents built with the Agent Development Kit (ADK) from customer-facing applications. + +## Why use a proxy? + +Accessing Agent Engine directly from a client application requires service account credentials or user credentials with broad access, which is not secure for public-facing apps. This wrapper provides: + +1. **Authentication**: Automatically integrates with Firebase Authentication. +2. **Security**: Enforces App Check to prevent abuse. +3. **Encapsulation**: Hides specific Agent Engine IDs and project details from the client. + +## Functions Code + +See the [functions/src/adk-endpoints](functions/src/adk-endpoints) directory for the implementation of each endpoint. + +All functions are implemented as **Callable Functions** (`onCall`). They automatically decode client data and verify user authentication tokens. + +### How to call from your app (Client Example) + +To call these functions from a client app (e.g., Web, iOS, Android), use the Firebase Functions SDK. Here is a generic example using the JS SDK: + +```javascript +import { getFunctions, httpsCallable } from 'firebase/functions'; + +const functions = getFunctions(); + +// Example: Calling async_create_session +const asyncCreateSession = httpsCallable(functions, 'async_create_session'); +try { + const result = await asyncCreateSession(); + console.log('Session created:', result.data); +} catch (error) { + console.error('Error creating session:', error); +} +``` + +### Callable Function Reference + +Here are the available callable functions and how to call them with data. + +#### `async_create_session` +Creates a new session for the authenticated user. +* **Requires Auth**: Yes +* **Input**: None (Uses the authenticated user's UID as `user_id`). +* **Returns**: The created session object. + +#### `async_delete_session` +Deletes a session for the authenticated user. +* **Requires Auth**: Yes +* **Input**: `{ session_id: string }` +* **Returns**: The result from Agent Engine. + +#### `async_get_session` +Retrieves details for a specific session. +* **Requires Auth**: Yes +* **Input**: `{ session_id: string }` +* **Returns**: The session details. + +#### `async_list_sessions` +Lists all sessions for the authenticated user. +* **Requires Auth**: Yes +* **Input**: None (Uses the authenticated user's UID to filter sessions). +* **Returns**: An array of sessions. + +#### `async_add_session_to_memory` +Adds a session to memory (generates memories). +* **Requires Auth**: Yes +* **Input**: `{ session: any }` +* **Returns**: The result from Agent Engine. + +#### `async_search_memory` +Searches memories for the given user. +* **Requires Auth**: Yes +* **Input**: `{ query: string }` +* **Returns**: The search results. + +#### `async_stream_query` +Streams responses asynchronously from the ADK application. +* **Requires Auth**: Yes +* **Input**: `{ message: string, session_id?: string, run_config?: any }` +* **Returns**: An object containing the full response and chunks. + +#### `streaming_agent_run_with_events` +Streams responses asynchronously from the ADK application, typically used by tools like AgentSpace. +* **Requires Auth**: Yes +* **Input**: `{ request_json: any }` +* **Returns**: An object containing the full response and chunks. + +## The `common` Folder & Configuration + +The `functions/src/common` folder contains shared logic and configuration for all endpoints. + +* `adk.ts`: Contains helper functions `callReasoningEngine` and `callReasoningEngineStream` that use the `@google-cloud/aiplatform` SDK to communicate with Agent Engine. +* `config.ts`: Defines the configuration options for the project. + +### Configuration Options in `config.ts` + +To use this wrapper, you need to configure it with your Google Cloud and Agent Engine details. You can do this by setting environment variables or editing the values directly in `config.ts`: + +* **`PROJECT_ID`**: The Google Cloud project ID containing your agent. Defaults to `process.env.GCLOUD_PROJECT`. +* **`LOCATION`**: The region where your Agent Engine agent is deployed (e.g., `us-central1`). Defaults to `process.env.LOCATION`. +* **`REASONING_ENGINE_ID`**: The unique ID of your reasoning engine instance. Defaults to `process.env.REASONING_ENGINE_ID`. +* **`ENFORCE_APP_CHECK`**: Set to `true` to require Firebase App Check tokens for all requests. Hardcoded to `true` in this sample. +* **`REPLAY_PROTECTED`**: Set to `true` to consume App Check tokens for replay protection. Hardcoded to `true` in this sample. + +## Deploy and test + +To set up the sample: + +1. Create a Firebase Project using the [Firebase Console](https://console.firebase.google.com). +2. Enable Cloud Functions and Firebase Authentication. +3. Deploy your ADK agent to Agent Engine and obtain the `REASONING_ENGINE_ID`. +4. Clone this repository. +5. Navigate to this sample directory: `cd Node/adk-wrapper`. +6. Set up your project: `firebase use --add` and follow the instructions. +7. Install dependencies: `cd functions; npm install; cd -`. +8. Set environment variables or edit `functions/src/common/config.ts` with your values. +9. Deploy the functions: `firebase deploy`. diff --git a/Node/adk-wrapper/firebase.json b/Node/adk-wrapper/firebase.json new file mode 100644 index 0000000000..c25db37804 --- /dev/null +++ b/Node/adk-wrapper/firebase.json @@ -0,0 +1,20 @@ +{ + "functions": [ + { + "source": "functions", + "codebase": "default", + "disallowLegacyRuntimeConfig": true, + "ignore": [ + "node_modules", + ".git", + "firebase-debug.log", + "firebase-debug.*.log", + "*.local" + ], + "predeploy": [ + "npm --prefix \"$RESOURCE_DIR\" run lint", + "npm --prefix \"$RESOURCE_DIR\" run build" + ] + } + ] +} diff --git a/Node/adk-wrapper/functions/.eslintrc.js b/Node/adk-wrapper/functions/.eslintrc.js new file mode 100644 index 0000000000..0f8e2a9b7e --- /dev/null +++ b/Node/adk-wrapper/functions/.eslintrc.js @@ -0,0 +1,33 @@ +module.exports = { + root: true, + env: { + es6: true, + node: true, + }, + extends: [ + "eslint:recommended", + "plugin:import/errors", + "plugin:import/warnings", + "plugin:import/typescript", + "google", + "plugin:@typescript-eslint/recommended", + ], + parser: "@typescript-eslint/parser", + parserOptions: { + project: ["tsconfig.json", "tsconfig.dev.json"], + sourceType: "module", + }, + ignorePatterns: [ + "/lib/**/*", // Ignore built files. + "/generated/**/*", // Ignore generated files. + ], + plugins: [ + "@typescript-eslint", + "import", + ], + rules: { + "quotes": ["error", "double"], + "import/no-unresolved": 0, + "indent": ["error", 2], + }, +}; diff --git a/Node/adk-wrapper/functions/.gitignore b/Node/adk-wrapper/functions/.gitignore new file mode 100644 index 0000000000..9be0f014f4 --- /dev/null +++ b/Node/adk-wrapper/functions/.gitignore @@ -0,0 +1,10 @@ +# Compiled JavaScript files +lib/**/*.js +lib/**/*.js.map + +# TypeScript v1 declaration files +typings/ + +# Node.js dependency directory +node_modules/ +*.local \ No newline at end of file diff --git a/Node/adk-wrapper/functions/package.json b/Node/adk-wrapper/functions/package.json new file mode 100644 index 0000000000..73249ad619 --- /dev/null +++ b/Node/adk-wrapper/functions/package.json @@ -0,0 +1,32 @@ +{ + "name": "functions", + "scripts": { + "lint": "eslint --ext .js,.ts .", + "build": "tsc", + "build:watch": "tsc --watch", + "serve": "npm run build && firebase emulators:start --only functions", + "shell": "npm run build && firebase functions:shell", + "start": "npm run shell", + "deploy": "firebase deploy --only functions", + "logs": "firebase functions:log" + }, + "engines": { + "node": "24" + }, + "main": "lib/index.js", + "dependencies": { + "@google-cloud/aiplatform": "^6.5.0", + "firebase-admin": "^13.6.0", + "firebase-functions": "^7.0.0" + }, + "devDependencies": { + "@typescript-eslint/eslint-plugin": "^5.12.0", + "@typescript-eslint/parser": "^5.12.0", + "eslint": "^8.9.0", + "eslint-config-google": "^0.14.0", + "eslint-plugin-import": "^2.25.4", + "firebase-functions-test": "^3.4.1", + "typescript": "^5.7.3" + }, + "private": true +} diff --git a/Node/adk-wrapper/functions/src/adk-endpoints/async_add_session_to_memory.ts b/Node/adk-wrapper/functions/src/adk-endpoints/async_add_session_to_memory.ts new file mode 100644 index 0000000000..c4de1d12d5 --- /dev/null +++ b/Node/adk-wrapper/functions/src/adk-endpoints/async_add_session_to_memory.ts @@ -0,0 +1,19 @@ +import { onCall } from "firebase-functions/v2/https"; +import { callReasoningEngine } from "../common/adk"; +import { ENFORCE_APP_CHECK, REPLAY_PROTECTED } from "../common/config"; + +/** + * Generates memories. + */ +export const async_add_session_to_memory = onCall({ + timeoutSeconds: 3600, + enforceAppCheck: ENFORCE_APP_CHECK, + consumeAppCheckToken: REPLAY_PROTECTED, +}, async (request) => { + const uid = request.auth?.uid; + if (!uid) { + throw new Error("Unauthorized"); + } + const { session } = request.data; + return await callReasoningEngine("async_add_session_to_memory", { user_id: uid, session }); +}); diff --git a/Node/adk-wrapper/functions/src/adk-endpoints/async_create_session.ts b/Node/adk-wrapper/functions/src/adk-endpoints/async_create_session.ts new file mode 100644 index 0000000000..eb2893d044 --- /dev/null +++ b/Node/adk-wrapper/functions/src/adk-endpoints/async_create_session.ts @@ -0,0 +1,18 @@ +import { onCall } from "firebase-functions/v2/https"; +import { callReasoningEngine } from "../common/adk"; +import { ENFORCE_APP_CHECK, REPLAY_PROTECTED } from "../common/config"; + +/** + * Creates a new session. + */ +export const async_create_session = onCall({ + timeoutSeconds: 3600, + enforceAppCheck: ENFORCE_APP_CHECK, + consumeAppCheckToken: REPLAY_PROTECTED, +}, async (request) => { + const uid = request.auth?.uid; + if (!uid) { + throw new Error("Unauthorized"); + } + return await callReasoningEngine("async_create_session", { user_id: uid }); +}); diff --git a/Node/adk-wrapper/functions/src/adk-endpoints/async_delete_session.ts b/Node/adk-wrapper/functions/src/adk-endpoints/async_delete_session.ts new file mode 100644 index 0000000000..6930096b1c --- /dev/null +++ b/Node/adk-wrapper/functions/src/adk-endpoints/async_delete_session.ts @@ -0,0 +1,19 @@ +import { onCall } from "firebase-functions/v2/https"; +import { callReasoningEngine } from "../common/adk"; +import { ENFORCE_APP_CHECK, REPLAY_PROTECTED } from "../common/config"; + +/** + * Deletes a session for the given user. + */ +export const async_delete_session = onCall({ + timeoutSeconds: 3600, + enforceAppCheck: ENFORCE_APP_CHECK, + consumeAppCheckToken: REPLAY_PROTECTED, +}, async (request) => { + const uid = request.auth?.uid; + if (!uid) { + throw new Error("Unauthorized"); + } + const { session_id } = request.data; + return await callReasoningEngine("async_delete_session", { user_id: uid, session_id }); +}); diff --git a/Node/adk-wrapper/functions/src/adk-endpoints/async_get_session.ts b/Node/adk-wrapper/functions/src/adk-endpoints/async_get_session.ts new file mode 100644 index 0000000000..bcbba50603 --- /dev/null +++ b/Node/adk-wrapper/functions/src/adk-endpoints/async_get_session.ts @@ -0,0 +1,19 @@ +import { onCall } from "firebase-functions/v2/https"; +import { callReasoningEngine } from "../common/adk"; +import { ENFORCE_APP_CHECK, REPLAY_PROTECTED } from "../common/config"; + +/** + * Get a session for the given user. + */ +export const async_get_session = onCall({ + timeoutSeconds: 3600, + enforceAppCheck: ENFORCE_APP_CHECK, + consumeAppCheckToken: REPLAY_PROTECTED, +}, async (request) => { + const uid = request.auth?.uid; + if (!uid) { + throw new Error("Unauthorized"); + } + const { session_id } = request.data; + return await callReasoningEngine("async_get_session", { user_id: uid, session_id }); +}); diff --git a/Node/adk-wrapper/functions/src/adk-endpoints/async_list_sessions.ts b/Node/adk-wrapper/functions/src/adk-endpoints/async_list_sessions.ts new file mode 100644 index 0000000000..b1b8d83ed1 --- /dev/null +++ b/Node/adk-wrapper/functions/src/adk-endpoints/async_list_sessions.ts @@ -0,0 +1,21 @@ +import { onCall } from "firebase-functions/v2/https"; +import { callReasoningEngine } from "../common/adk"; +import { ENFORCE_APP_CHECK, REPLAY_PROTECTED } from "../common/config"; + +/** + * List sessions for the given user. + */ +export const async_list_sessions = onCall({ + timeoutSeconds: 3600, + enforceAppCheck: ENFORCE_APP_CHECK, + consumeAppCheckToken: REPLAY_PROTECTED, +}, async (request) => { + const uid = request.auth?.uid; + if (!uid) { + throw new Error("Unauthorized"); + } + console.log("Calling async_list_sessions for uid:", uid); + const result = await callReasoningEngine("async_list_sessions", { user_id: uid }) as any; + console.log("Reasoning Engine result:", JSON.stringify(result, null, 2)); + return result?.sessions || []; +}); diff --git a/Node/adk-wrapper/functions/src/adk-endpoints/async_search_memory.ts b/Node/adk-wrapper/functions/src/adk-endpoints/async_search_memory.ts new file mode 100644 index 0000000000..1765b84caa --- /dev/null +++ b/Node/adk-wrapper/functions/src/adk-endpoints/async_search_memory.ts @@ -0,0 +1,19 @@ +import { onCall } from "firebase-functions/v2/https"; +import { callReasoningEngine } from "../common/adk"; +import { ENFORCE_APP_CHECK, REPLAY_PROTECTED } from "../common/config"; + +/** + * Searches memories for the given user. + */ +export const async_search_memory = onCall({ + timeoutSeconds: 3600, + enforceAppCheck: ENFORCE_APP_CHECK, + consumeAppCheckToken: REPLAY_PROTECTED, +}, async (request) => { + const uid = request.auth?.uid; + if (!uid) { + throw new Error("Unauthorized"); + } + const { query } = request.data; + return await callReasoningEngine("async_search_memory", { user_id: uid, query }); +}); diff --git a/Node/adk-wrapper/functions/src/adk-endpoints/async_stream_query.ts b/Node/adk-wrapper/functions/src/adk-endpoints/async_stream_query.ts new file mode 100644 index 0000000000..f660853707 --- /dev/null +++ b/Node/adk-wrapper/functions/src/adk-endpoints/async_stream_query.ts @@ -0,0 +1,19 @@ +import { onCall } from "firebase-functions/v2/https"; +import { callReasoningEngineStream } from "../common/adk"; +import { ENFORCE_APP_CHECK, REPLAY_PROTECTED } from "../common/config"; + +/** + * Streams responses asynchronously from the ADK application. + */ +export const async_stream_query = onCall({ + timeoutSeconds: 3600, + enforceAppCheck: ENFORCE_APP_CHECK, + consumeAppCheckToken: REPLAY_PROTECTED, +}, async (request) => { + const uid = request.auth?.uid; + if (!uid) { + throw new Error("Unauthorized"); + } + const { message, session_id, run_config } = request.data; + return await callReasoningEngineStream("async_stream_query", { user_id: uid, message, session_id, run_config }); +}); diff --git a/Node/adk-wrapper/functions/src/adk-endpoints/streaming_agent_run_with_events.ts b/Node/adk-wrapper/functions/src/adk-endpoints/streaming_agent_run_with_events.ts new file mode 100644 index 0000000000..85e368efd8 --- /dev/null +++ b/Node/adk-wrapper/functions/src/adk-endpoints/streaming_agent_run_with_events.ts @@ -0,0 +1,20 @@ +import { onCall } from "firebase-functions/v2/https"; +import { callReasoningEngineStream } from "../common/adk"; +import { ENFORCE_APP_CHECK, REPLAY_PROTECTED } from "../common/config"; + +/** + * Streams responses asynchronously from the ADK application. + * Meant for invocation from AgentSpace. + */ +export const streaming_agent_run_with_events = onCall({ + timeoutSeconds: 3600, + enforceAppCheck: ENFORCE_APP_CHECK, + consumeAppCheckToken: REPLAY_PROTECTED, +}, async (request) => { + const uid = request.auth?.uid; + if (!uid) { + throw new Error("Unauthorized"); + } + const { request_json } = request.data; + return await callReasoningEngineStream("streaming_agent_run_with_events", { user_id: uid, request_json }); +}); diff --git a/Node/adk-wrapper/functions/src/common/adk.ts b/Node/adk-wrapper/functions/src/common/adk.ts new file mode 100644 index 0000000000..f2af0b3543 --- /dev/null +++ b/Node/adk-wrapper/functions/src/common/adk.ts @@ -0,0 +1,75 @@ +import { ReasoningEngineExecutionServiceClient, helpers } from "@google-cloud/aiplatform"; +import { PROJECT_ID, LOCATION, REASONING_ENGINE_ID } from "./config"; + +const client = new ReasoningEngineExecutionServiceClient({ + apiEndpoint: `${LOCATION}-aiplatform.googleapis.com`, +}); + +export const callReasoningEngine = async (method: string, input: any) => { + const name = `projects/${PROJECT_ID}/locations/${LOCATION}/reasoningEngines/${REASONING_ENGINE_ID}`; + // Remove undefined properties to prevent serialization errors + const cleanInput = Object.fromEntries( + Object.entries(input || {}).filter(([_, v]) => v !== undefined) + ); + + const [response] = await client.queryReasoningEngine({ + name, + classMethod: method, + input: (helpers.toValue(cleanInput) as any)?.structValue, + }); + // Safely check if response.output is defined + try { + if (!response.output || Object.keys(response.output).length === 0) { + return null; + } + return helpers.fromValue(response.output as any); + } catch (e) { + console.warn("Could not parse Reasoning Engine output", JSON.stringify(response.output), e); + return null; + } +}; + +export const callReasoningEngineStream = async (method: string, input: any) => { + const name = `projects/${PROJECT_ID}/locations/${LOCATION}/reasoningEngines/${REASONING_ENGINE_ID}`; + // Remove undefined properties to prevent serialization errors + const cleanInput = Object.fromEntries( + Object.entries(input || {}).filter(([_, v]) => v !== undefined) + ); + + return new Promise((resolve, reject) => { + try { + const stream = client.streamQueryReasoningEngine({ + name, + classMethod: method, + input: (helpers.toValue(cleanInput) as any)?.structValue, + }, { timeout: 300000 }); // Increase timeout to 5 minutes + + let fullText = ""; + const chunks: any[] = []; + + stream.on('data', (response: any) => { + if (response.data) { + try { + const parsed = JSON.parse(response.data.toString('utf8')); + chunks.push(parsed); + if (parsed.content && parsed.content.parts) { + fullText += parsed.content.parts.map((p: any) => p.text || "").join(""); + } + } catch (e) { + // keep going + } + } + }); + + stream.on('end', () => { + resolve({ response: fullText, chunks }); + }); + + stream.on('error', (err: any) => { + reject(err); + }); + } catch (e) { + reject(e); + } + }); +}; diff --git a/Node/adk-wrapper/functions/src/common/config.ts b/Node/adk-wrapper/functions/src/common/config.ts new file mode 100644 index 0000000000..d3821f9225 --- /dev/null +++ b/Node/adk-wrapper/functions/src/common/config.ts @@ -0,0 +1,6 @@ +export const PROJECT_ID = process.env.GCLOUD_PROJECT || ""; // The google cloud project that contains your agent. +export const LOCATION = process.env.LOCATION || ""; // The location that your agent engine agent is deployed to. +export const REASONING_ENGINE_ID = process.env.REASONING_ENGINE_ID || ""; // The reasoning engine id for your agent. + +export const ENFORCE_APP_CHECK = true; +export const REPLAY_PROTECTED = true; \ No newline at end of file diff --git a/Node/adk-wrapper/functions/src/index.ts b/Node/adk-wrapper/functions/src/index.ts new file mode 100644 index 0000000000..12dd205a71 --- /dev/null +++ b/Node/adk-wrapper/functions/src/index.ts @@ -0,0 +1,21 @@ +/** + * Import function triggers from their respective submodules: + * + * import {onCall} from "firebase-functions/v2/https"; + * import {onDocumentWritten} from "firebase-functions/v2/firestore"; + * + * See a full list of supported triggers at https://firebase.google.com/docs/functions + */ + +import { setGlobalOptions } from "firebase-functions"; + +setGlobalOptions({ maxInstances: 10 }); + +export * from "./adk-endpoints/async_get_session"; +export * from "./adk-endpoints/async_list_sessions"; +export * from "./adk-endpoints/async_create_session"; +export * from "./adk-endpoints/async_delete_session"; +export * from "./adk-endpoints/async_add_session_to_memory"; +export * from "./adk-endpoints/async_search_memory"; +export * from "./adk-endpoints/async_stream_query"; +export * from "./adk-endpoints/streaming_agent_run_with_events"; \ No newline at end of file diff --git a/Node/adk-wrapper/functions/tsconfig.dev.json b/Node/adk-wrapper/functions/tsconfig.dev.json new file mode 100644 index 0000000000..7560eed4ca --- /dev/null +++ b/Node/adk-wrapper/functions/tsconfig.dev.json @@ -0,0 +1,5 @@ +{ + "include": [ + ".eslintrc.js" + ] +} diff --git a/Node/adk-wrapper/functions/tsconfig.json b/Node/adk-wrapper/functions/tsconfig.json new file mode 100644 index 0000000000..57b915f3cc --- /dev/null +++ b/Node/adk-wrapper/functions/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "module": "NodeNext", + "esModuleInterop": true, + "moduleResolution": "nodenext", + "noImplicitReturns": true, + "noUnusedLocals": true, + "outDir": "lib", + "sourceMap": true, + "strict": true, + "target": "es2017" + }, + "compileOnSave": true, + "include": [ + "src" + ] +}