mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-06-20 16:26:44 -07:00
feat: add Google Chat bridge for forever mode
- Minimal Express bridge that receives Google Chat webhooks, forwards to the forever agent's external listener via JSON-RPC, and pushes responses back via Chat API - Chat API client for async message delivery (Google Chat webhooks have a 30s timeout, agent responses take longer) - JWT verification for Google Chat requests (skippable for local dev) - External listener default port changed to 3100 (configurable via A2A_PORT env var), blocking timeout increased to 30min - Startup script (scripts/start-forever.sh) launches both processes - FOREVER.md with full setup instructions (local dev + GCE VM)
This commit is contained in:
+255
@@ -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
|
||||
Generated
+1
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
||||
}
|
||||
|
||||
function str(obj: Record<string, unknown>, 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<string, unknown>): 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<string> {
|
||||
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<string, unknown>;
|
||||
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<void> {
|
||||
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}`);
|
||||
});
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
await this.init();
|
||||
|
||||
const chunks = options.text ? splitText(options.text) : [''];
|
||||
|
||||
for (const chunk of chunks) {
|
||||
const message: Record<string, unknown> = {};
|
||||
if (chunk) message['text'] = chunk;
|
||||
if (threadName) {
|
||||
message['thread'] = { name: threadName };
|
||||
}
|
||||
|
||||
await this.postMessage(spaceName, message);
|
||||
}
|
||||
}
|
||||
|
||||
private async postMessage(
|
||||
spaceName: string,
|
||||
message: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
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;
|
||||
}
|
||||
@@ -413,7 +413,8 @@ export async function parseArguments(
|
||||
result['a2aPort'] === undefined &&
|
||||
result['a2a-port'] === undefined
|
||||
) {
|
||||
(result as Record<string, unknown>)['a2aPort'] = 0;
|
||||
(result as Record<string, unknown>)['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
|
||||
|
||||
@@ -33,7 +33,7 @@ interface A2ATask {
|
||||
const tasks = new Map<string, A2ATask>();
|
||||
|
||||
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;
|
||||
|
||||
Executable
+53
@@ -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
|
||||
Reference in New Issue
Block a user