diff --git a/FOREVER.md b/FOREVER.md new file mode 100644 index 0000000000..b396fc0c0e --- /dev/null +++ b/FOREVER.md @@ -0,0 +1,255 @@ +# Forever Agent + Google Chat Bridge + +Run Gemini CLI as an autonomous forever agent, accessible via Google Chat. + +## Architecture + +``` +Google Chat Space + ↓ webhook (HTTPS) +Chat Bridge (port 8081, public) + ↓ JSON-RPC (localhost) +gemini-cli --forever (port 3100, localhost only) + ↓ +Gemini API (LLM + tools) +``` + +**Two processes on one VM:** + +- **gemini-cli --forever** — the agent, runs continuously with Sisyphus + auto-resume +- **Chat bridge** — receives Google Chat webhooks, forwards to agent, pushes + responses back via Chat API + +One agent per Google Chat space. YOLO mode (auto-approve all tools). + +## Prerequisites + +1. **Google Cloud project** with: + - Chat API enabled + - A service account with `Chat Bot` role + - Service account key JSON file + +2. **Gemini API key** from + [Google AI Studio](https://aistudio.google.com/apikey) + +3. **Node.js 20+** + +## Local Development (with ngrok) + +### 1. Install and build + +```bash +git clone -b st/forever https://github.com/google-gemini/gemini-cli.git gemini-cli-forever +cd gemini-cli-forever +npm install --ignore-scripts +npm run build +``` + +### 2. Set env vars + +```bash +export GOOGLE_API_KEY="your-gemini-api-key" +export GOOGLE_APPLICATION_CREDENTIALS="/path/to/service-account-key.json" +export A2A_PORT=3100 +export BRIDGE_PORT=8081 +# Optional: set for JWT verification (your GCP project number, not project ID) +# export CHAT_PROJECT_NUMBER="123456789" +``` + +### 3. Start ngrok (separate terminal) + +```bash +ngrok http 8081 +``` + +Copy the HTTPS URL (e.g., `https://abc123.ngrok.io`). + +### 4. Configure Google Chat App + +1. Go to + [Google Cloud Console → APIs & Services → Google Chat API](https://console.cloud.google.com/apis/api/chat.googleapis.com/hangouts-chat) +2. Click **Configuration** +3. Set: + - **App name:** Forever Agent + - **App URL:** `https://abc123.ngrok.io/chat/webhook` + - **Visibility:** People and groups in your domain (or specific people) + - **Functionality:** Spaces and group conversations (check), Direct messages + (check) + - **Connection settings:** HTTP endpoint URL + - **Permissions:** Everyone in your Workspace domain (or specific people) +4. Save + +### 5. Start the agent + +```bash +./scripts/start-forever.sh +``` + +The onboarding dialog will ask for a mission and Sisyphus config on first run. + +### 6. Test + +1. Open Google Chat +2. Create a new space and add the "Forever Agent" app +3. Send a message — the agent will process it and respond + +## GCE VM Deployment + +### 1. Create the VM + +```bash +gcloud compute instances create forever-agent \ + --zone=us-central1-a \ + --machine-type=e2-small \ + --image-family=debian-12 \ + --image-project=debian-cloud \ + --boot-disk-size=20GB \ + --tags=forever-agent +``` + +### 2. Allow inbound traffic on bridge port + +```bash +gcloud compute firewall-rules create allow-chat-bridge \ + --allow=tcp:8081 \ + --target-tags=forever-agent \ + --source-ranges=0.0.0.0/0 \ + --description="Allow Google Chat webhooks to reach the bridge" +``` + +### 3. SSH and install Node.js + +```bash +gcloud compute ssh forever-agent --zone=us-central1-a + +# On the VM: +curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - +sudo apt-get install -y nodejs git +``` + +### 4. Clone and build + +```bash +git clone -b st/forever https://github.com/google-gemini/gemini-cli.git +cd gemini-cli +npm install --ignore-scripts +npm run build +``` + +### 5. Configure + +```bash +# Create env file +cat > ~/.forever-agent.env << 'EOF' +GOOGLE_API_KEY=your-gemini-api-key +GOOGLE_APPLICATION_CREDENTIALS=/home/$USER/service-account-key.json +A2A_PORT=3100 +BRIDGE_PORT=8081 +CHAT_PROJECT_NUMBER=your-project-number +EOF + +# Upload service account key +# (from your local machine): +# gcloud compute scp service-account-key.json forever-agent:~/service-account-key.json --zone=us-central1-a +``` + +### 6. Create systemd service + +```bash +sudo tee /etc/systemd/system/forever-agent.service << EOF +[Unit] +Description=Gemini CLI Forever Agent +After=network.target + +[Service] +Type=simple +User=$USER +WorkingDirectory=/home/$USER/gemini-cli +EnvironmentFile=/home/$USER/.forever-agent.env +ExecStart=/home/$USER/gemini-cli/scripts/start-forever.sh +Restart=always +RestartSec=10 + +[Install] +WantedBy=multi-user.target +EOF + +sudo systemctl daemon-reload +sudo systemctl enable forever-agent +sudo systemctl start forever-agent +``` + +### 7. Check logs + +```bash +sudo journalctl -u forever-agent -f +``` + +### 8. Configure Google Chat App + +Same as local dev, but use the VM's external IP: + +- **App URL:** `http://EXTERNAL_IP:8081/chat/webhook` + +> **Note:** Google Chat requires HTTPS in production. For HTTPS, either: +> +> - Put nginx + Let's Encrypt in front +> (`sudo apt install nginx certbot python3-certbot-nginx`) +> - Use a Cloud Load Balancer with managed cert +> - For testing, HTTP works with some Chat API configurations + +## Env Vars Reference + +| Variable | Required | Default | Description | +| -------------------------------- | -------- | ----------------------- | --------------------------------------- | +| `GOOGLE_API_KEY` | Yes | — | Gemini API key | +| `GOOGLE_APPLICATION_CREDENTIALS` | Yes | — | Path to service account key JSON | +| `A2A_PORT` | No | `3100` | External listener port (agent) | +| `BRIDGE_PORT` | No | `8081` | Chat bridge port (public-facing) | +| `A2A_URL` | No | `http://127.0.0.1:3100` | Agent URL (for bridge to connect to) | +| `CHAT_PROJECT_NUMBER` | No | — | GCP project number for JWT verification | + +## How It Works + +1. User sends message in Google Chat space +2. Google Chat POSTs webhook to the bridge (`/chat/webhook`) +3. Bridge immediately returns `{}` (Google Chat has a 30s webhook timeout) +4. Bridge asynchronously sends the message to the forever agent via JSON-RPC +5. Agent processes the message (may take minutes — tools, thinking, etc.) +6. Agent returns response to bridge +7. Bridge pushes response to Google Chat via Chat REST API + +The forever agent runs with: + +- **Sisyphus** — auto-resumes after idle timeout (configurable) +- **Confucius** — reflects and consolidates knowledge at ~80% context +- **Hippocampus** — extracts key facts after each turn +- **Bicameral Voice** — proactively captures knowledge from user messages +- **YOLO mode** — auto-approves all tool calls (no human-in-the-loop) + +## Troubleshooting + +### Bridge returns 401/403 + +- Check `CHAT_PROJECT_NUMBER` matches your GCP project number (not project ID) +- Unset `CHAT_PROJECT_NUMBER` to disable JWT verification for testing + +### Agent doesn't respond + +- Check the agent is running: + `curl http://localhost:3100/.well-known/agent-card.json` +- Check bridge health: `curl http://localhost:8081/health` +- Check bridge logs for errors + +### Google Chat shows no response + +- The bridge returns `{}` immediately — responses are pushed async via Chat API +- Ensure `GOOGLE_APPLICATION_CREDENTIALS` points to a valid service account key +- Ensure the service account has `Chat Bot` role + +### Webhook not reaching bridge + +- Check firewall rules allow inbound on port 8081 +- For HTTPS requirement: use ngrok for testing, nginx + Let's Encrypt for + production diff --git a/package-lock.json b/package-lock.json index 5f0c5f058d..d41894b057 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17086,6 +17086,7 @@ "@google/gemini-cli-core": "file:../core", "express": "^5.1.0", "fs-extra": "^11.3.0", + "google-auth-library": "^9.15.1", "strip-json-comments": "^3.1.1", "tar": "^7.5.8", "uuid": "^13.0.0", diff --git a/packages/a2a-server/package.json b/packages/a2a-server/package.json index bc85e51bc6..2a0d740a38 100644 --- a/packages/a2a-server/package.json +++ b/packages/a2a-server/package.json @@ -29,6 +29,7 @@ "@google-cloud/storage": "^7.16.0", "@google/gemini-cli-core": "file:../core", "express": "^5.1.0", + "google-auth-library": "^9.15.1", "fs-extra": "^11.3.0", "strip-json-comments": "^3.1.1", "tar": "^7.5.8", diff --git a/packages/a2a-server/src/chat-bridge/bridge.ts b/packages/a2a-server/src/chat-bridge/bridge.ts new file mode 100644 index 0000000000..855bd84861 --- /dev/null +++ b/packages/a2a-server/src/chat-bridge/bridge.ts @@ -0,0 +1,321 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Minimal Google Chat bridge for the Gemini CLI forever mode. + * + * Architecture: + * Google Chat webhook -> this bridge (port 8081) -> external listener (port 3100) + * Response comes back -> bridge pushes to Google Chat via Chat API + * + * One agent per space. Messages are forwarded as-is to the running + * gemini-cli --forever session via its JSON-RPC external listener. + */ + +import express from 'express'; +import { OAuth2Client } from 'google-auth-library'; +import { ChatApiClient } from './chat-api-client.js'; +import { logger } from '../utils/logger.js'; + +// --- Config from env vars --- + +const BRIDGE_PORT = parseInt(process.env['BRIDGE_PORT'] ?? '8081', 10); +const A2A_URL = process.env['A2A_URL'] ?? 'http://127.0.0.1:3100'; +const CHAT_PROJECT_NUMBER = process.env['CHAT_PROJECT_NUMBER']; +const CHAT_ISSUER = 'chat@system.gserviceaccount.com'; + +// --- Types --- + +interface ChatMessagePart { + kind?: string; + text?: string; +} + +interface JsonRpcResponse { + jsonrpc: string; + id: string | number | null; + result?: { + kind: string; + id: string; + status: { + state: string; + message?: { + parts: ChatMessagePart[]; + }; + }; + }; + error?: { code: number; message: string }; +} + +// --- Auth middleware --- + +function createAuthMiddleware(): ( + req: express.Request, + res: express.Response, + next: express.NextFunction, +) => void { + // On Cloud Run, IAM handles auth + if (process.env['K_SERVICE']) { + logger.info('[Bridge] Running on Cloud Run — auth delegated to IAM.'); + return (_req, _res, next) => next(); + } + + if (!CHAT_PROJECT_NUMBER) { + logger.warn( + '[Bridge] CHAT_PROJECT_NUMBER not set — JWT verification disabled.', + ); + return (_req, _res, next) => next(); + } + + const authClient = new OAuth2Client(); + + return (req, res, next) => { + const authHeader = req.headers['authorization']; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + res.status(401).json({ error: 'Unauthorized' }); + return; + } + + authClient + .verifyIdToken({ + idToken: authHeader.substring(7), + audience: CHAT_PROJECT_NUMBER, + }) + .then((ticket) => { + const payload = ticket.getPayload(); + if (payload?.iss !== CHAT_ISSUER) { + res.status(403).json({ error: 'Forbidden: invalid issuer' }); + return; + } + next(); + }) + .catch((err: unknown) => { + const msg = err instanceof Error ? err.message : 'Unknown error'; + logger.warn(`[Bridge] JWT verification failed: ${msg}`); + res.status(401).json({ error: 'Unauthorized' }); + }); + }; +} + +// --- Event normalization --- + +function isObj(v: unknown): v is Record { + return typeof v === 'object' && v !== null && !Array.isArray(v); +} + +function str(obj: Record, key: string): string { + const v = obj[key]; + return typeof v === 'string' ? v : ''; +} + +interface NormalizedEvent { + type: string; + text: string; + spaceName: string; + threadName: string; +} + +/** + * Extract the essentials from a Google Chat webhook event. + * Handles both legacy and Workspace Add-ons format. + */ +function normalizeEvent(raw: Record): NormalizedEvent | null { + // Legacy format + if (typeof raw['type'] === 'string') { + const message = isObj(raw['message']) ? raw['message'] : {}; + const space = isObj(raw['space']) + ? raw['space'] + : isObj(message['space']) + ? message['space'] + : {}; + const thread = isObj(message['thread']) ? message['thread'] : {}; + return { + type: raw['type'], + text: str(message, 'text'), + spaceName: str(space, 'name'), + threadName: str(thread, 'name'), + }; + } + + // Workspace Add-ons format + const chat = raw['chat']; + if (!isObj(chat)) return null; + + if (isObj(chat['messagePayload'])) { + const payload = chat['messagePayload']; + const message = isObj(payload['message']) ? payload['message'] : {}; + const space = isObj(payload['space']) + ? payload['space'] + : isObj(message['space']) + ? message['space'] + : {}; + const thread = isObj(message['thread']) ? message['thread'] : {}; + return { + type: 'MESSAGE', + text: str(message, 'text'), + spaceName: str(space, 'name'), + threadName: str(thread, 'name'), + }; + } + + if (isObj(chat['addedToSpacePayload'])) { + const payload = chat['addedToSpacePayload']; + const space = isObj(payload['space']) ? payload['space'] : {}; + return { + type: 'ADDED_TO_SPACE', + text: '', + spaceName: str(space, 'name'), + threadName: '', + }; + } + + return null; +} + +// --- JSON-RPC call to external listener --- + +async function sendToAgent(text: string): Promise { + const body = JSON.stringify({ + jsonrpc: '2.0', + id: Date.now(), + method: 'message/send', + params: { + message: { + role: 'user', + parts: [{ kind: 'text', text }], + }, + }, + }); + + const response = await fetch(A2A_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body, + }); + + if (!response.ok) { + throw new Error( + `Agent returned ${response.status}: ${await response.text()}`, + ); + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const result = (await response.json()) as JsonRpcResponse; + + if (result.error) { + throw new Error(`Agent error: ${result.error.message}`); + } + + // Extract text from the response + const parts = result.result?.status?.message?.parts ?? []; + const texts = parts + .filter((p) => p.kind === 'text' && p.text) + .map((p) => p.text!); + + return texts.join('\n') || '(no response)'; +} + +// --- Express app --- + +export function createBridgeApp(): express.Express { + const app = express(); + app.use(express.json()); + + const chatApi = new ChatApiClient(); + const auth = createAuthMiddleware(); + + // Health check + app.get('/health', (_req, res) => { + res.json({ status: 'ok', a2aUrl: A2A_URL }); + }); + + // Google Chat webhook + app.post('/chat/webhook', auth, (req, res) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const raw = req.body as Record; + const event = normalizeEvent(raw); + + if (!event) { + logger.warn( + `[Bridge] Unknown event format: ${Object.keys(raw).join(',')}`, + ); + res.json({}); + return; + } + + logger.info( + `[Bridge] ${event.type}: space=${event.spaceName} text="${event.text.substring(0, 100)}"`, + ); + + // Handle non-message events + if (event.type === 'ADDED_TO_SPACE') { + res.json({ + hostAppDataAction: { + chatDataAction: { + createMessageAction: { + message: { + text: 'Gemini CLI forever agent connected. Send me a task!', + }, + }, + }, + }, + }); + return; + } + + if (event.type !== 'MESSAGE' || !event.text) { + res.json({}); + return; + } + + // Immediately ack the webhook (30s timeout) + res.json({}); + + // Process async — send to agent, push response back via Chat API + processMessageAsync(chatApi, event).catch((err) => { + logger.error(`[Bridge] Async processing failed: ${err}`); + }); + }); + + return app; +} + +async function processMessageAsync( + chatApi: ChatApiClient, + event: NormalizedEvent, +): Promise { + const { text, spaceName, threadName } = event; + + try { + logger.info(`[Bridge] Sending to agent: "${text.substring(0, 100)}"`); + const responseText = await sendToAgent(text); + logger.info( + `[Bridge] Agent response (${responseText.length} chars): "${responseText.substring(0, 100)}..."`, + ); + + // Push response back to Google Chat + await chatApi.sendMessage(spaceName, threadName, { text: responseText }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logger.error(`[Bridge] Error: ${msg}`); + await chatApi.sendMessage(spaceName, threadName, { + text: `Error: ${msg}`, + }); + } +} + +// --- Standalone entrypoint --- + +if ( + process.argv[1]?.endsWith('bridge.js') || + process.argv[1]?.endsWith('bridge.ts') +) { + const app = createBridgeApp(); + app.listen(BRIDGE_PORT, '0.0.0.0', () => { + logger.info(`[Bridge] Google Chat bridge listening on port ${BRIDGE_PORT}`); + logger.info(`[Bridge] Forwarding to agent at ${A2A_URL}`); + }); +} diff --git a/packages/a2a-server/src/chat-bridge/chat-api-client.ts b/packages/a2a-server/src/chat-bridge/chat-api-client.ts new file mode 100644 index 0000000000..3c29e1a811 --- /dev/null +++ b/packages/a2a-server/src/chat-bridge/chat-api-client.ts @@ -0,0 +1,105 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Google Chat REST API client for pushing messages to spaces. + */ + +import { GoogleAuth } from 'google-auth-library'; +import { logger } from '../utils/logger.js'; + +const CHAT_API_BASE = 'https://chat.googleapis.com/v1'; +const MAX_TEXT_LENGTH = 4000; + +export class ChatApiClient { + private auth: GoogleAuth; + private initialized = false; + + constructor() { + this.auth = new GoogleAuth({ + scopes: ['https://www.googleapis.com/auth/chat.bot'], + }); + } + + private async init(): Promise { + if (this.initialized) return; + await this.auth.getClient(); + this.initialized = true; + logger.info('[ChatApi] Initialized'); + } + + async sendMessage( + spaceName: string, + threadName: string, + options: { text?: string }, + ): Promise { + await this.init(); + + const chunks = options.text ? splitText(options.text) : ['']; + + for (const chunk of chunks) { + const message: Record = {}; + if (chunk) message['text'] = chunk; + if (threadName) { + message['thread'] = { name: threadName }; + } + + await this.postMessage(spaceName, message); + } + } + + private async postMessage( + spaceName: string, + message: Record, + ): Promise { + try { + const url = + `${CHAT_API_BASE}/${spaceName}/messages` + + `?messageReplyOption=REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD`; + + const client = await this.auth.getClient(); + const headers = await client.getRequestHeaders(); + + const response = await fetch(url, { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify(message), + }); + + if (!response.ok) { + const body = await response.text(); + logger.error(`[ChatApi] Send failed: ${response.status} ${body}`); + } else { + logger.info(`[ChatApi] Message sent to ${spaceName}`); + } + } catch (error) { + const msg = error instanceof Error ? error.message : 'Unknown error'; + logger.error(`[ChatApi] Error: ${msg}`); + } + } +} + +function splitText(text: string): string[] { + if (text.length <= MAX_TEXT_LENGTH) return [text]; + + const chunks: string[] = []; + let remaining = text; + + while (remaining.length > MAX_TEXT_LENGTH) { + let splitAt = remaining.lastIndexOf('\n\n', MAX_TEXT_LENGTH); + if (splitAt < MAX_TEXT_LENGTH * 0.3) { + splitAt = remaining.lastIndexOf('\n', MAX_TEXT_LENGTH); + } + if (splitAt < MAX_TEXT_LENGTH * 0.3) { + splitAt = MAX_TEXT_LENGTH; + } + chunks.push(remaining.substring(0, splitAt)); + remaining = remaining.substring(splitAt); + } + + if (remaining) chunks.push(remaining); + return chunks; +} diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 44dd66cbe0..fa183ecebb 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -413,7 +413,8 @@ export async function parseArguments( result['a2aPort'] === undefined && result['a2a-port'] === undefined ) { - (result as Record)['a2aPort'] = 0; + (result as Record)['a2aPort'] = + parseInt(process.env['A2A_PORT'] ?? '0', 10) || 3100; } // The import format is now only controlled by settings.memoryImportFormat // We no longer accept it as a CLI argument // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion diff --git a/packages/cli/src/external-listener.ts b/packages/cli/src/external-listener.ts index 5262d78970..48ab568f6c 100644 --- a/packages/cli/src/external-listener.ts +++ b/packages/cli/src/external-listener.ts @@ -33,7 +33,7 @@ interface A2ATask { const tasks = new Map(); const TASK_CLEANUP_DELAY_MS = 10 * 60 * 1000; // 10 minutes -const DEFAULT_BLOCKING_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes +const DEFAULT_BLOCKING_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes (forever mode tasks can run long) interface ResponseWaiter { taskId: string; diff --git a/scripts/start-forever.sh b/scripts/start-forever.sh new file mode 100755 index 0000000000..f2bda27fa3 --- /dev/null +++ b/scripts/start-forever.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Start the forever agent + chat bridge together. +# Usage: ./scripts/start-forever.sh +# +# Required env vars: +# GOOGLE_API_KEY - Gemini API key +# GOOGLE_APPLICATION_CREDENTIALS - path to service account key (for Chat API) +# +# Optional env vars: +# A2A_PORT - external listener port (default: 3100) +# BRIDGE_PORT - chat bridge port (default: 8081) +# CHAT_PROJECT_NUMBER - Google Cloud project number (for JWT verification) + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +# Default ports +export A2A_PORT="${A2A_PORT:-3100}" +export BRIDGE_PORT="${BRIDGE_PORT:-8081}" +export A2A_URL="${A2A_URL:-http://127.0.0.1:${A2A_PORT}}" + +echo "=== Gemini CLI Forever Agent ===" +echo "Agent listener: localhost:${A2A_PORT}" +echo "Chat bridge: 0.0.0.0:${BRIDGE_PORT}" +echo "" + +# Build if needed +if [ ! -d "$REPO_ROOT/packages/a2a-server/dist" ]; then + echo "Building a2a-server..." + cd "$REPO_ROOT" && npm run build --workspace=packages/a2a-server +fi + +# Start the chat bridge in the background +echo "Starting chat bridge..." +node "$REPO_ROOT/packages/a2a-server/dist/src/chat-bridge/bridge.js" & +BRIDGE_PID=$! + +# Cleanup on exit +cleanup() { + echo "Shutting down..." + kill "$BRIDGE_PID" 2>/dev/null || true + wait "$BRIDGE_PID" 2>/dev/null || true +} +trap cleanup EXIT INT TERM + +# Start the forever agent in the foreground +echo "Starting forever agent..." +cd "$REPO_ROOT" +npx gemini --forever --a2a-port "$A2A_PORT" --yolo + +# If the agent exits, cleanup will fire