From 6872805274773db74afdd88f45ae1169a948425a Mon Sep 17 00:00:00 2001 From: Adam Weidman Date: Sat, 14 Feb 2026 20:04:32 -0700 Subject: [PATCH] feat: split chat bridge into standalone microservice Separate the Google Chat bridge from the A2A agent server so each can scale independently on Cloud Run. The bridge is a lightweight proxy (concurrency=80) while the agent needs concurrency=1. - Add standalone entry point (src/chat-bridge/server.ts) - Add Dockerfile.chat-bridge and cloudbuild-chat-bridge.yaml - Remove chat bridge setup from app.ts - Inline A2UI constants in a2a-bridge-client.ts (no agent deps) - Update README for two-service architecture --- packages/a2a-server/Dockerfile.chat-bridge | 23 ++++ packages/a2a-server/README.md | 118 +++++++++++++----- .../a2a-server/cloudbuild-chat-bridge.yaml | 35 ++++++ .../src/chat-bridge/a2a-bridge-client.ts | 5 +- packages/a2a-server/src/chat-bridge/server.ts | 62 +++++++++ packages/a2a-server/src/http/app.ts | 26 +--- 6 files changed, 212 insertions(+), 57 deletions(-) create mode 100644 packages/a2a-server/Dockerfile.chat-bridge create mode 100644 packages/a2a-server/cloudbuild-chat-bridge.yaml create mode 100644 packages/a2a-server/src/chat-bridge/server.ts diff --git a/packages/a2a-server/Dockerfile.chat-bridge b/packages/a2a-server/Dockerfile.chat-bridge new file mode 100644 index 0000000000..37ce19edba --- /dev/null +++ b/packages/a2a-server/Dockerfile.chat-bridge @@ -0,0 +1,23 @@ +# Standalone Google Chat bridge server. +# Connects to the A2A agent server over HTTP — no agent dependencies needed. +FROM docker.io/library/node:20-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Copy pre-installed node_modules and pre-built dist +COPY package.json package-lock.json ./ +COPY node_modules/ node_modules/ +COPY packages/a2a-server/ packages/a2a-server/ + +USER node + +ENV PORT=8080 +ENV NODE_ENV=production + +EXPOSE 8080 + +CMD ["node", "packages/a2a-server/dist/src/chat-bridge/server.js"] diff --git a/packages/a2a-server/README.md b/packages/a2a-server/README.md index e04b6b3402..f511420629 100644 --- a/packages/a2a-server/README.md +++ b/packages/a2a-server/README.md @@ -14,16 +14,18 @@ Google Chat ──webhook──> Chat Bridge ──A2A──> A2A Server └── Chat REST API (push responses back to Chat) ``` -The server runs as a single Cloud Run container with two logical components: +This package contains two independently deployable services: -1. **A2A Server** - Standard A2A protocol endpoint (JSON-RPC + SSE streaming) - that wraps the Gemini CLI agent -2. **Chat Bridge** - Translates between Google Chat webhook events and A2A - protocol, pushing streamed results back via the Chat REST API +1. **A2A Server** (`src/http/server.ts`) - Standard A2A protocol endpoint + (JSON-RPC + SSE streaming) that wraps the Gemini CLI agent. Heavy workload — + deploy with `concurrency=1`. +2. **Chat Bridge** (`src/chat-bridge/server.ts`) - Lightweight proxy that + translates Google Chat webhooks into A2A protocol calls. Connects to the A2A + server over HTTP. Deploy with high concurrency (`concurrency=80`). The Chat Bridge responds immediately to webhooks with "Processing..." (avoiding Google Chat's 30s timeout), then streams results from the A2A agent and pushes -them to Chat as they arrive. +them to Chat via the REST API. ## Prerequisites @@ -39,19 +41,28 @@ them to Chat as they arrive. ## Environment Variables -| Variable | Required | Description | -| ---------------------------- | -------- | ---------------------------------------------------------------------------------------------------- | -| `GEMINI_API_KEY` | Yes | Gemini API key for the agent | -| `CODER_AGENT_PORT` | No | Server port (default: `8080`) | -| `CODER_AGENT_HOST` | No | Bind host (default: `localhost`, set `0.0.0.0` for containers) | -| `CODER_AGENT_WORKSPACE_PATH` | No | Agent workspace directory (default: `/workspace`) | -| `GCS_BUCKET_NAME` | No | GCS bucket for task & session persistence | -| `GEMINI_YOLO_MODE` | No | Set `true` to auto-approve all tool calls | -| `CHAT_BRIDGE_A2A_URL` | No | A2A server URL for the Chat bridge (e.g. `http://localhost:8080`). Presence enables the Chat bridge. | -| `CHAT_PROJECT_NUMBER` | No | Google Chat project number for JWT verification | -| `CHAT_SA_KEY_PATH` | No | Path to service account key for Chat API auth (uses ADC if not set) | -| `CHAT_BRIDGE_DEBUG` | No | Set `true` for verbose bridge logging | -| `GIT_TERMINAL_PROMPT` | No | Set `0` to prevent git credential prompts in headless environments | +### A2A Server + +| Variable | Required | Description | +| ---------------------------- | -------- | -------------------------------------------------------------- | +| `GEMINI_API_KEY` | Yes | Gemini API key for the agent | +| `CODER_AGENT_PORT` | No | Server port (default: `8080`) | +| `CODER_AGENT_HOST` | No | Bind host (default: `localhost`, set `0.0.0.0` for containers) | +| `CODER_AGENT_WORKSPACE_PATH` | No | Agent workspace directory (default: `/workspace`) | +| `GCS_BUCKET_NAME` | No | GCS bucket for task persistence | +| `GEMINI_YOLO_MODE` | No | Set `true` to auto-approve all tool calls | +| `GIT_TERMINAL_PROMPT` | No | Set `0` to prevent git credential prompts in headless env | + +### Chat Bridge + +| Variable | Required | Description | +| --------------------- | -------- | -------------------------------------------------------------- | +| `A2A_SERVER_URL` | Yes | URL of the A2A agent server (e.g. `http://localhost:8080`) | +| `PORT` | No | Server port (default: `8080`) | +| `CHAT_PROJECT_NUMBER` | No | Google Chat project number for JWT verification | +| `CHAT_SA_KEY_PATH` | No | Path to service account key for Chat API (uses ADC if not set) | +| `GCS_BUCKET_NAME` | No | GCS bucket for session persistence | +| `CHAT_BRIDGE_DEBUG` | No | Set `true` for verbose bridge logging | ## Local Development @@ -64,16 +75,24 @@ npm install npm run build ``` -### Run +### Run the A2A Server ```bash export GEMINI_API_KEY="your-api-key" export CODER_AGENT_PORT=8080 -export CHAT_BRIDGE_A2A_URL=http://localhost:8080 node packages/a2a-server/dist/src/http/server.js ``` +### Run the Chat Bridge (separate terminal) + +```bash +export A2A_SERVER_URL=http://localhost:8080 +export PORT=8090 + +node packages/a2a-server/dist/src/chat-bridge/server.js +``` + ### Test the A2A endpoint ```bash @@ -143,21 +162,27 @@ gcloud artifacts repositories create gemini-a2a \ gsutil mb -l $REGION gs://gemini-a2a-sessions-$PROJECT_ID ``` -### 3. Build with Cloud Build +### 3. Build both images ```bash +# Build A2A agent server gcloud builds submit \ --config=packages/a2a-server/cloudbuild.yaml \ --project=$PROJECT_ID + +# Build Chat bridge +gcloud builds submit \ + --config=packages/a2a-server/cloudbuild-chat-bridge.yaml \ + --project=$PROJECT_ID ``` -### 4. Deploy to Cloud Run +### 4. Deploy A2A agent server ```bash -export IMAGE=us-central1-docker.pkg.dev/$PROJECT_ID/gemini-a2a/a2a-server:latest +export AGENT_IMAGE=us-central1-docker.pkg.dev/$PROJECT_ID/gemini-a2a/a2a-server:latest gcloud run deploy gemini-a2a-server \ - --image=$IMAGE \ + --image=$AGENT_IMAGE \ --region=$REGION \ --project=$PROJECT_ID \ --platform=managed \ @@ -167,14 +192,38 @@ gcloud run deploy gemini-a2a-server \ --timeout=300 \ --concurrency=1 \ --max-instances=1 \ - --set-env-vars="GEMINI_YOLO_MODE=true,GCS_BUCKET_NAME=gemini-a2a-sessions-$PROJECT_ID,CHAT_BRIDGE_A2A_URL=http://localhost:8080" \ + --set-env-vars="GEMINI_YOLO_MODE=true,GCS_BUCKET_NAME=gemini-a2a-sessions-$PROJECT_ID" \ --set-secrets="GEMINI_API_KEY=gemini-api-key:latest" ``` +### 5. Deploy Chat bridge + +Get the A2A server URL first: + +```bash +export A2A_URL=$(gcloud run services describe gemini-a2a-server \ + --region=$REGION --project=$PROJECT_ID --format='value(status.url)') + +export BRIDGE_IMAGE=us-central1-docker.pkg.dev/$PROJECT_ID/gemini-a2a/chat-bridge:latest + +gcloud run deploy gemini-chat-bridge \ + --image=$BRIDGE_IMAGE \ + --region=$REGION \ + --project=$PROJECT_ID \ + --platform=managed \ + --allow-unauthenticated \ + --memory=512Mi \ + --cpu=1 \ + --timeout=60 \ + --concurrency=80 \ + --max-instances=5 \ + --set-env-vars="A2A_SERVER_URL=$A2A_URL,GCS_BUCKET_NAME=gemini-a2a-sessions-$PROJECT_ID" +``` + > **Important**: After initial deployment, always use `--update-env-vars` > instead of `--set-env-vars` to avoid wiping existing environment variables. -### 5. Update an existing deployment +### 6. Update an existing deployment ```bash # Update env vars without replacing existing ones @@ -218,9 +267,9 @@ the Chat app's service account. [Google Cloud Console > APIs & Services > Google Chat API > Configuration](https://console.cloud.google.com/apis/api/chat.googleapis.com/hangouts-chat) 2. Set **App name** and **Description** 3. Under **Connection settings**, select **HTTP endpoint URL** -4. Set the URL to your Cloud Run service URL + `/chat/webhook`: +4. Set the URL to your **Chat bridge** Cloud Run service URL + `/chat/webhook`: ``` - https://gemini-a2a-server-HASH-uc.a.run.app/chat/webhook + https://gemini-chat-bridge-HASH-uc.a.run.app/chat/webhook ``` 5. Under **Authentication Audience**, select **HTTP endpoint URL** 6. Under **Visibility**, choose who can use the app @@ -235,7 +284,7 @@ If your Cloud Run service requires authentication (recommended): # Get the Chat service account # It's usually chat@system.gserviceaccount.com -gcloud run services add-iam-policy-binding gemini-a2a-server \ +gcloud run services add-iam-policy-binding gemini-chat-bridge \ --region=$REGION \ --project=$PROJECT_ID \ --member="serviceAccount:chat@system.gserviceaccount.com" \ @@ -259,10 +308,15 @@ When messaging the bot in Google Chat: ### "Gemini CLI Agent is not responding" in Google Chat -This usually means the agent hit Google Chat's 30-second webhook timeout before -the bridge could return "Processing...". Check Cloud Run logs: +This usually means the bridge couldn't return "Processing..." within Google +Chat's 30-second timeout. Check Cloud Run logs for both services: ```bash +# Chat bridge logs +gcloud run services logs read gemini-chat-bridge \ + --region=$REGION --project=$PROJECT_ID --limit=50 + +# A2A agent logs gcloud run services logs read gemini-a2a-server \ --region=$REGION --project=$PROJECT_ID --limit=50 ``` diff --git a/packages/a2a-server/cloudbuild-chat-bridge.yaml b/packages/a2a-server/cloudbuild-chat-bridge.yaml new file mode 100644 index 0000000000..bc26479e65 --- /dev/null +++ b/packages/a2a-server/cloudbuild-chat-bridge.yaml @@ -0,0 +1,35 @@ +steps: + # Step 1: Install all dependencies and build + - name: 'node:20-slim' + entrypoint: 'bash' + args: + - '-c' + - | + apt-get update && apt-get install -y python3 make g++ git + npm pkg delete scripts.prepare + npm install + npm run build + env: + - 'HUSKY=0' + + # Step 2: Build Docker image for Chat bridge + - name: 'gcr.io/cloud-builders/docker' + args: + - 'build' + - '-t' + - 'us-central1-docker.pkg.dev/$PROJECT_ID/gemini-a2a/chat-bridge:latest' + - '-f' + - 'packages/a2a-server/Dockerfile.chat-bridge' + - '.' + + # Step 3: Push to Artifact Registry + - name: 'gcr.io/cloud-builders/docker' + args: + - 'push' + - 'us-central1-docker.pkg.dev/$PROJECT_ID/gemini-a2a/chat-bridge:latest' + +images: + - 'us-central1-docker.pkg.dev/$PROJECT_ID/gemini-a2a/chat-bridge:latest' +timeout: '1800s' +options: + machineType: 'E2_HIGHCPU_8' diff --git a/packages/a2a-server/src/chat-bridge/a2a-bridge-client.ts b/packages/a2a-server/src/chat-bridge/a2a-bridge-client.ts index e08349d39c..ba0ab08176 100644 --- a/packages/a2a-server/src/chat-bridge/a2a-bridge-client.ts +++ b/packages/a2a-server/src/chat-bridge/a2a-bridge-client.ts @@ -29,7 +29,10 @@ import { } from '@a2a-js/sdk/client'; import { v4 as uuidv4 } from 'uuid'; import { logger } from '../utils/logger.js'; -import { A2UI_EXTENSION_URI, A2UI_MIME_TYPE } from '../a2ui/a2ui-extension.js'; + +// Inline A2UI constants so the chat bridge has no dependency on ../a2ui/ +const A2UI_EXTENSION_URI = 'https://a2ui.org/a2a-extension/a2ui/v0.10'; +const A2UI_MIME_TYPE = 'application/json+a2ui'; export type A2AResponse = Message | Task; export type A2AStreamEventData = diff --git a/packages/a2a-server/src/chat-bridge/server.ts b/packages/a2a-server/src/chat-bridge/server.ts new file mode 100644 index 0000000000..f8af435ef9 --- /dev/null +++ b/packages/a2a-server/src/chat-bridge/server.ts @@ -0,0 +1,62 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Standalone Google Chat bridge server. + * Runs independently from the A2A agent server — connects to it + * via the A2A protocol over HTTP. Deploy as a separate Cloud Run + * service for independent scaling. + */ + +import express from 'express'; +import { createChatBridgeRoutes } from './routes.js'; +import { logger } from '../utils/logger.js'; + +function main() { + const a2aServerUrl = process.env['A2A_SERVER_URL']; + if (!a2aServerUrl) { + logger.error( + '[ChatBridge] A2A_SERVER_URL is required. Set it to the A2A agent server URL.', + ); + process.exit(1); + } + + const app = express(); + app.use(express.json()); + + const chatRoutes = createChatBridgeRoutes({ + a2aServerUrl, + projectNumber: process.env['CHAT_PROJECT_NUMBER'], + debug: process.env['CHAT_BRIDGE_DEBUG'] === 'true', + gcsBucket: process.env['GCS_BUCKET_NAME'], + serviceAccountKeyPath: process.env['CHAT_SA_KEY_PATH'], + }); + app.use(chatRoutes); + + // Root health check + app.get('/', (_req, res) => { + res.json({ + service: 'gemini-chat-bridge', + status: 'ok', + a2aServerUrl, + }); + }); + + const port = Number(process.env['PORT'] || 8080); + const host = process.env['HOST'] || '0.0.0.0'; + + app.listen(port, host, () => { + logger.info(`[ChatBridge] Server started on http://${host}:${port}`); + logger.info(`[ChatBridge] Connected to A2A agent at ${a2aServerUrl}`); + }); +} + +process.on('uncaughtException', (error) => { + logger.error('[ChatBridge] Unhandled exception:', error); + process.exit(1); +}); + +main(); diff --git a/packages/a2a-server/src/http/app.ts b/packages/a2a-server/src/http/app.ts index feb5037b22..84a3b3ac80 100644 --- a/packages/a2a-server/src/http/app.ts +++ b/packages/a2a-server/src/http/app.ts @@ -29,7 +29,6 @@ import { debugLogger, SimpleExtensionLoader } from '@google/gemini-cli-core'; import type { Command, CommandArgument } from '../commands/types.js'; import { GitService } from '@google/gemini-cli-core'; import { getA2UIAgentExtension } from '../a2ui/a2ui-extension.js'; -import { createChatBridgeRoutes } from '../chat-bridge/routes.js'; type CommandResponse = { name: string; @@ -203,29 +202,8 @@ export async function createApp() { requestStorage.run({ req }, next); }); - // Mount Google Chat bridge routes BEFORE A2A SDK routes. - // The A2A SDK's setupRoutes registers a catch-all jsonRpcHandler middleware - // at baseUrl="" that intercepts ALL POST requests and returns 400 for - // non-JSON-RPC payloads. Chat bridge must be registered first. - const chatBridgeUrl = - process.env['CHAT_BRIDGE_A2A_URL'] || process.env['CODER_AGENT_PORT'] - ? `http://localhost:${process.env['CODER_AGENT_PORT'] || '8080'}` - : undefined; - if (chatBridgeUrl) { - expressApp.use(express.json()); - const chatRoutes = createChatBridgeRoutes({ - a2aServerUrl: chatBridgeUrl, - projectNumber: process.env['CHAT_PROJECT_NUMBER'], - debug: process.env['CHAT_BRIDGE_DEBUG'] === 'true', - gcsBucket: process.env['GCS_BUCKET_NAME'], - serviceAccountKeyPath: process.env['CHAT_SA_KEY_PATH'], - }); - expressApp.use(chatRoutes); - logger.info( - `[CoreAgent] Google Chat bridge enabled at /chat/webhook (A2A: ${chatBridgeUrl})`, - ); - } - + // Google Chat bridge runs as a separate service (src/chat-bridge/server.ts). + // It connects to this A2A server over HTTP. const appBuilder = new A2AExpressApp(requestHandler); expressApp = appBuilder.setupRoutes(expressApp, ''); expressApp.use(express.json());