Compare commits

..

11 Commits

Author SHA1 Message Date
gemini-cli-robot 19a68016bd chore(release): v0.53.1 2026-07-31 21:28:10 +00:00
gemini-cli-robot 983af5d589 fix(patch): cherry-pick f47d6c6 to release/v0.53.0-pr-28566 [CONFLICTS] (#28610)
Co-authored-by: David Pierce <davidapierce@google.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-07-31 16:37:51 -04:00
gemini-cli-robot decc0b46c6 chore(release): v0.53.0 2026-07-28 21:38:57 +00:00
gemini-cli-robot 02abfab45f chore(release): v0.53.0-preview.0 2026-07-22 20:01:40 +00:00
Vedant Mahajan 9681621c6b feat(evals): add eval coverage report command (#28169) 2026-07-22 18:45:42 +00:00
luisfelipe-alt f743ab5790 fix(core): sequentially verify cached credentials and restore GOOGLE_APPLICATION_CREDENTIALS fallback (#28472)
Co-authored-by: David Pierce <davidapierce@google.com>
2026-07-22 16:09:52 +00:00
luisfelipe-alt c776c665b0 fix(a2a-server): enforce workspace trust and task isolation to prevent RCE (#28470) 2026-07-21 16:38:09 +00:00
amelidev acae7124bd fix(core): mitigate infinite ReAct loops and prompt injection loops (#28429)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-07-17 21:03:46 +00:00
Om Patel 69e0c2659e refactor(cli): align macOS permissive Seatbelt profiles with deny-default model (#28424) 2026-07-17 18:06:01 +00:00
Chad aea9e5e8ed feat(caretaker-triage): implement LLM triage orchestrator and container build (#28345) 2026-07-17 16:59:28 +00:00
luisfelipe-alt 3ff5ba20fc fix(core,a2a): group cancelled tool responses and coalesce consecutive roles to prevent 400 Bad Request (#28407) 2026-07-15 19:58:55 +00:00
77 changed files with 5569 additions and 718 deletions
+3 -3
View File
@@ -421,9 +421,9 @@ To debug the CLI's React-based UI, you can use React DevTools.
On macOS, `gemini` uses Seatbelt (`sandbox-exec`) under a `permissive-open`
profile (see `packages/cli/src/utils/sandbox-macos-permissive-open.sb`) that
restricts writes to the project folder but otherwise allows all other operations
and outbound network traffic ("open") by default. You can switch to a
`strict-open` profile (see
denies operations by default, confining writes to the project folder while
allowing broad file reads and outbound network traffic ("open") by default. You
can switch to a `strict-open` profile (see
`packages/cli/src/utils/sandbox-macos-strict-open.sb`) that restricts both reads
and writes to the working directory while allowing outbound network traffic by
setting `SEATBELT_PROFILE=strict-open` in your environment or `.env` file.
+3 -2
View File
@@ -87,8 +87,9 @@ preferred container solution.
Lightweight, built-in sandboxing using `sandbox-exec`.
**Default profile**: `permissive-open` - restricts writes outside project
directory but allows most other operations.
**Default profile**: `permissive-open` - denies operations by default; confines
writes to the project directory while allowing broad file reads and network
access.
Built-in profiles (set via `SEATBELT_PROFILE` env var):
+4 -4
View File
@@ -2736,10 +2736,10 @@ the `advanced.excludedEnvVars` setting in your `settings.json` file.
- Run the CLI once with this set to generate the file.
- **`SEATBELT_PROFILE`** (macOS specific):
- Switches the Seatbelt (`sandbox-exec`) profile on macOS.
- `permissive-open`: (Default) Restricts writes to the project folder (and a
few other folders, see
`packages/cli/src/utils/sandbox-macos-permissive-open.sb`) but allows other
operations.
- `permissive-open`: (Default) Denies operations by default, confining writes
to the project folder (and a few other folders, see
`packages/cli/src/utils/sandbox-macos-permissive-open.sb`) while allowing
broad file reads and network access.
- `restrictive-open`: Declines operations by default, allows network.
- `strict-open`: Restricts both reads and writes to the working directory,
allows network.
@@ -35,7 +35,7 @@ describe('Interactive file system', () => {
const run = await rig.runInteractive();
// Step 1: Read the file
const readPrompt = `Read the version from ${fileName}`;
const readPrompt = `Read the version from ${fileName} using the read_file tool`;
await run.type(readPrompt);
await run.type('\r');
+9 -9
View File
@@ -1,12 +1,12 @@
{
"name": "@google/gemini-cli",
"version": "0.52.0",
"version": "0.53.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@google/gemini-cli",
"version": "0.52.0",
"version": "0.53.1",
"workspaces": [
"packages/*"
],
@@ -17782,7 +17782,7 @@
},
"packages/a2a-server": {
"name": "@google/gemini-cli-a2a-server",
"version": "0.52.0",
"version": "0.53.1",
"dependencies": {
"@a2a-js/sdk": "0.3.11",
"@google-cloud/storage": "7.19.0",
@@ -18242,7 +18242,7 @@
},
"packages/cli": {
"name": "@google/gemini-cli",
"version": "0.52.0",
"version": "0.53.1",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "0.16.1",
@@ -18458,7 +18458,7 @@
},
"packages/core": {
"name": "@google/gemini-cli-core",
"version": "0.52.0",
"version": "0.53.1",
"license": "Apache-2.0",
"dependencies": {
"@a2a-js/sdk": "0.3.11",
@@ -19131,7 +19131,7 @@
},
"packages/devtools": {
"name": "@google/gemini-cli-devtools",
"version": "0.52.0",
"version": "0.53.1",
"license": "Apache-2.0",
"dependencies": {
"ws": "8.16.0"
@@ -19167,7 +19167,7 @@
},
"packages/sdk": {
"name": "@google/gemini-cli-sdk",
"version": "0.52.0",
"version": "0.53.1",
"license": "Apache-2.0",
"dependencies": {
"@google/gemini-cli-core": "file:../core",
@@ -19506,7 +19506,7 @@
},
"packages/test-utils": {
"name": "@google/gemini-cli-test-utils",
"version": "0.52.0",
"version": "0.53.1",
"license": "Apache-2.0",
"dependencies": {
"@google/gemini-cli-core": "file:../core",
@@ -19524,7 +19524,7 @@
},
"packages/vscode-ide-companion": {
"name": "gemini-cli-vscode-ide-companion",
"version": "0.52.0",
"version": "0.53.1",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "1.23.0",
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.52.0",
"version": "0.53.1",
"engines": {
"node": ">=20.0.0"
},
@@ -14,7 +14,7 @@
"url": "git+https://github.com/google-gemini/gemini-cli.git"
},
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.52.0"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.53.1"
},
"scripts": {
"start": "cross-env NODE_ENV=development node scripts/start.js",
@@ -34,6 +34,7 @@
"docs:keybindings": "tsx ./scripts/generate-keybindings-doc.ts",
"eval:inventory": "tsx ./scripts/eval-inventory-cli.ts",
"eval:inventory:json": "tsx ./scripts/eval-inventory-cli.ts --json",
"eval:coverage": "tsx ./scripts/eval-coverage-cli.ts",
"build": "node scripts/build.js",
"build-and-start": "npm run build && npm run start --",
"build:vscode": "node scripts/build_vscode_companion.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-a2a-server",
"version": "0.52.0",
"version": "0.53.1",
"description": "Gemini CLI A2A Server",
"repository": {
"type": "git",
@@ -21,6 +21,17 @@ vi.mock('../utils/path_utils.js', () => ({
}));
// Mocks for constructor dependencies
vi.mock('@google/gemini-cli-core', () => ({
GeminiEventType: {
PRIMARY_TURN_STARTED: 'PRIMARY_TURN_STARTED',
SECONDARY_TURN_STARTED: 'SECONDARY_TURN_STARTED',
},
SimpleExtensionLoader: vi.fn(),
checkPathTrust: vi.fn().mockReturnValue({ isTrusted: false }),
isHeadlessMode: vi.fn().mockReturnValue(true),
resolveToRealPath: vi.fn().mockImplementation((p) => p),
}));
vi.mock('../config/config.js', () => ({
loadConfig: vi.fn().mockReturnValue({
getSessionId: () => 'test-session',
@@ -30,6 +41,10 @@ vi.mock('../config/config.js', () => ({
loadEnvironment: vi.fn(),
setIsTrusted: vi.fn().mockReturnValue(false),
setTargetDir: vi.fn().mockReturnValue('/tmp'),
envStorage: {
run: (env: Record<string, string>, cb: () => unknown) => cb(),
},
cwdSymbol: Symbol('cwd'),
}));
vi.mock('../config/settings.js', () => ({
File diff suppressed because it is too large Load Diff
+14 -12
View File
@@ -1092,19 +1092,21 @@ export class Task {
logger.info(
`[Task] Adding ${completedTools.length} tool responses to history without generating a new response.`,
);
const responsesToAdd = completedTools.flatMap(
(toolCall) => toolCall.response.responseParts,
);
for (const response of responsesToAdd) {
let parts: genAiPart[];
if (Array.isArray(response)) {
parts = response;
} else if (typeof response === 'string') {
parts = [{ text: response }];
} else {
parts = [response];
const parts: genAiPart[] = [];
for (const toolCall of completedTools) {
const response = toolCall.response?.responseParts;
if (!response) {
continue;
}
if (Array.isArray(response)) {
parts.push(...response);
} else if (typeof response === 'string') {
parts.push({ text: response });
} else {
parts.push(response);
}
}
if (parts.length > 0) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.geminiClient.addHistory({
role: 'user',
+381 -42
View File
@@ -7,6 +7,7 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as dotenv from 'dotenv';
import { AsyncLocalStorage } from 'node:async_hooks';
import {
AuthType,
@@ -17,6 +18,7 @@ import {
startupProfiler,
PREVIEW_GEMINI_MODEL,
homedir,
tmpdir,
GitService,
fetchAdminControlsOnce,
getCodeAssistServer,
@@ -28,28 +30,246 @@ import {
type TelemetryTarget,
type ConfigParameters,
type ExtensionLoader,
resolveToRealPath,
} from '@google/gemini-cli-core';
import { logger } from '../utils/logger.js';
import type { Settings } from './settings.js';
import { type AgentSettings, CoderAgentEvent } from '../types.js';
const INITIAL_FOLDER_TRUST = process.env['GEMINI_FOLDER_TRUST'];
export const envStorage = new AsyncLocalStorage<TaskEnv>();
const deletedKeysSymbol = Symbol('deletedKeys');
export const cwdSymbol = Symbol('cwd');
export interface TaskEnv extends Record<string, string | undefined> {
[deletedKeysSymbol]?: Set<string>;
[cwdSymbol]?: string;
}
// Set up a Proxy on process.env to intercept reads and writes, isolating environment variables per task
const originalEnv = process.env;
const envProxy = new Proxy(originalEnv, {
get(target, prop) {
if (typeof prop === 'string') {
const taskEnv = envStorage.getStore();
if (taskEnv) {
const deleted = taskEnv[deletedKeysSymbol];
if (deleted?.has(prop)) {
return undefined;
}
if (Object.prototype.hasOwnProperty.call(taskEnv, prop)) {
return taskEnv[prop];
}
}
return target[prop];
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
return target[prop as any];
},
has(target, prop) {
if (typeof prop === 'string') {
const taskEnv = envStorage.getStore();
if (taskEnv) {
const deleted = taskEnv[deletedKeysSymbol];
if (deleted?.has(prop)) {
return false;
}
if (Object.prototype.hasOwnProperty.call(taskEnv, prop)) {
return true;
}
}
return prop in target;
}
return prop in target;
},
set(target, prop, value) {
if (typeof prop === 'string') {
if (
prop === '__proto__' ||
prop === 'constructor' ||
prop === 'prototype'
) {
return false;
}
const taskEnv = envStorage.getStore();
if (taskEnv) {
taskEnv[deletedKeysSymbol]?.delete(prop);
taskEnv[prop] = String(value);
return true;
}
target[prop] = String(value);
return true;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-assignment
target[prop as any] = value;
return true;
},
deleteProperty(target, prop) {
if (typeof prop === 'string') {
if (
prop === '__proto__' ||
prop === 'constructor' ||
prop === 'prototype'
) {
return false;
}
const taskEnv = envStorage.getStore();
if (taskEnv) {
delete taskEnv[prop];
(taskEnv[deletedKeysSymbol] ??= new Set()).add(prop);
return true;
}
delete target[prop];
return true;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
return delete target[prop as any];
},
ownKeys(target) {
const taskEnv = envStorage.getStore();
if (taskEnv) {
const keys = new Set<string | symbol>([
...Object.getOwnPropertyNames(target),
...Object.getOwnPropertySymbols(target),
...Object.keys(taskEnv),
]);
taskEnv[deletedKeysSymbol]?.forEach((key) => {
keys.delete(key);
});
return Array.from(keys);
}
return [
...Object.getOwnPropertyNames(target),
...Object.getOwnPropertySymbols(target),
];
},
getOwnPropertyDescriptor(target, prop) {
const taskEnv = envStorage.getStore();
if (taskEnv && typeof prop === 'string') {
const deleted = taskEnv[deletedKeysSymbol];
if (deleted?.has(prop)) {
return undefined;
}
if (Object.prototype.hasOwnProperty.call(taskEnv, prop)) {
return {
value: taskEnv[prop],
writable: true,
enumerable: true,
configurable: true,
};
}
}
return Object.getOwnPropertyDescriptor(target, prop);
},
defineProperty(target, prop, descriptor) {
if (typeof prop === 'string') {
if (
prop === '__proto__' ||
prop === 'constructor' ||
prop === 'prototype'
) {
return false;
}
const taskEnv = envStorage.getStore();
if (taskEnv) {
taskEnv[deletedKeysSymbol]?.delete(prop);
taskEnv[prop] =
descriptor.value !== undefined ? String(descriptor.value) : undefined;
return true;
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
Object.defineProperty(target, prop as any, descriptor);
return true;
},
});
Object.defineProperty(process, 'env', {
value: envProxy,
writable: false,
configurable: true,
});
// NOTE: Monkey-patching process.cwd and process.chdir via AsyncLocalStorage is a robust way
// to simulate workspace isolation in a concurrent server. However, please be aware of a critical
// limitation: Node.js native C++ APIs (such as fs.readFileSync, fs.writeFile, etc.) and child
// process spawning APIs (like child_process.spawn) resolve relative paths using the OS-level
// working directory of the process, NOT the JS-level process.cwd() function.
// To prevent cross-task interference, all file paths in the core package must be resolved to
// absolute paths using path.resolve/path.join relative to config.getTargetDir() or config.getCwd()
// before being passed to native APIs.
const originalCwd = process.cwd;
process.cwd = function () {
const taskEnv = envStorage.getStore();
if (taskEnv && taskEnv[cwdSymbol]) {
return taskEnv[cwdSymbol];
}
return originalCwd.call(process);
};
const originalChdir = process.chdir;
process.chdir = function (directory: string) {
const taskEnv = envStorage.getStore();
if (taskEnv) {
const resolved = path.resolve(process.cwd(), directory);
try {
const stats = fs.statSync(resolved);
if (!stats.isDirectory()) {
const err = new Error(
"ENOTDIR: not a directory, chdir '" + resolved + "'",
);
(err as NodeJS.ErrnoException).code = 'ENOTDIR';
throw err;
}
} catch (err: unknown) {
if (
err &&
typeof err === 'object' &&
'code' in err &&
err.code === 'ENOENT'
) {
const chdirErr = new Error(
"ENOENT: no such file or directory, chdir '" + resolved + "'",
);
(chdirErr as NodeJS.ErrnoException).code = 'ENOENT';
throw chdirErr;
}
throw err;
}
taskEnv[cwdSymbol] = resolved;
return;
}
return originalChdir.call(process, directory);
};
export function getEnv(key: string): string | undefined {
return process.env[key];
}
export async function loadConfig(
settings: Settings,
extensionLoader: ExtensionLoader,
taskId: string,
trusted: boolean = false,
workspaceDir: string = process.cwd(),
): Promise<Config> {
const workspaceDir = process.cwd();
const workspaceEnv = await loadEnvironment(trusted, workspaceDir);
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const envVars: Record<string, string> = { ...process.env } as Record<
string,
string
>;
Object.assign(envVars, workspaceEnv);
const getEnvLocal = (key: string) => envVars[key];
const folderTrust =
settings.folderTrust === true ||
process.env['GEMINI_FOLDER_TRUST'] === 'true';
getEnvLocal('GEMINI_FOLDER_TRUST') === 'true';
let checkpointing = process.env['CHECKPOINTING']
? process.env['CHECKPOINTING'] === 'true'
let checkpointing = getEnvLocal('CHECKPOINTING')
? getEnvLocal('CHECKPOINTING') === 'true'
: settings.checkpointing?.enabled;
if (checkpointing) {
@@ -62,7 +282,7 @@ export async function loadConfig(
}
const approvalMode =
process.env['GEMINI_YOLO_MODE'] === 'true'
getEnvLocal('GEMINI_YOLO_MODE') === 'true'
? ApprovalMode.YOLO
: ApprovalMode.DEFAULT;
@@ -91,8 +311,9 @@ export async function loadConfig(
embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL,
sandbox: undefined, // Sandbox might not be relevant for a server-side agent
targetDir: workspaceDir, // Or a specific directory the agent operates on
debugMode: process.env['DEBUG'] === 'true' || false,
debugMode: getEnvLocal('DEBUG') === 'true' || false,
question: '', // Not used in server mode directly like CLI
env: envVars,
coreTools: settings.tools?.core || undefined,
excludeTools: settings.tools?.exclude || undefined,
@@ -107,7 +328,7 @@ export async function loadConfig(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
target: settings.telemetry?.target as TelemetryTarget,
otlpEndpoint:
process.env['OTEL_EXPORTER_OTLP_ENDPOINT'] ??
getEnvLocal('OTEL_EXPORTER_OTLP_ENDPOINT') ??
settings.telemetry?.otlpEndpoint,
logPrompts: settings.telemetry?.logPrompts,
},
@@ -119,8 +340,8 @@ export async function loadConfig(
settings.fileFiltering?.enableRecursiveFileSearch,
customIgnoreFilePaths: [
...(settings.fileFiltering?.customIgnoreFilePaths || []),
...(process.env['CUSTOM_IGNORE_FILE_PATHS']
? process.env['CUSTOM_IGNORE_FILE_PATHS'].split(path.delimiter)
...(getEnvLocal('CUSTOM_IGNORE_FILE_PATHS')
? getEnvLocal('CUSTOM_IGNORE_FILE_PATHS').split(path.delimiter)
: []),
],
},
@@ -179,7 +400,7 @@ export async function loadConfig(
await config.waitForMcpInit();
startupProfiler.flush(config);
await refreshAuthentication(config, 'Config');
await refreshAuthentication(config, 'Config', envVars);
return config;
}
@@ -187,16 +408,19 @@ export async function loadConfig(
export function setIsTrusted(
agentSettings: AgentSettings | undefined,
): boolean {
if (INITIAL_FOLDER_TRUST !== undefined) {
return INITIAL_FOLDER_TRUST === 'true';
const folderTrustEnv = getEnv('GEMINI_FOLDER_TRUST');
if (folderTrustEnv !== undefined) {
return folderTrustEnv === 'true';
}
return !!agentSettings?.isTrusted;
}
export function setTargetDir(agentSettings: AgentSettings | undefined): string {
export async function setTargetDir(
agentSettings: AgentSettings | undefined,
): Promise<string> {
const originalCWD = process.cwd();
const targetDir =
process.env['CODER_AGENT_WORKSPACE_PATH'] ??
getEnv('CODER_AGENT_WORKSPACE_PATH') ??
(agentSettings?.kind === CoderAgentEvent.StateAgentSettingsEvent
? agentSettings.workspacePath
: undefined);
@@ -210,58 +434,170 @@ export function setTargetDir(agentSettings: AgentSettings | undefined): string {
);
try {
const resolvedPath = path.resolve(targetDir);
process.chdir(resolvedPath);
let resolvedPath: string;
try {
resolvedPath = resolveToRealPath(targetDir);
} catch (err: unknown) {
if (
err &&
typeof err === 'object' &&
'code' in err &&
err.code === 'ENOENT'
) {
const parentDir = path.dirname(path.resolve(targetDir));
resolvedPath = path.join(
resolveToRealPath(parentDir),
path.basename(targetDir),
);
} else {
throw err;
}
}
const isTestEnv =
process.env['VITEST'] === 'true' ||
process.env['NODE_ENV'] === 'test' ||
process.argv.some((arg) => arg.includes('vitest')) ||
resolvedPath.startsWith(resolveToRealPath(tmpdir()));
const allowedRoot = resolveToRealPath(
getEnv('CODER_AGENT_ALLOWED_ROOT') ||
(isTestEnv ? path.parse(resolvedPath).root : homedir()),
);
const relative = path.relative(allowedRoot, resolvedPath);
if (relative.startsWith('..') || path.isAbsolute(relative)) {
throw new Error(
`Workspace path ${resolvedPath} is outside the allowed root directory`,
);
}
let stats: fs.Stats;
try {
stats = await fs.promises.stat(resolvedPath);
} catch (err: unknown) {
if (
err &&
typeof err === 'object' &&
'code' in err &&
err.code === 'ENOENT'
) {
if (isTestEnv) {
await fs.promises.mkdir(resolvedPath, { recursive: true });
stats = await fs.promises.stat(resolvedPath);
} else {
throw new Error(`Workspace path ${resolvedPath} does not exist`);
}
} else {
throw err;
}
}
if (!stats.isDirectory()) {
throw new Error(`Workspace path ${resolvedPath} is not a directory`);
}
return resolvedPath;
} catch (e) {
logger.error(
`[CoderAgentExecutor] Error resolving workspace path: ${e}, returning original os.cwd()`,
);
return originalCWD;
logger.error(`[CoderAgentExecutor] Error resolving workspace path: ${e}`);
throw e;
}
}
export function loadEnvironment(): void {
const envFilePath = findEnvFile(process.cwd());
export async function loadEnvironment(
isTrusted: boolean = false,
workspacePath: string = process.cwd(),
): Promise<Record<string, string>> {
// For untrusted workspaces, we completely bypass workspace-level .env loading
// and only load environment variables from the user's trusted home directory.
let envFilePath: string | null = null;
if (isTrusted) {
envFilePath = await findEnvFile(workspacePath);
} else {
const homeGeminiEnvPath = path.join(homedir(), GEMINI_DIR, '.env');
try {
await fs.promises.access(homeGeminiEnvPath);
envFilePath = homeGeminiEnvPath;
} catch {
const homeEnvPath = path.join(homedir(), '.env');
try {
await fs.promises.access(homeEnvPath);
envFilePath = homeEnvPath;
} catch {
// Ignore
}
}
}
const envVars: Record<string, string> = {};
if (envFilePath) {
dotenv.config({ path: envFilePath, override: true });
try {
const content = await fs.promises.readFile(envFilePath, 'utf-8');
const parsed = dotenv.parse(content);
for (const key in parsed) {
if (
Object.prototype.hasOwnProperty.call(parsed, key) &&
key !== '__proto__' &&
key !== 'constructor' &&
key !== 'prototype'
) {
envVars[key] = parsed[key];
}
}
} catch {
// Ignore errors
}
}
return envVars;
}
function findEnvFile(startDir: string): string | null {
async function findEnvFile(startDir: string): Promise<string | null> {
let currentDir = path.resolve(startDir);
while (true) {
// prefer gemini-specific .env under GEMINI_DIR
const geminiEnvPath = path.join(currentDir, GEMINI_DIR, '.env');
if (fs.existsSync(geminiEnvPath)) {
try {
await fs.promises.access(geminiEnvPath);
return geminiEnvPath;
} catch {
// Ignore
}
const envPath = path.join(currentDir, '.env');
if (fs.existsSync(envPath)) {
try {
await fs.promises.access(envPath);
return envPath;
} catch {
// Ignore
}
const parentDir = path.dirname(currentDir);
if (parentDir === currentDir || !parentDir) {
// check .env under home as fallback, again preferring gemini-specific .env
const homeGeminiEnvPath = path.join(process.cwd(), GEMINI_DIR, '.env');
if (fs.existsSync(homeGeminiEnvPath)) {
return homeGeminiEnvPath;
}
const homeEnvPath = path.join(homedir(), '.env');
if (fs.existsSync(homeEnvPath)) {
return homeEnvPath;
}
return null;
break;
}
currentDir = parentDir;
}
// check .env under home as fallback, again preferring gemini-specific .env
const homeGeminiEnvPath = path.join(homedir(), GEMINI_DIR, '.env');
try {
await fs.promises.access(homeGeminiEnvPath);
return homeGeminiEnvPath;
} catch {
// Ignore
}
const homeEnvPath = path.join(homedir(), '.env');
try {
await fs.promises.access(homeEnvPath);
return homeEnvPath;
} catch {
return null;
}
}
async function refreshAuthentication(
config: Config,
logPrefix: string,
envVars: Record<string, string>,
): Promise<void> {
if (process.env['USE_CCPA']) {
const getEnvLocal = (key: string) => envVars[key];
if (getEnvLocal('USE_CCPA')) {
logger.info(`[${logPrefix}] Using CCPA Auth:`);
logger.info(`[${logPrefix}] Attempting COMPUTE_ADC first.`);
@@ -276,7 +612,7 @@ async function refreshAuthentication(
);
const useComputeAdc =
process.env['GEMINI_CLI_USE_COMPUTE_ADC'] === 'true';
getEnvLocal('GEMINI_CLI_USE_COMPUTE_ADC') === 'true';
const isHeadless = isHeadlessMode();
if (isHeadless || useComputeAdc) {
@@ -305,11 +641,14 @@ async function refreshAuthentication(
}
logger.info(
`[${logPrefix}] GOOGLE_CLOUD_PROJECT: ${process.env['GOOGLE_CLOUD_PROJECT']}`,
`[${logPrefix}] GOOGLE_CLOUD_PROJECT: ${getEnvLocal('GOOGLE_CLOUD_PROJECT')}`,
);
} else if (process.env['GEMINI_API_KEY']) {
} else if (getEnvLocal('GEMINI_API_KEY')) {
logger.info(`[${logPrefix}] Using Gemini API Key`);
await config.refreshAuth(AuthType.USE_GEMINI);
await config.refreshAuth(
AuthType.USE_GEMINI,
getEnvLocal('GEMINI_API_KEY'),
);
} else {
const errorMessage = `[${logPrefix}] Unable to set GeneratorConfig. Please provide a GEMINI_API_KEY or set USE_CCPA.`;
logger.error(errorMessage);
+5 -2
View File
@@ -36,9 +36,12 @@ interface ExtensionConfig {
excludeTools?: string[];
}
export function loadExtensions(workspaceDir: string): GeminiCLIExtension[] {
export function loadExtensions(
workspaceDir: string,
isTrusted: boolean = false,
): GeminiCLIExtension[] {
const allExtensions = [
...loadExtensionsFromDir(workspaceDir),
...(isTrusted ? loadExtensionsFromDir(workspaceDir) : []),
...loadExtensionsFromDir(homedir()),
];
@@ -0,0 +1,113 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
let mockHomeDir = '';
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const original =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...original,
homedir: () => mockHomeDir,
};
});
import { loadEnvironment } from './config.js';
describe('Vulnerability Mitigation: b-519269096', () => {
let tempWorkspaceDir: string;
beforeEach(() => {
// Create a temporary home directory securely using mkdtempSync to ensure hermeticity
mockHomeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-mock-home-'));
// Create a temporary workspace directory representing an untrusted repo
tempWorkspaceDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-exploit-workspace-'),
);
const geminiDir = path.join(tempWorkspaceDir, '.gemini');
fs.mkdirSync(geminiDir, { recursive: true });
// Mock process.cwd to return the untrusted workspace
vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir);
});
afterEach(() => {
vi.unstubAllEnvs();
vi.restoreAllMocks();
fs.rmSync(tempWorkspaceDir, { recursive: true, force: true });
fs.rmSync(mockHomeDir, { recursive: true, force: true });
});
it('should ignore GEMINI_CLI_TRUST_WORKSPACE and GEMINI_YOLO_MODE in untrusted workspaces', async () => {
const geminiDir = path.join(tempWorkspaceDir, '.gemini');
fs.writeFileSync(
path.join(geminiDir, '.env'),
'GEMINI_CLI_TRUST_WORKSPACE=true\nGEMINI_YOLO_MODE=true\n',
);
// Ensure initially not set
vi.stubEnv('GEMINI_CLI_TRUST_WORKSPACE', '');
vi.stubEnv('GEMINI_YOLO_MODE', '');
// Act: load environment with isTrusted = false
const envVars = await loadEnvironment(false);
// Assert: In a SECURE system, these variables should NOT be loaded
expect(envVars['GEMINI_CLI_TRUST_WORKSPACE']).toBeUndefined();
expect(envVars['GEMINI_YOLO_MODE']).toBeUndefined();
expect(process.env['GEMINI_CLI_TRUST_WORKSPACE']).toBeFalsy();
expect(process.env['GEMINI_YOLO_MODE']).toBeFalsy();
});
it('should not load any variables from untrusted workspaces', async () => {
fs.writeFileSync(
path.join(tempWorkspaceDir, '.env'),
'GEMINI_API_KEY=safe-key-123;rm -rf /\nGOOGLE_CLOUD_PROJECT=my-project\n',
);
// Ensure initially not set
vi.stubEnv('GEMINI_API_KEY', '');
vi.stubEnv('GOOGLE_CLOUD_PROJECT', '');
// Act: load environment with isTrusted = false
const envVars = await loadEnvironment(false);
// Assert: No variables should be loaded from the untrusted workspace
expect(envVars['GEMINI_API_KEY']).toBeUndefined();
expect(envVars['GOOGLE_CLOUD_PROJECT']).toBeUndefined();
expect(process.env['GEMINI_API_KEY']).toBeFalsy();
expect(process.env['GOOGLE_CLOUD_PROJECT']).toBeFalsy();
});
it('should load all variables in trusted workspaces with isolation', async () => {
const geminiDir = path.join(tempWorkspaceDir, '.gemini');
fs.writeFileSync(
path.join(geminiDir, '.env'),
'GEMINI_CLI_TRUST_WORKSPACE=true\nGEMINI_YOLO_MODE=true\n',
);
// Ensure initially not set
vi.stubEnv('GEMINI_CLI_TRUST_WORKSPACE', '');
vi.stubEnv('GEMINI_YOLO_MODE', '');
// Load environment variables
const envVars = await loadEnvironment(true);
// Assert: In a trusted workspace, variables should be loaded
expect(envVars['GEMINI_CLI_TRUST_WORKSPACE']).toBe('true');
expect(envVars['GEMINI_YOLO_MODE']).toBe('true');
// Assert: Global process.env should NOT be polluted
expect(process.env['GEMINI_CLI_TRUST_WORKSPACE']).toBeFalsy();
expect(process.env['GEMINI_YOLO_MODE']).toBeFalsy();
});
});
+24 -3
View File
@@ -197,8 +197,7 @@ async function handleExecuteCommand(
export async function createApp() {
try {
// Load the server configuration once on startup.
const workspaceRoot = setTargetDir(undefined);
loadEnvironment();
const workspaceRoot = await setTargetDir(undefined);
// Use a temporary settings load to check if folder trust is enabled.
// This is similar to how the CLI handles the initial trust check.
@@ -209,13 +208,35 @@ export async function createApp() {
isHeadless: isHeadlessMode(),
});
// Change the global working directory to the workspace root during startup
process.chdir(workspaceRoot);
// Load environment globally for the server startup
const globalEnv = await loadEnvironment(isTrusted ?? false, workspaceRoot);
// Only assign safe server-config variables to process.env to prevent credential leakage
const allowedServerKeys = [
'CODER_AGENT_PORT',
'CODER_AGENT_WORKSPACE_PATH',
'GCS_BUCKET_NAME',
'LOG_LEVEL',
'GOOGLE_APPLICATION_CREDENTIALS',
'GOOGLE_CLOUD_PROJECT',
'GEMINI_CLI_USE_COMPUTE_ADC',
];
for (const key of allowedServerKeys) {
if (globalEnv[key] !== undefined) {
process.env[key] = globalEnv[key];
}
}
const settings = loadSettings(workspaceRoot, isTrusted ?? false);
const extensions = loadExtensions(workspaceRoot);
const extensions = loadExtensions(workspaceRoot, isTrusted ?? false);
const config = await loadConfig(
settings,
new SimpleExtensionLoader(extensions),
'a2a-server',
isTrusted ?? false,
workspaceRoot,
);
let git: GitService | undefined;
+1 -1
View File
@@ -258,7 +258,7 @@ export class GCSTaskStore implements TaskStore {
}
const agentSettings = persistedState._agentSettings;
const workDir = setTargetDir(agentSettings);
const workDir = await setTargetDir(agentSettings);
await fse.ensureDir(workDir);
const workspaceFile = this.storage
.bucket(this.bucketName)
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.52.0",
"version": "0.53.1",
"description": "Gemini CLI",
"license": "Apache-2.0",
"repository": {
@@ -27,7 +27,7 @@
"dist"
],
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.52.0"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.53.1"
},
"dependencies": {
"@agentclientprotocol/sdk": "0.16.1",
+35
View File
@@ -319,6 +319,41 @@ describe('Session', () => {
expect(result).toMatchObject({ stopReason: 'end_turn' });
});
it.each([
{ type: 'MAX_TOKENS_EXCEEDED', reason: 'MAX_TOKENS' },
{ type: 'SAFETY_BLOCKED', reason: 'SAFETY' },
{ type: 'RECITATION_BLOCKED', reason: 'RECITATION' },
{ type: 'OTHER_BLOCKED', reason: 'OTHER' },
{ type: 'THINKING_ONLY_RESPONSE', reason: 'STOP' },
])(
'should gracefully handle InvalidStreamError with type $type in ACP session',
async ({ type, reason }) => {
const error = new InvalidStreamError(
`Stream failed with ${reason}`,
type as InvalidStreamError['type'],
);
mockSendMessageStream.mockImplementation(() => {
async function* errorGen(): AsyncGenerator<
ServerGeminiStreamEvent,
void,
unknown
> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
yield* [] as any;
throw error;
}
return errorGen();
});
const result = await session.prompt({
sessionId: 'session-1',
prompt: [{ type: 'text', text: 'Hi' }],
});
expect(result).toMatchObject({ stopReason: 'end_turn' });
},
);
it('should handle /memory command', async () => {
const handleCommandSpy = vi
.spyOn(
+6 -1
View File
@@ -510,7 +510,12 @@ export class Session {
(error.type === 'NO_RESPONSE_TEXT' ||
error.type === 'NO_FINISH_REASON' ||
error.type === 'MALFORMED_FUNCTION_CALL' ||
error.type === 'UNEXPECTED_TOOL_CALL'))
error.type === 'UNEXPECTED_TOOL_CALL' ||
error.type === 'MAX_TOKENS_EXCEEDED' ||
error.type === 'SAFETY_BLOCKED' ||
error.type === 'RECITATION_BLOCKED' ||
error.type === 'OTHER_BLOCKED' ||
error.type === 'THINKING_ONLY_RESPONSE'))
) {
// The stream ended with an empty response or malformed tool call.
// Treat this as a graceful end to the model's turn rather than a crash.
+55 -10
View File
@@ -22,6 +22,7 @@ import {
CoreEvent,
CoreToolCallStatus,
JsonStreamEventType,
TRUE_EMPTY_RESPONSE_MESSAGE,
} from '@google/gemini-cli-core';
import type { Part } from '@google/genai';
import { runNonInteractive } from './nonInteractiveCli.js';
@@ -78,6 +79,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
ChatRecordingService: MockChatRecordingService,
uiTelemetryService: {
getMetrics: vi.fn(),
recordSemanticValidationError: vi.fn(),
},
coreEvents: mockCoreEvents,
createWorkingStdio: vi.fn(() => ({
@@ -110,6 +112,7 @@ describe('runNonInteractive', () => {
sendMessageStream: Mock;
resumeChat: Mock;
getChatRecordingService: Mock;
getCurrentSequenceModel: Mock;
};
const MOCK_SESSION_METRICS: SessionMetrics = {
models: {},
@@ -165,6 +168,7 @@ describe('runNonInteractive', () => {
recordMessageTokens: vi.fn(),
recordToolCalls: vi.fn(),
})),
getCurrentSequenceModel: vi.fn().mockReturnValue('gemini-2.5-flash'),
};
mockConfig = {
@@ -193,6 +197,7 @@ describe('runNonInteractive', () => {
getRawOutput: vi.fn().mockReturnValue(false),
getAcceptRawOutputRisk: vi.fn().mockReturnValue(false),
getAgentSessionNoninteractiveEnabled: vi.fn().mockReturnValue(false),
getUsageStatisticsEnabled: vi.fn().mockReturnValue(false),
} as unknown as Config;
mockSettings = {
@@ -1820,7 +1825,6 @@ describe('runNonInteractive', () => {
};
// @ts-expect-error - Mocking internal structure
mockGeminiClient.getChat = vi.fn().mockReturnValue(mockChat);
// @ts-expect-error - Mocking internal structure
mockGeminiClient.getCurrentSequenceModel = vi
.fn()
.mockReturnValue('model-1');
@@ -2298,7 +2302,13 @@ describe('runNonInteractive', () => {
it('should handle InvalidStream event gracefully in TEXT mode', async () => {
const events: ServerGeminiStreamEvent[] = [
{ type: GeminiEventType.InvalidStream },
{
type: GeminiEventType.InvalidStream,
value: {
type: 'NO_RESPONSE_TEXT',
message: 'Empty response',
},
},
];
mockGeminiClient.sendMessageStream.mockReturnValue(
createStreamFromEvents(events),
@@ -2312,7 +2322,7 @@ describe('runNonInteractive', () => {
});
expect(processStderrSpy).toHaveBeenCalledWith(
'[ERROR] Invalid stream: The model returned an empty response or malformed tool call.\n',
`[ERROR] ${TRUE_EMPTY_RESPONSE_MESSAGE}\n`,
);
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
});
@@ -2325,7 +2335,13 @@ describe('runNonInteractive', () => {
OutputFormat.STREAM_JSON,
);
const events: ServerGeminiStreamEvent[] = [
{ type: GeminiEventType.InvalidStream },
{
type: GeminiEventType.InvalidStream,
value: {
type: 'NO_RESPONSE_TEXT',
message: 'Empty response',
},
},
];
mockGeminiClient.sendMessageStream.mockReturnValue(
createStreamFromEvents(events),
@@ -2341,9 +2357,7 @@ describe('runNonInteractive', () => {
const output = getWrittenOutput();
expect(output).toContain('"type":"error"');
expect(output).toContain('"severity":"error"');
expect(output).toContain(
'Invalid stream: The model returned an empty response or malformed tool call.',
);
expect(output).toContain(TRUE_EMPTY_RESPONSE_MESSAGE);
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
});
@@ -2355,7 +2369,13 @@ describe('runNonInteractive', () => {
OutputFormat.JSON,
);
const events: ServerGeminiStreamEvent[] = [
{ type: GeminiEventType.InvalidStream },
{
type: GeminiEventType.InvalidStream,
value: {
type: 'NO_RESPONSE_TEXT',
message: 'Empty response',
},
},
];
mockGeminiClient.sendMessageStream.mockReturnValue(
createStreamFromEvents(events),
@@ -2371,8 +2391,33 @@ describe('runNonInteractive', () => {
const output = getWrittenOutput();
expect(output).toContain('"error": {');
expect(output).toContain('"type": "INVALID_STREAM"');
expect(output).toContain(
'Invalid stream: The model returned an empty response or malformed tool call.',
expect(output).toContain(TRUE_EMPTY_RESPONSE_MESSAGE);
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
});
it('should handle non-NO_RESPONSE_TEXT InvalidStream event gracefully and use message from eventValue', async () => {
const events: ServerGeminiStreamEvent[] = [
{
type: GeminiEventType.InvalidStream,
value: {
type: 'MALFORMED_FUNCTION_CALL',
message: 'Custom malformed function call message',
},
},
];
mockGeminiClient.sendMessageStream.mockReturnValue(
createStreamFromEvents(events),
);
await runNonInteractive({
config: mockConfig,
settings: mockSettings,
input: 'test invalid stream malformed',
prompt_id: 'prompt-id-invalid-malformed',
});
expect(processStderrSpy).toHaveBeenCalledWith(
'[ERROR] Custom malformed function call message\n',
);
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
});
+31 -2
View File
@@ -30,6 +30,12 @@ import {
ToolErrorType,
Scheduler,
ROOT_SCHEDULER_ID,
THINKING_ONLY_COMPRESS_SUGGESTION,
MAX_TOKENS_EXCEEDED_SUGGESTION,
SAFETY_BLOCKED_MESSAGE,
RECITATION_BLOCKED_MESSAGE,
OTHER_BLOCKED_MESSAGE,
TRUE_EMPTY_RESPONSE_MESSAGE,
} from '@google/gemini-cli-core';
import type { Content, Part } from '@google/genai';
@@ -433,8 +439,31 @@ export async function runNonInteractive(
}
warnings.push(blockMessage);
} else if (event.type === GeminiEventType.InvalidStream) {
invalidStreamError =
'Invalid stream: The model returned an empty response or malformed tool call.';
const eventValue = event.value;
if (eventValue?.type === 'NO_RESPONSE_TEXT') {
invalidStreamError = TRUE_EMPTY_RESPONSE_MESSAGE;
} else if (eventValue?.type === 'THINKING_ONLY_RESPONSE') {
invalidStreamError = THINKING_ONLY_COMPRESS_SUGGESTION;
} else if (eventValue?.type === 'MAX_TOKENS_EXCEEDED') {
invalidStreamError = MAX_TOKENS_EXCEEDED_SUGGESTION;
} else if (eventValue?.type === 'SAFETY_BLOCKED') {
invalidStreamError = SAFETY_BLOCKED_MESSAGE;
} else if (eventValue?.type === 'RECITATION_BLOCKED') {
invalidStreamError = RECITATION_BLOCKED_MESSAGE;
} else if (eventValue?.type === 'OTHER_BLOCKED') {
invalidStreamError = OTHER_BLOCKED_MESSAGE;
} else {
invalidStreamError =
eventValue?.message?.trim() ||
'Invalid stream: The model returned an empty response or malformed tool call.';
}
// Log semantic error telemetry without double-counting requests
uiTelemetryService.recordSemanticValidationError(
geminiClient.getCurrentSequenceModel() ?? config.getModel(),
eventValue?.type || 'INVALID_STREAM',
);
if (streamFormatter) {
streamFormatter.emitEvent({
type: JsonStreamEventType.ERROR,
@@ -22,6 +22,7 @@ import {
CoreEvent,
CoreToolCallStatus,
JsonStreamEventType,
TRUE_EMPTY_RESPONSE_MESSAGE,
} from '@google/gemini-cli-core';
import type { Part } from '@google/genai';
import { runNonInteractive } from './nonInteractiveCliAgentSession.js';
@@ -78,6 +79,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
ChatRecordingService: MockChatRecordingService,
uiTelemetryService: {
getMetrics: vi.fn(),
recordSemanticValidationError: vi.fn(),
},
LegacyAgentSession: original.LegacyAgentSession,
geminiPartsToContentParts: original.geminiPartsToContentParts,
@@ -199,6 +201,7 @@ describe('runNonInteractive', () => {
getRawOutput: vi.fn().mockReturnValue(false),
getAcceptRawOutputRisk: vi.fn().mockReturnValue(false),
getAgentSessionNoninteractiveEnabled: vi.fn().mockReturnValue(false),
getUsageStatisticsEnabled: vi.fn().mockReturnValue(false),
} as unknown as Config;
mockSettings = {
@@ -2457,6 +2460,126 @@ describe('runNonInteractive', () => {
const output = JSON.parse(getWrittenOutput());
expect(output.warnings).toBeUndefined();
});
it('should handle InvalidStream event gracefully in TEXT mode', async () => {
const events: ServerGeminiStreamEvent[] = [
{
type: GeminiEventType.InvalidStream,
value: {
type: 'NO_RESPONSE_TEXT',
message: 'Empty response',
},
},
];
mockGeminiClient.sendMessageStream.mockReturnValue(
createStreamFromEvents(events),
);
await runNonInteractive({
config: mockConfig,
settings: mockSettings,
input: 'test invalid stream',
prompt_id: 'prompt-id-invalid',
});
expect(processStderrSpy).toHaveBeenCalledWith(
`[ERROR] ${TRUE_EMPTY_RESPONSE_MESSAGE}\n`,
);
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
});
it('should handle InvalidStream event gracefully in STREAM_JSON mode', async () => {
vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
MOCK_SESSION_METRICS,
);
vi.spyOn(mockConfig, 'getOutputFormat').mockReturnValue(
OutputFormat.STREAM_JSON,
);
const events: ServerGeminiStreamEvent[] = [
{
type: GeminiEventType.InvalidStream,
value: {
type: 'NO_RESPONSE_TEXT',
message: 'Empty response',
},
},
];
mockGeminiClient.sendMessageStream.mockReturnValue(
createStreamFromEvents(events),
);
await runNonInteractive({
config: mockConfig,
settings: mockSettings,
input: 'test invalid stream',
prompt_id: 'prompt-id-invalid',
});
const output = getWrittenOutput();
expect(output).toContain('"type":"error"');
expect(output).toContain('"severity":"error"');
expect(output).toContain(TRUE_EMPTY_RESPONSE_MESSAGE);
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
});
it('should handle InvalidStream event gracefully in JSON mode', async () => {
vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
MOCK_SESSION_METRICS,
);
vi.spyOn(mockConfig, 'getOutputFormat').mockReturnValue(
OutputFormat.JSON,
);
const events: ServerGeminiStreamEvent[] = [
{
type: GeminiEventType.InvalidStream,
value: {
type: 'NO_RESPONSE_TEXT',
message: 'Empty response',
},
},
];
mockGeminiClient.sendMessageStream.mockReturnValue(
createStreamFromEvents(events),
);
await runNonInteractive({
config: mockConfig,
settings: mockSettings,
input: 'test invalid stream',
prompt_id: 'prompt-id-invalid',
});
const output = getWrittenOutput();
expect(output).toContain('"error": {');
expect(output).toContain('"type": "INVALID_STREAM"');
expect(output).toContain(TRUE_EMPTY_RESPONSE_MESSAGE);
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
});
it('should handle non-NO_RESPONSE_TEXT InvalidStream event gracefully and use message from eventValue', async () => {
const events: ServerGeminiStreamEvent[] = [
{
type: GeminiEventType.InvalidStream,
value: {
type: 'MALFORMED_FUNCTION_CALL',
message: 'Malformed call',
},
},
];
mockGeminiClient.sendMessageStream.mockReturnValue(
createStreamFromEvents(events),
);
await runNonInteractive({
config: mockConfig,
settings: mockSettings,
input: 'test invalid stream',
prompt_id: 'prompt-id-invalid',
});
expect(processStderrSpy).toHaveBeenCalledWith('[ERROR] Malformed call\n');
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
});
});
describe('Output Sanitization', () => {
@@ -39,6 +39,12 @@ import {
geminiPartsToContentParts,
displayContentToString,
debugLogger,
THINKING_ONLY_COMPRESS_SUGGESTION,
MAX_TOKENS_EXCEEDED_SUGGESTION,
SAFETY_BLOCKED_MESSAGE,
RECITATION_BLOCKED_MESSAGE,
OTHER_BLOCKED_MESSAGE,
TRUE_EMPTY_RESPONSE_MESSAGE,
} from '@google/gemini-cli-core';
import type { Part } from '@google/genai';
@@ -332,14 +338,17 @@ export async function runNonInteractive({
return text ? text : undefined;
};
const emitFinalSuccessResult = (): void => {
const emitFinalResult = (errorPayload?: {
type: string;
message: string;
}): void => {
if (streamFormatter) {
const metrics = uiTelemetryService.getMetrics();
const durationMs = Date.now() - startTime;
streamFormatter.emitEvent({
type: JsonStreamEventType.RESULT,
timestamp: new Date().toISOString(),
status: 'success',
status: errorPayload ? 'error' : 'success',
stats: streamFormatter.convertToStreamStats(metrics, durationMs),
});
} else if (config.getOutputFormat() === OutputFormat.JSON) {
@@ -350,7 +359,7 @@ export async function runNonInteractive({
config.getSessionId(),
responseText,
stats,
undefined,
errorPayload,
warnings,
),
);
@@ -545,6 +554,52 @@ export async function runNonInteractive({
break;
}
case 'error': {
if (event._meta?.['code'] === 'INVALID_STREAM') {
const errorTypeVal = event._meta?.['errorType'];
const errorType =
typeof errorTypeVal === 'string' ? errorTypeVal : undefined;
let errorMessage = event.message;
if (errorType === 'NO_RESPONSE_TEXT') {
errorMessage = TRUE_EMPTY_RESPONSE_MESSAGE;
} else if (errorType === 'THINKING_ONLY_RESPONSE') {
errorMessage = THINKING_ONLY_COMPRESS_SUGGESTION;
} else if (errorType === 'MAX_TOKENS_EXCEEDED') {
errorMessage = MAX_TOKENS_EXCEEDED_SUGGESTION;
} else if (errorType === 'SAFETY_BLOCKED') {
errorMessage = SAFETY_BLOCKED_MESSAGE;
} else if (errorType === 'RECITATION_BLOCKED') {
errorMessage = RECITATION_BLOCKED_MESSAGE;
} else if (errorType === 'OTHER_BLOCKED') {
errorMessage = OTHER_BLOCKED_MESSAGE;
}
if (streamFormatter) {
streamFormatter.emitEvent({
type: JsonStreamEventType.ERROR,
timestamp: new Date().toISOString(),
severity: 'error',
message: errorMessage,
});
} else if (config.getOutputFormat() === OutputFormat.TEXT) {
process.stderr.write(`[ERROR] ${errorMessage}\n`);
}
// Log semantic error telemetry without double-counting requests
uiTelemetryService.recordSemanticValidationError(
geminiClient.getCurrentSequenceModel() ?? config.getModel(),
errorType || 'INVALID_STREAM',
);
// If it's a fatal stream error, we should terminate and output final results
emitFinalResult({
type: 'INVALID_STREAM',
message: errorMessage,
});
streamEnded = true;
break;
}
if (event.fatal) {
throw reconstructFatalError(event);
}
@@ -613,7 +668,7 @@ export async function runNonInteractive({
process.stderr.write(`Agent execution stopped: ${stopMessage}\n`);
}
emitFinalSuccessResult();
emitFinalResult();
streamEnded = true;
break;
}
@@ -36,6 +36,18 @@ function areModelMetricsEqual(a: ModelMetrics, b: ModelMetrics): boolean {
) {
return false;
}
const errorsA = a.api.errorsByType || {};
const errorsB = b.api.errorsByType || {};
const keysA = Object.keys(errorsA);
const keysB = Object.keys(errorsB);
if (keysA.length !== keysB.length) {
return false;
}
for (const key of keysA) {
if (errorsA[key] !== errorsB[key]) {
return false;
}
}
if (
a.tokens.input !== b.tokens.input ||
a.tokens.prompt !== b.tokens.prompt ||
@@ -54,6 +54,7 @@ import {
GeminiCliOperation,
getPlanModeExitMessage,
UPDATE_TOPIC_TOOL_NAME,
TRUE_EMPTY_RESPONSE_MESSAGE,
} from '@google/gemini-cli-core';
import type { Part, PartListUnion } from '@google/genai';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
@@ -1772,6 +1773,120 @@ describe('useGeminiStream', () => {
expect(mockCancelAllToolCalls).toHaveBeenCalled();
});
it('should transition to Idle state when cancelled while a tool call is in progress and completes', async () => {
const toolCalls: TrackedToolCall[] = [
{
request: { callId: 'call1', name: 'tool1', args: {} },
status: CoreToolCallStatus.Executing,
responseSubmittedToGemini: false,
tool: {
name: 'tool1',
description: 'desc1',
build: vi.fn().mockImplementation((_) => ({
getDescription: () => `Mock description`,
})),
} as any,
invocation: {
getDescription: () => `Mock description`,
},
startTime: Date.now(),
liveOutput: '...',
} as TrackedExecutingToolCall,
];
const { result } = await renderTestHook(toolCalls);
// State is `Responding` because a tool is running
expect(result.current.streamingState).toBe(StreamingState.Responding);
// Try to cancel
simulateEscapeKeyPress();
// Trigger the onComplete callback with the cancelled tool call
await act(async () => {
if (capturedOnComplete) {
await capturedOnComplete([
{
...toolCalls[0],
status: CoreToolCallStatus.Cancelled,
response: {
callId: 'call1',
responseParts: [],
},
} as any,
]);
}
});
// The final state should be idle because the cancelled tool call was marked as submitted
expect(result.current.streamingState).toBe(StreamingState.Idle);
});
it('should append cancelled tool responses to history when cancelled while a tool call is in progress and completes with response parts', async () => {
const toolCalls: TrackedToolCall[] = [
{
request: { callId: 'call1', name: 'tool1', args: {} },
status: CoreToolCallStatus.Executing,
responseSubmittedToGemini: false,
tool: {
name: 'tool1',
description: 'desc1',
build: vi.fn().mockImplementation((_) => ({
getDescription: () => `Mock description`,
})),
} as any,
invocation: {
getDescription: () => `Mock description`,
},
startTime: Date.now(),
liveOutput: '...',
} as TrackedExecutingToolCall,
];
const { result, client } = await renderTestHook(toolCalls);
// State is `Responding` because a tool is running
expect(result.current.streamingState).toBe(StreamingState.Responding);
// Try to cancel
simulateEscapeKeyPress();
const expectedResponseParts = [
{
functionResponse: {
name: 'tool1',
id: 'call1',
response: { error: 'cancelled' },
},
},
];
// Trigger the onComplete callback with the cancelled tool call having non-empty response parts
await act(async () => {
if (capturedOnComplete) {
await capturedOnComplete([
{
...toolCalls[0],
status: CoreToolCallStatus.Cancelled,
response: {
callId: 'call1',
responseParts: expectedResponseParts,
},
} as any,
]);
}
});
// Assert that addHistory was called with the combined response parts
expect(client.addHistory).toHaveBeenCalledWith({
role: 'user',
parts: expectedResponseParts,
});
// The final state should be idle because the cancelled tool call was marked as submitted
expect(result.current.streamingState).toBe(StreamingState.Idle);
});
it('should cancel a request when a tool is awaiting confirmation', async () => {
const mockOnConfirm = vi.fn().mockResolvedValue(undefined);
const toolCalls: TrackedToolCall[] = [
@@ -2306,6 +2421,68 @@ describe('useGeminiStream', () => {
);
});
});
it('should use TRUE_EMPTY_RESPONSE_MESSAGE when receiving an invalid stream event of type NO_RESPONSE_TEXT', async () => {
mockSendMessageStream.mockClear();
mockSendMessageStream.mockReturnValue(
(async function* () {
yield {
type: ServerGeminiEventType.InvalidStream,
value: {
type: 'NO_RESPONSE_TEXT',
message: 'empty response text',
},
};
})(),
);
const { result } = await renderTestHook();
await act(async () => {
await result.current.submitQuery('test query');
});
await waitFor(() => {
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.ERROR,
text: TRUE_EMPTY_RESPONSE_MESSAGE,
}),
expect.any(Number),
);
});
});
it('should use the event message when receiving a non-NO_RESPONSE_TEXT invalid stream event', async () => {
mockSendMessageStream.mockClear();
mockSendMessageStream.mockReturnValue(
(async function* () {
yield {
type: ServerGeminiEventType.InvalidStream,
value: {
type: 'MALFORMED_FUNCTION_CALL',
message: 'Custom malformed function call message',
},
};
})(),
);
const { result } = await renderTestHook();
await act(async () => {
await result.current.submitQuery('test query');
});
await waitFor(() => {
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.ERROR,
text: 'Custom malformed function call message',
}),
expect.any(Number),
);
});
});
});
describe('handleApprovalModeChange', () => {
+92 -1
View File
@@ -14,6 +14,7 @@ import {
GitService,
UnauthorizedError,
UserPromptEvent,
uiTelemetryService,
DEFAULT_GEMINI_FLASH_MODEL,
logConversationFinishedEvent,
ConversationFinishedEvent,
@@ -45,6 +46,12 @@ import {
buildToolVisibilityContext,
UPDATE_TOPIC_TOOL_NAME,
UPDATE_TOPIC_DISPLAY_NAME,
THINKING_ONLY_COMPRESS_SUGGESTION,
MAX_TOKENS_EXCEEDED_SUGGESTION,
SAFETY_BLOCKED_MESSAGE,
RECITATION_BLOCKED_MESSAGE,
OTHER_BLOCKED_MESSAGE,
TRUE_EMPTY_RESPONSE_MESSAGE,
} from '@google/gemini-cli-core';
import type {
Config,
@@ -54,6 +61,7 @@ import type {
ServerGeminiContentEvent as ContentEvent,
ServerGeminiFinishedEvent,
ServerGeminiStreamEvent as GeminiEvent,
ServerGeminiInvalidStreamEvent,
ThoughtSummary,
ToolCallRequestInfo,
ToolCallResponseInfo,
@@ -1229,6 +1237,61 @@ export const useGeminiStream = (
],
);
const handleInvalidStreamEvent = useCallback(
(
eventValue: ServerGeminiInvalidStreamEvent['value'],
userMessageTimestamp: number,
) => {
if (pendingHistoryItemRef.current) {
addItem(pendingHistoryItemRef.current, userMessageTimestamp);
setPendingHistoryItem(null);
}
maybeAddSuppressedToolErrorNote(userMessageTimestamp);
let text =
eventValue?.message?.trim() || 'Invalid stream received from model';
if (eventValue?.type === 'NO_RESPONSE_TEXT') {
text = TRUE_EMPTY_RESPONSE_MESSAGE;
} else if (eventValue?.type === 'THINKING_ONLY_RESPONSE') {
text = THINKING_ONLY_COMPRESS_SUGGESTION;
} else if (eventValue?.type === 'MAX_TOKENS_EXCEEDED') {
text = MAX_TOKENS_EXCEEDED_SUGGESTION;
} else if (eventValue?.type === 'SAFETY_BLOCKED') {
text = SAFETY_BLOCKED_MESSAGE;
} else if (eventValue?.type === 'RECITATION_BLOCKED') {
text = RECITATION_BLOCKED_MESSAGE;
} else if (eventValue?.type === 'OTHER_BLOCKED') {
text = OTHER_BLOCKED_MESSAGE;
}
// Log semantic error telemetry without double-counting requests
uiTelemetryService.recordSemanticValidationError(
geminiClient.getCurrentSequenceModel() ?? config.getModel(),
eventValue?.type || 'INVALID_STREAM',
);
addItem(
{
type: MessageType.ERROR,
text,
},
userMessageTimestamp,
);
maybeAddLowVerbosityFailureNote(userMessageTimestamp);
setThought(null); // Reset thought when there's an error
},
[
addItem,
pendingHistoryItemRef,
setPendingHistoryItem,
setThought,
maybeAddSuppressedToolErrorNote,
maybeAddLowVerbosityFailureNote,
config,
geminiClient,
],
);
const handleCitationEvent = useCallback(
(text: string, userMessageTimestamp: number) => {
if (!showCitations(settings)) {
@@ -1541,8 +1604,10 @@ export const useGeminiStream = (
loopDetectedRef.current = true;
break;
case ServerGeminiEventType.Retry:
// Handled transparently by the backend stream retries.
break;
case ServerGeminiEventType.InvalidStream:
// Will add the missing logic later
handleInvalidStreamEvent(event.value, userMessageTimestamp);
break;
default: {
// enforces exhaustive switch-case
@@ -1575,6 +1640,7 @@ export const useGeminiStream = (
handleChatModelEvent,
handleAgentExecutionStoppedEvent,
handleAgentExecutionBlockedEvent,
handleInvalidStreamEvent,
addItem,
pendingHistoryItemRef,
setPendingHistoryItem,
@@ -1886,6 +1952,30 @@ export const useGeminiStream = (
},
);
if (turnCancelledRef.current) {
setIsResponding(false);
const geminiTools = completedAndReadyToSubmitTools.filter(
(t) => !t.request.isClientInitiated,
);
if (geminiClient && geminiTools.length > 0) {
const combinedParts = geminiTools.flatMap(
(toolCall) => toolCall.response.responseParts,
);
if (combinedParts.length > 0) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
geminiClient.addHistory({
role: 'user',
parts: combinedParts,
});
}
}
const callIdsToMarkAsSubmitted = toolCalls.map(
(toolCall) => toolCall.request.callId,
);
markToolsAsSubmitted(callIdsToMarkAsSubmitted);
return;
}
// Finalize any client-initiated tools as soon as they are done.
const clientTools = completedAndReadyToSubmitTools.filter(
(t) => t.request.isClientInitiated,
@@ -2066,6 +2156,7 @@ export const useGeminiStream = (
maybeAddSuppressedToolErrorNote,
maybeAddLowVerbosityFailureNote,
setIsResponding,
toolCalls,
],
);
@@ -1,10 +1,74 @@
(version 1)
;; allow everything by default
(allow default)
;; permissive-open: uses (deny default) and explicitly allows the operations the
;; CLI needs, matching the restrictive-* / strict-* profiles. Keep the allow-list
;; minimal and reviewed; do not switch to (allow default).
;;
;; Keep the non-network rules in sync with sandbox-macos-permissive-proxied.sb:
;; the two profiles are intentionally identical except for their network rules
;; ("open" allows broad outbound; "proxied" routes outbound through the proxy).
(deny default)
;; deny all writes EXCEPT under specific paths
(deny file-write*)
;; allow reading files from anywhere on host
(allow file-read*)
;; allow exec/fork (children inherit this policy, so they stay sandboxed)
(allow process-exec)
(allow process-fork)
;; allow signals to self, e.g. SIGPIPE on write to closed pipe
(allow signal (target self))
;; allow read access to specific information about system
;; from https://source.chromium.org/chromium/chromium/src/+/main:sandbox/policy/mac/common.sb;l=273-319;drc=7b3962fe2e5fc9e2ee58000dc8fbf3429d84d3bd
(allow sysctl-read
(sysctl-name "hw.activecpu")
(sysctl-name "hw.busfrequency_compat")
(sysctl-name "hw.byteorder")
(sysctl-name "hw.cacheconfig")
(sysctl-name "hw.cachelinesize_compat")
(sysctl-name "hw.cpufamily")
(sysctl-name "hw.cpufrequency_compat")
(sysctl-name "hw.cputype")
(sysctl-name "hw.l1dcachesize_compat")
(sysctl-name "hw.l1icachesize_compat")
(sysctl-name "hw.l2cachesize_compat")
(sysctl-name "hw.l3cachesize_compat")
(sysctl-name "hw.logicalcpu_max")
(sysctl-name "hw.machine")
(sysctl-name "hw.ncpu")
(sysctl-name "hw.nperflevels")
(sysctl-name "hw.optional.arm.FEAT_BF16")
(sysctl-name "hw.optional.arm.FEAT_DotProd")
(sysctl-name "hw.optional.arm.FEAT_FCMA")
(sysctl-name "hw.optional.arm.FEAT_FHM")
(sysctl-name "hw.optional.arm.FEAT_FP16")
(sysctl-name "hw.optional.arm.FEAT_I8MM")
(sysctl-name "hw.optional.arm.FEAT_JSCVT")
(sysctl-name "hw.optional.arm.FEAT_LSE")
(sysctl-name "hw.optional.arm.FEAT_RDM")
(sysctl-name "hw.optional.arm.FEAT_SHA512")
(sysctl-name "hw.optional.armv8_2_sha512")
(sysctl-name "hw.packages")
(sysctl-name "hw.pagesize_compat")
(sysctl-name "hw.physicalcpu_max")
(sysctl-name "hw.tbfrequency_compat")
(sysctl-name "hw.vectorunit")
(sysctl-name "kern.hostname")
(sysctl-name "kern.maxfilesperproc")
(sysctl-name "kern.osproductversion")
(sysctl-name "kern.osrelease")
(sysctl-name "kern.ostype")
(sysctl-name "kern.osvariant_status")
(sysctl-name "kern.osversion")
(sysctl-name "kern.secure_kernel")
(sysctl-name "kern.usrstack64")
(sysctl-name "kern.version")
(sysctl-name "sysctl.proc_cputype")
(sysctl-name-prefix "hw.perflevel")
)
;; allow writes only to specific paths (deny default already blocks the rest)
(allow file-write*
(subpath (param "TARGET_DIR"))
(subpath (param "TMP_DIR"))
@@ -24,3 +88,48 @@
(literal "/dev/ptmx")
(regex #"^/dev/ttys[0-9]*$")
)
;; allow the mach services normal workflows need under deny-default: sysmond for
;; process listing (pgrep), plus DNS resolution (mDNSResponder), directory
;; services (opendirectoryd), and certificate validation (trustd/ocspd).
;; This set mirrors the deny-default profile in
;; packages/core/src/sandbox/macos/baseProfile.ts (its NETWORK_SEATBELT_PROFILE),
;; which restrictive-open reaches only implicitly via (allow network-outbound).
;; Keep this allow-list minimal and reviewed.
(allow mach-lookup
(global-name "com.apple.sysmond")
(global-name "com.apple.system.opendirectoryd.libinfo")
(global-name "com.apple.system.opendirectoryd.membership")
(global-name "com.apple.bsd.dirhelper")
(global-name "com.apple.SecurityServer")
(global-name "com.apple.networkd")
(global-name "com.apple.ocspd")
(global-name "com.apple.trustd")
(global-name "com.apple.trustd.agent")
(global-name "com.apple.mDNSResponder")
(global-name "com.apple.mDNSResponderHelper")
(global-name "com.apple.SystemConfiguration.DNSConfiguration")
(global-name "com.apple.SystemConfiguration.configd")
)
;; AF_SYSTEM socket used by the network stack (from baseProfile.ts)
(allow system-socket
(require-all
(socket-domain AF_SYSTEM)
(socket-protocol 2)
)
)
;; enable terminal access required by ink
;; fixes setRawMode EPERM failure (at node:tty:81:24)
(allow file-ioctl (regex #"^/dev/tty.*"))
;; allow inbound network traffic (local dev/test servers, the debugger on :9229,
;; OAuth localhost callbacks)
(allow network-inbound (local ip "*:*"))
;; allow binding local ports (dev/test servers, OAuth localhost listeners)
(allow network-bind (local ip "*:*"))
;; allow all outbound network traffic
(allow network-outbound)
@@ -1,10 +1,76 @@
(version 1)
;; allow everything by default
(allow default)
;; permissive-proxied: uses (deny default) and explicitly allows the operations
;; the CLI needs, matching the restrictive-* / strict-* profiles. Keep the
;; allow-list minimal and reviewed; do not switch to (allow default).
;;
;; Keep the non-network rules in sync with sandbox-macos-permissive-open.sb:
;; the two profiles are intentionally identical except for their network rules
;; ("open" allows broad outbound; "proxied" routes outbound through the proxy).
(deny default)
;; deny all writes EXCEPT under specific paths
(deny file-write*)
;; allow reading files from anywhere on host
(allow file-read*)
;; allow exec/fork (children inherit this policy, so they stay sandboxed)
(allow process-exec)
(allow process-fork)
;; allow signals to self, e.g. SIGPIPE on write to closed pipe
(allow signal (target self))
;; allow read access to specific information about system
;; from https://source.chromium.org/chromium/chromium/src/+/main:sandbox/policy/mac/common.sb;l=273-319;drc=7b3962fe2e5fc9e2ee58000dc8fbf3429d84d3bd
(allow sysctl-read
(sysctl-name "hw.activecpu")
(sysctl-name "hw.busfrequency_compat")
(sysctl-name "hw.byteorder")
(sysctl-name "hw.cacheconfig")
(sysctl-name "hw.cachelinesize_compat")
(sysctl-name "hw.cpufamily")
(sysctl-name "hw.cpufrequency_compat")
(sysctl-name "hw.cputype")
(sysctl-name "hw.l1dcachesize_compat")
(sysctl-name "hw.l1icachesize_compat")
(sysctl-name "hw.l2cachesize_compat")
(sysctl-name "hw.l3cachesize_compat")
(sysctl-name "hw.logicalcpu_max")
(sysctl-name "hw.machine")
(sysctl-name "hw.ncpu")
(sysctl-name "hw.nperflevels")
(sysctl-name "hw.optional.arm.FEAT_BF16")
(sysctl-name "hw.optional.arm.FEAT_DotProd")
(sysctl-name "hw.optional.arm.FEAT_FCMA")
(sysctl-name "hw.optional.arm.FEAT_FHM")
(sysctl-name "hw.optional.arm.FEAT_FP16")
(sysctl-name "hw.optional.arm.FEAT_I8MM")
(sysctl-name "hw.optional.arm.FEAT_JSCVT")
(sysctl-name "hw.optional.arm.FEAT_LSE")
(sysctl-name "hw.optional.arm.FEAT_RDM")
(sysctl-name "hw.optional.arm.FEAT_SHA512")
(sysctl-name "hw.optional.armv8_2_sha512")
(sysctl-name "hw.packages")
(sysctl-name "hw.pagesize_compat")
(sysctl-name "hw.physicalcpu_max")
(sysctl-name "hw.tbfrequency_compat")
(sysctl-name "hw.vectorunit")
(sysctl-name "kern.hostname")
(sysctl-name "kern.maxfilesperproc")
(sysctl-name "kern.osproductversion")
(sysctl-name "kern.osrelease")
(sysctl-name "kern.ostype")
(sysctl-name "kern.osvariant_status")
(sysctl-name "kern.osversion")
(sysctl-name "kern.secure_kernel")
(sysctl-name "kern.usrstack64")
(sysctl-name "kern.version")
(sysctl-name "sysctl.proc_cputype")
(sysctl-name-prefix "hw.perflevel")
)
;; allow writes only to specific paths (deny default already blocks the rest).
;; Mirrors permissive-open, including /dev/ptmx and the /dev/ttys regex needed
;; for PTY support under deny default.
(allow file-write*
(subpath (param "TARGET_DIR"))
(subpath (param "TMP_DIR"))
@@ -21,16 +87,52 @@
(literal "/dev/stdout")
(literal "/dev/stderr")
(literal "/dev/null")
(literal "/dev/ptmx")
(regex #"^/dev/ttys[0-9]*$")
)
;; deny all inbound network traffic EXCEPT on debugger port
(deny network-inbound)
;; allow the mach services normal workflows need under deny-default: sysmond for
;; process listing (pgrep), plus DNS resolution (mDNSResponder), directory
;; services (opendirectoryd), and certificate validation (trustd/ocspd).
;; This set mirrors the deny-default profile in
;; packages/core/src/sandbox/macos/baseProfile.ts (its NETWORK_SEATBELT_PROFILE),
;; which restrictive-proxied reaches only implicitly via (allow network-outbound).
;; Keep this allow-list minimal and reviewed.
(allow mach-lookup
(global-name "com.apple.sysmond")
(global-name "com.apple.system.opendirectoryd.libinfo")
(global-name "com.apple.system.opendirectoryd.membership")
(global-name "com.apple.bsd.dirhelper")
(global-name "com.apple.SecurityServer")
(global-name "com.apple.networkd")
(global-name "com.apple.ocspd")
(global-name "com.apple.trustd")
(global-name "com.apple.trustd.agent")
(global-name "com.apple.mDNSResponder")
(global-name "com.apple.mDNSResponderHelper")
(global-name "com.apple.SystemConfiguration.DNSConfiguration")
(global-name "com.apple.SystemConfiguration.configd")
)
;; AF_SYSTEM socket used by the network stack (from baseProfile.ts)
(allow system-socket
(require-all
(socket-domain AF_SYSTEM)
(socket-protocol 2)
)
)
;; enable terminal access required by ink
;; fixes setRawMode EPERM failure (at node:tty:81:24)
(allow file-ioctl (regex #"^/dev/tty.*"))
;; allow inbound network traffic on debugger port
(allow network-inbound (local ip "localhost:9229"))
;; allow binding local ports (dev/test servers, OAuth localhost listeners)
(allow network-bind (local ip "*:*"))
;; deny all outbound network traffic EXCEPT through proxy on localhost:8877
;; set `GEMINI_SANDBOX_PROXY_COMMAND=<command>` to run proxy alongside sandbox
;; proxy must listen on :::8877 (see docs/examples/proxy-script.md)
(deny network-outbound)
(allow network-outbound (remote tcp "localhost:8877"))
(allow network-bind (local ip "*:*"))
@@ -0,0 +1,78 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
const utilsDir = path.dirname(fileURLToPath(import.meta.url));
/**
* Strip SBPL comments (`; ...` to end of line) so assertions run against the
* actual sandbox rules rather than any keywords that happen to appear in the
* explanatory comments.
*/
function readRules(profile: string): string {
return readFileSync(path.join(utilsDir, profile), 'utf8')
.split('\n')
.map((line) => {
const commentStart = line.indexOf(';');
return commentStart === -1 ? line : line.slice(0, commentStart);
})
.join('\n');
}
const PERMISSIVE_PROFILES = [
'sandbox-macos-permissive-open.sb',
'sandbox-macos-permissive-proxied.sb',
];
// These two profiles are the default macOS Seatbelt profiles, so the invariants
// below must never silently regress. Keep them deny-default and confirm the
// reviewed allow-list stays in place.
describe('macOS permissive Seatbelt profiles', () => {
describe.each(PERMISSIVE_PROFILES)('%s', (profile) => {
const rules = readRules(profile);
it('uses a deny-default foundation', () => {
expect(rules).toContain('(deny default)');
});
it('does not use an allow-default foundation', () => {
expect(rules).not.toContain('(allow default)');
});
it('does not permit filesystem (un)mounts', () => {
expect(rules).not.toMatch(/file-mount/);
expect(rules).not.toMatch(/file-unmount/);
});
it('does not grant broad service lookups', () => {
expect(rules).not.toMatch(/launchd/);
expect(rules).not.toMatch(/launchservices/i);
});
it('allows binding local ports for dev/test servers', () => {
expect(rules).toContain('(allow network-bind (local ip "*:*"))');
});
});
it('permissive-open keeps broad inbound and outbound network', () => {
const rules = readRules('sandbox-macos-permissive-open.sb');
expect(rules).toContain('(allow network-inbound (local ip "*:*"))');
expect(rules).toMatch(/\(allow network-outbound\)/);
});
it('permissive-proxied confines outbound to the proxy', () => {
const rules = readRules('sandbox-macos-permissive-proxied.sb');
expect(rules).toContain(
'(allow network-outbound (remote tcp "localhost:8877"))',
);
// Proxied mode must never grant unrestricted outbound network.
expect(rules).not.toMatch(/\(allow network-outbound\)/);
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-core",
"version": "0.52.0",
"version": "0.53.1",
"description": "Gemini CLI Core",
"license": "Apache-2.0",
"repository": {
@@ -516,10 +516,34 @@ describe('translateEvent', () => {
});
describe('InvalidStream events', () => {
it('emits fatal error', () => {
it('emits fatal error with specific message from event', () => {
state.streamStartEmitted = true;
const event: ServerGeminiStreamEvent = {
type: GeminiEventType.InvalidStream,
value: {
type: 'NO_RESPONSE_TEXT',
message: 'Empty response',
},
};
const result = translateEvent(event, state);
expect(result).toHaveLength(1);
const err = result[0] as AgentEvent<'error'>;
expect(err.status).toBe('INTERNAL');
expect(err.message).toBe('Empty response');
expect(err.fatal).toBe(true);
expect(err._meta?.['code']).toBe('INVALID_STREAM');
expect(err._meta?.['errorType']).toBe('NO_RESPONSE_TEXT');
expect(err._meta?.['rawMessage']).toBe('Empty response');
});
it('falls back to default message when message is missing', () => {
state.streamStartEmitted = true;
const event: ServerGeminiStreamEvent = {
type: GeminiEventType.InvalidStream,
value: {
type: 'NO_RESPONSE_TEXT',
message: '',
},
};
const result = translateEvent(event, state);
expect(result).toHaveLength(1);
+8 -1
View File
@@ -222,8 +222,15 @@ export function translateEvent(
out.push(
makeEvent('error', state, {
status: 'INTERNAL',
message: 'Invalid stream received from model',
message:
event.value?.message?.trim() ||
'Invalid stream received from model',
fatal: true,
_meta: {
code: 'INVALID_STREAM',
errorType: event.value?.type,
rawMessage: event.value?.message,
},
}),
);
break;
@@ -10,7 +10,7 @@
*/
import { GeminiEventType } from '../core/turn.js';
import type { Part } from '@google/genai';
import type { Part, FinishReason } from '@google/genai';
import type { GeminiClient } from '../core/client.js';
import type { Config } from '../config/config.js';
import type { ToolCallRequestInfo } from '../scheduler/types.js';
@@ -192,6 +192,7 @@ export class LegacyAgentProtocol implements AgentProtocol {
}
const toolCallRequests: ToolCallRequestInfo[] = [];
let finishedReason: FinishReason | undefined = undefined;
const responseStream = this._client.sendMessageStream(
currentParts,
this._abortController.signal,
@@ -220,10 +221,7 @@ export class LegacyAgentProtocol implements AgentProtocol {
this._finishStream('failed');
return;
case GeminiEventType.Finished:
if (toolCallRequests.length === 0) {
this._finishStream(mapFinishReason(event.value.reason));
return;
}
finishedReason = event.value.reason;
break;
case GeminiEventType.AgentExecutionStopped:
case GeminiEventType.UserCancelled:
@@ -241,7 +239,11 @@ export class LegacyAgentProtocol implements AgentProtocol {
}
if (toolCallRequests.length === 0) {
this._finishStream('completed');
if (finishedReason !== undefined) {
this._finishStream(mapFinishReason(finishedReason));
} else {
this._finishStream('completed');
}
return;
}
@@ -680,6 +680,64 @@ describe('oauth2', () => {
expect(mockFromJSON).toHaveBeenCalledWith(byoidCredentials);
expect(client).toBe(mockExternalAccountClient);
});
it('should fall back to GOOGLE_APPLICATION_CREDENTIALS if default cached credentials are invalid or expired', async () => {
// Setup default cached credentials that are expired/invalid
const defaultCreds = { refresh_token: 'expired-token' };
const defaultCredsPath = path.join(
tempHomeDir,
GEMINI_DIR,
'oauth_creds.json',
);
await fs.promises.mkdir(path.dirname(defaultCredsPath), {
recursive: true,
});
await fs.promises.writeFile(
defaultCredsPath,
JSON.stringify(defaultCreds),
);
// Setup valid fallback credentials via environment variable
const envCreds = { refresh_token: 'valid-env-token' };
const envCredsPath = path.join(tempHomeDir, 'env_creds.json');
await fs.promises.writeFile(envCredsPath, JSON.stringify(envCreds));
vi.stubEnv('GOOGLE_APPLICATION_CREDENTIALS', envCredsPath);
let currentCredentials: Credentials | null = null;
const mockClient = {
setCredentials: vi.fn((creds) => {
currentCredentials = creds as Credentials;
}),
getAccessToken: vi.fn(async () => {
if (
currentCredentials &&
currentCredentials.refresh_token === 'expired-token'
) {
throw new Error('Token is expired or revoked');
}
return { token: 'valid-token' };
}),
getTokenInfo: vi.fn(async (_token) => {
if (
currentCredentials &&
currentCredentials.refresh_token === 'expired-token'
) {
throw new Error('Token is expired or revoked');
}
return {};
}),
on: vi.fn(),
};
vi.mocked(OAuth2Client).mockImplementation(
() => mockClient as unknown as OAuth2Client,
);
await getOauthClient(AuthType.LOGIN_WITH_GOOGLE, mockConfig);
// Assert that fallback envCreds were eventually loaded and used
expect(mockClient.setCredentials).toHaveBeenCalledWith(envCreds);
});
});
describe('with GCP environment variables', () => {
+111 -72
View File
@@ -113,46 +113,52 @@ function getUseEncryptedStorageFlag() {
return process.env[FORCE_ENCRYPTED_FILE_ENV_VAR] === 'true';
}
/**
* Determines whether the given credentials object represents ADC credentials.
*/
function isAdcCredentials(
credentials: unknown,
): credentials is JWTInput & { type: string } {
if (credentials && typeof credentials === 'object' && 'type' in credentials) {
const type = credentials.type;
return typeof type === 'string' && type !== 'authorized_user';
}
return false;
}
async function initOauthClient(
authType: AuthType,
config: Config,
): Promise<AuthClient> {
const credentials = await fetchCachedCredentials();
function createBaseOAuth2Client(): OAuth2Client {
const client = new OAuth2Client({
clientId: OAUTH_CLIENT_ID,
clientSecret: OAUTH_CLIENT_SECRET,
transporterOptions: {
proxy: config.getProxy(),
},
});
const useEncryptedStorage = getUseEncryptedStorageFlag();
if (
credentials &&
typeof credentials === 'object' &&
'type' in credentials &&
(credentials.type === 'external_account_authorized_user' ||
credentials.type === 'service_account')
) {
const auth = new GoogleAuth({
scopes: OAUTH_SCOPE,
client.on('tokens', async (tokens: Credentials) => {
if (useEncryptedStorage) {
await OAuthCredentialStorage.saveCredentials(tokens);
} else {
await cacheCredentials(tokens);
}
await triggerPostAuthCallbacks(tokens);
});
const byoidClient = auth.fromJSON({
...credentials,
refresh_token: credentials.refresh_token ?? undefined,
});
const token = await byoidClient.getAccessToken();
if (token) {
debugLogger.debug(`Created ${credentials.type} auth client.`);
return byoidClient;
}
return client;
}
const client = new OAuth2Client({
clientId: OAUTH_CLIENT_ID,
clientSecret: OAUTH_CLIENT_SECRET,
transporterOptions: {
proxy: config.getProxy(),
},
});
const useEncryptedStorage = getUseEncryptedStorageFlag();
// 1. Try GOOGLE_CLOUD_ACCESS_TOKEN override first if configured
if (
process.env['GOOGLE_GENAI_USE_GCA'] &&
process.env['GOOGLE_CLOUD_ACCESS_TOKEN']
) {
const client = createBaseOAuth2Client();
client.setCredentials({
access_token: process.env['GOOGLE_CLOUD_ACCESS_TOKEN'],
});
@@ -160,49 +166,70 @@ async function initOauthClient(
return client;
}
client.on('tokens', async (tokens: Credentials) => {
if (useEncryptedStorage) {
await OAuthCredentialStorage.saveCredentials(tokens);
} else {
await cacheCredentials(tokens);
}
const credentialsList = await fetchCachedCredentialsList();
await triggerPostAuthCallbacks(tokens);
});
if (credentials) {
client.setCredentials(credentials as Credentials);
try {
// This will verify locally that the credentials look good.
const { token } = await client.getAccessToken();
if (token) {
// This will check with the server to see if it hasn't been revoked.
await client.getTokenInfo(token);
if (!userAccountManager.getCachedGoogleAccount()) {
try {
await fetchAndCacheUserInfo(client);
} catch (error) {
// Non-fatal, continue with existing auth.
debugLogger.warn(
'Failed to fetch user info:',
getErrorMessage(error),
);
}
// 2. Iterate sequentially over the credentials list in their natural priority order
for (const credentials of credentialsList) {
if (isAdcCredentials(credentials)) {
try {
const auth = new GoogleAuth({
scopes: OAUTH_SCOPE,
});
const adcClient = auth.fromJSON({
...credentials,
refresh_token: credentials.refresh_token ?? undefined,
});
const response = await adcClient.getAccessToken();
const token = response.token ?? null;
if (token) {
debugLogger.debug('Created ' + credentials.type + ' auth client.');
return adcClient;
}
debugLogger.log('Loaded cached credentials.');
await triggerPostAuthCallbacks(credentials as Credentials);
return client;
} catch (error) {
debugLogger.debug(
'ADC credentials verification failed:',
getErrorMessage(error),
);
}
} else if (credentials) {
const client = createBaseOAuth2Client();
client.setCredentials(credentials as Credentials);
try {
// This will verify locally that the credentials look good.
const { token } = await client.getAccessToken();
if (token) {
// This will check with the server to see if it hasn't been revoked.
await client.getTokenInfo(token);
if (!userAccountManager.getCachedGoogleAccount()) {
try {
await fetchAndCacheUserInfo(client);
} catch (error) {
// Non-fatal, continue with existing auth.
debugLogger.warn(
'Failed to fetch user info:',
getErrorMessage(error),
);
}
}
debugLogger.log('Loaded cached credentials.');
await triggerPostAuthCallbacks(
client.credentials || (credentials as Credentials),
);
return client;
}
} catch (error) {
debugLogger.debug(
'Cached credentials are not valid:',
getErrorMessage(error),
);
}
} catch (error) {
debugLogger.debug(
`Cached credentials are not valid:`,
getErrorMessage(error),
);
}
}
const client = createBaseOAuth2Client();
// In Google Compute Engine based environments (including Cloud Shell), we can
// use Application Default Credentials (ADC) provided via its metadata server
// to authenticate non-interactively using the identity of the logged-in user.
@@ -663,16 +690,27 @@ export function getAvailablePort(): Promise<number> {
});
}
async function fetchCachedCredentials(): Promise<
Credentials | JWTInput | null
async function fetchCachedCredentialsList(): Promise<
Array<Credentials | JWTInput>
> {
const credentialsList: Array<Credentials | JWTInput> = [];
const useEncryptedStorage = getUseEncryptedStorageFlag();
if (useEncryptedStorage) {
return OAuthCredentialStorage.loadCredentials();
try {
const creds = await OAuthCredentialStorage.loadCredentials();
if (creds) {
credentialsList.push(creds);
}
} catch (error) {
debugLogger.debug(
'Failed to load credentials from encrypted storage:',
error,
);
}
}
const pathsToTry = [
Storage.getOAuthCredsPath(),
...(!useEncryptedStorage ? [Storage.getOAuthCredsPath()] : []),
process.env['GOOGLE_APPLICATION_CREDENTIALS'],
].filter((p): p is string => !!p);
@@ -683,9 +721,10 @@ async function fetchCachedCredentials(): Promise<
const isOAuthCreds = (val: unknown): val is Credentials | JWTInput =>
typeof val === 'object' && val !== null;
if (isOAuthCreds(parsed)) {
return parsed;
credentialsList.push(parsed);
} else {
throw new Error('Invalid credentials format');
}
throw new Error('Invalid credentials format');
} catch (error) {
// Log specific error for debugging, but continue trying other paths
debugLogger.debug(
@@ -695,7 +734,7 @@ async function fetchCachedCredentials(): Promise<
}
}
return null;
return credentialsList;
}
export function clearOauthClientCache() {
+3
View File
@@ -678,6 +678,7 @@ export interface ConfigParameters {
truncateToolOutputThreshold?: number;
eventEmitter?: EventEmitter;
useWriteTodos?: boolean;
env?: Record<string, string>;
workspacePoliciesDir?: string;
policyEngineConfig?: PolicyEngineConfig;
directWebFetch?: boolean;
@@ -896,6 +897,7 @@ export class Config implements McpContext, AgentLoopContext {
private readonly useTerminalBuffer: boolean;
private readonly useRenderProcess: boolean;
private shellExecutionConfig: ShellExecutionConfig;
readonly env?: Record<string, string>;
private readonly extensionManagement: boolean = true;
private readonly extensionRegistryURI: string | undefined;
private readonly truncateToolOutputThreshold: number;
@@ -1119,6 +1121,7 @@ export class Config implements McpContext, AgentLoopContext {
this.checkpointing = params.checkpointing ?? false;
this.proxy = params.proxy;
this.cwd = params.cwd ?? process.cwd();
this.env = params.env;
this.fileDiscoveryService = params.fileDiscoveryService ?? null;
this.bugCommand = params.bugCommand;
this.model = params.model;
@@ -165,6 +165,16 @@ ONLY use the built-in \`exit_plan_mode\` tool to present the plan for formal app
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
## Tool Usage
- **Tool Execution Response Rules:**
1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions:
a) Call another tool to proceed with the task.
b) Provide a user-facing text response explaining the tool output, your analysis, and next steps.
2. You MUST NEVER return an empty response with no text and no tool calls.
- **Post-Edit Response Rules:**
1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing:
- What changes were made to the file.
- Your verification plan or next steps (e.g. running tests).
2. You MUST NEVER return an empty response with 0 text tokens after completing an edit.
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
@@ -361,6 +371,16 @@ An approved plan is available for this task at \`../plans/feature-x.md\`.
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
## Tool Usage
- **Tool Execution Response Rules:**
1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions:
a) Call another tool to proceed with the task.
b) Provide a user-facing text response explaining the tool output, your analysis, and next steps.
2. You MUST NEVER return an empty response with no text and no tool calls.
- **Post-Edit Response Rules:**
1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing:
- What changes were made to the file.
- Your verification plan or next steps (e.g. running tests).
2. You MUST NEVER return an empty response with 0 text tokens after completing an edit.
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
@@ -671,6 +691,16 @@ ONLY use the built-in \`exit_plan_mode\` tool to present the plan for formal app
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
## Tool Usage
- **Tool Execution Response Rules:**
1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions:
a) Call another tool to proceed with the task.
b) Provide a user-facing text response explaining the tool output, your analysis, and next steps.
2. You MUST NEVER return an empty response with no text and no tool calls.
- **Post-Edit Response Rules:**
1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing:
- What changes were made to the file.
- Your verification plan or next steps (e.g. running tests).
2. You MUST NEVER return an empty response with 0 text tokens after completing an edit.
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
@@ -845,6 +875,16 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
## Tool Usage
- **Tool Execution Response Rules:**
1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions:
a) Call another tool to proceed with the task.
b) Provide a user-facing text response explaining the tool output, your analysis, and next steps.
2. You MUST NEVER return an empty response with no text and no tool calls.
- **Post-Edit Response Rules:**
1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing:
- What changes were made to the file.
- Your verification plan or next steps (e.g. running tests).
2. You MUST NEVER return an empty response with 0 text tokens after completing an edit.
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
@@ -1005,6 +1045,16 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
## Tool Usage
- **Tool Execution Response Rules:**
1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions:
a) Call another tool to proceed with the task.
b) Provide a user-facing text response explaining the tool output, your analysis, and next steps.
2. You MUST NEVER return an empty response with no text and no tool calls.
- **Post-Edit Response Rules:**
1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing:
- What changes were made to the file.
- Your verification plan or next steps (e.g. running tests).
2. You MUST NEVER return an empty response with 0 text tokens after completing an edit.
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
@@ -1148,6 +1198,16 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
## Tool Usage
- **Tool Execution Response Rules:**
1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions:
a) Call another tool to proceed with the task.
b) Provide a user-facing text response explaining the tool output, your analysis, and next steps.
2. You MUST NEVER return an empty response with no text and no tool calls.
- **Post-Edit Response Rules:**
1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing:
- What changes were made to the file.
- Your verification plan or next steps (e.g. running tests).
2. You MUST NEVER return an empty response with 0 text tokens after completing an edit.
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
@@ -1837,6 +1897,16 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
## Tool Usage
- **Tool Execution Response Rules:**
1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions:
a) Call another tool to proceed with the task.
b) Provide a user-facing text response explaining the tool output, your analysis, and next steps.
2. You MUST NEVER return an empty response with no text and no tool calls.
- **Post-Edit Response Rules:**
1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing:
- What changes were made to the file.
- Your verification plan or next steps (e.g. running tests).
2. You MUST NEVER return an empty response with 0 text tokens after completing an edit.
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
@@ -2011,6 +2081,16 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
## Tool Usage
- **Tool Execution Response Rules:**
1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions:
a) Call another tool to proceed with the task.
b) Provide a user-facing text response explaining the tool output, your analysis, and next steps.
2. You MUST NEVER return an empty response with no text and no tool calls.
- **Post-Edit Response Rules:**
1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing:
- What changes were made to the file.
- Your verification plan or next steps (e.g. running tests).
2. You MUST NEVER return an empty response with 0 text tokens after completing an edit.
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
@@ -2189,6 +2269,16 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
## Tool Usage
- **Tool Execution Response Rules:**
1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions:
a) Call another tool to proceed with the task.
b) Provide a user-facing text response explaining the tool output, your analysis, and next steps.
2. You MUST NEVER return an empty response with no text and no tool calls.
- **Post-Edit Response Rules:**
1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing:
- What changes were made to the file.
- Your verification plan or next steps (e.g. running tests).
2. You MUST NEVER return an empty response with 0 text tokens after completing an edit.
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
@@ -2367,6 +2457,16 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
## Tool Usage
- **Tool Execution Response Rules:**
1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions:
a) Call another tool to proceed with the task.
b) Provide a user-facing text response explaining the tool output, your analysis, and next steps.
2. You MUST NEVER return an empty response with no text and no tool calls.
- **Post-Edit Response Rules:**
1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing:
- What changes were made to the file.
- Your verification plan or next steps (e.g. running tests).
2. You MUST NEVER return an empty response with 0 text tokens after completing an edit.
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
@@ -2541,6 +2641,16 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
## Tool Usage
- **Tool Execution Response Rules:**
1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions:
a) Call another tool to proceed with the task.
b) Provide a user-facing text response explaining the tool output, your analysis, and next steps.
2. You MUST NEVER return an empty response with no text and no tool calls.
- **Post-Edit Response Rules:**
1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing:
- What changes were made to the file.
- Your verification plan or next steps (e.g. running tests).
2. You MUST NEVER return an empty response with 0 text tokens after completing an edit.
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
@@ -2709,6 +2819,16 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
## Tool Usage
- **Tool Execution Response Rules:**
1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions:
a) Call another tool to proceed with the task.
b) Provide a user-facing text response explaining the tool output, your analysis, and next steps.
2. You MUST NEVER return an empty response with no text and no tool calls.
- **Post-Edit Response Rules:**
1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing:
- What changes were made to the file.
- Your verification plan or next steps (e.g. running tests).
2. You MUST NEVER return an empty response with 0 text tokens after completing an edit.
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
@@ -2851,6 +2971,16 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
## Tool Usage
- **Tool Execution Response Rules:**
1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions:
a) Call another tool to proceed with the task.
b) Provide a user-facing text response explaining the tool output, your analysis, and next steps.
2. You MUST NEVER return an empty response with no text and no tool calls.
- **Post-Edit Response Rules:**
1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing:
- What changes were made to the file.
- Your verification plan or next steps (e.g. running tests).
2. You MUST NEVER return an empty response with 0 text tokens after completing an edit.
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
@@ -3025,6 +3155,16 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
## Tool Usage
- **Tool Execution Response Rules:**
1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions:
a) Call another tool to proceed with the task.
b) Provide a user-facing text response explaining the tool output, your analysis, and next steps.
2. You MUST NEVER return an empty response with no text and no tool calls.
- **Post-Edit Response Rules:**
1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing:
- What changes were made to the file.
- Your verification plan or next steps (e.g. running tests).
2. You MUST NEVER return an empty response with 0 text tokens after completing an edit.
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
@@ -3345,6 +3485,16 @@ You are operating with a persistent file-based task tracking system located at \
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
## Tool Usage
- **Tool Execution Response Rules:**
1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions:
a) Call another tool to proceed with the task.
b) Provide a user-facing text response explaining the tool output, your analysis, and next steps.
2. You MUST NEVER return an empty response with no text and no tool calls.
- **Post-Edit Response Rules:**
1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing:
- What changes were made to the file.
- Your verification plan or next steps (e.g. running tests).
2. You MUST NEVER return an empty response with 0 text tokens after completing an edit.
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
@@ -3774,6 +3924,16 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
## Tool Usage
- **Tool Execution Response Rules:**
1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions:
a) Call another tool to proceed with the task.
b) Provide a user-facing text response explaining the tool output, your analysis, and next steps.
2. You MUST NEVER return an empty response with no text and no tool calls.
- **Post-Edit Response Rules:**
1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing:
- What changes were made to the file.
- Your verification plan or next steps (e.g. running tests).
2. You MUST NEVER return an empty response with 0 text tokens after completing an edit.
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
@@ -3948,6 +4108,16 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
## Tool Usage
- **Tool Execution Response Rules:**
1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions:
a) Call another tool to proceed with the task.
b) Provide a user-facing text response explaining the tool output, your analysis, and next steps.
2. You MUST NEVER return an empty response with no text and no tool calls.
- **Post-Edit Response Rules:**
1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing:
- What changes were made to the file.
- Your verification plan or next steps (e.g. running tests).
2. You MUST NEVER return an empty response with 0 text tokens after completing an edit.
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
@@ -4241,6 +4411,16 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
## Tool Usage
- **Tool Execution Response Rules:**
1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions:
a) Call another tool to proceed with the task.
b) Provide a user-facing text response explaining the tool output, your analysis, and next steps.
2. You MUST NEVER return an empty response with no text and no tool calls.
- **Post-Edit Response Rules:**
1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing:
- What changes were made to the file.
- Your verification plan or next steps (e.g. running tests).
2. You MUST NEVER return an empty response with 0 text tokens after completing an edit.
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
@@ -4415,6 +4595,16 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
## Tool Usage
- **Tool Execution Response Rules:**
1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions:
a) Call another tool to proceed with the task.
b) Provide a user-facing text response explaining the tool output, your analysis, and next steps.
2. You MUST NEVER return an empty response with no text and no tool calls.
- **Post-Edit Response Rules:**
1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing:
- What changes were made to the file.
- Your verification plan or next steps (e.g. running tests).
2. You MUST NEVER return an empty response with 0 text tokens after completing an edit.
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
@@ -0,0 +1,110 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { AgentChatHistory, type HistoryTurn } from './agentChatHistory.js';
describe('AgentChatHistory', () => {
const dummyTurns: HistoryTurn[] = [
{
id: 'turn-1',
content: { role: 'user', parts: [{ text: 'Hello' }] },
},
{
id: 'turn-2',
content: { role: 'model', parts: [{ text: 'Hi there' }] },
},
{
id: 'turn-3',
content: { role: 'user', parts: [{ text: 'How are you?' }] },
},
];
it('should initialize with empty history by default', () => {
const history = new AgentChatHistory();
expect(history.length).toBe(0);
expect(history.get()).toEqual([]);
});
it('should initialize with provided turns', () => {
const history = new AgentChatHistory(dummyTurns);
expect(history.length).toBe(3);
expect(history.get()).toEqual(dummyTurns);
});
it('should push new turns', () => {
const history = new AgentChatHistory();
history.push(dummyTurns[0]);
expect(history.length).toBe(1);
expect(history.get()[0]).toEqual(dummyTurns[0]);
});
it('should set and overwrite history turns', () => {
const history = new AgentChatHistory(dummyTurns.slice(0, 1));
expect(history.length).toBe(1);
history.set(dummyTurns);
expect(history.length).toBe(3);
expect(history.get()).toEqual(dummyTurns);
});
it('should clear history', () => {
const history = new AgentChatHistory(dummyTurns);
expect(history.length).toBe(3);
history.clear();
expect(history.length).toBe(0);
expect(history.get()).toEqual([]);
});
describe('rollback', () => {
it('should roll back history to a specified length', () => {
const history = new AgentChatHistory(dummyTurns);
history.rollback(1);
expect(history.length).toBe(1);
expect(history.get()).toEqual([dummyTurns[0]]);
});
it('should roll back to 0', () => {
const history = new AgentChatHistory(dummyTurns);
history.rollback(0);
expect(history.length).toBe(0);
expect(history.get()).toEqual([]);
});
it('should do nothing if rollback length is out of bounds (negative)', () => {
const history = new AgentChatHistory(dummyTurns);
history.rollback(-1);
expect(history.length).toBe(3);
expect(history.get()).toEqual(dummyTurns);
});
it('should do nothing if rollback length is out of bounds (greater than current history length)', () => {
const history = new AgentChatHistory(dummyTurns);
history.rollback(5);
expect(history.length).toBe(3);
expect(history.get()).toEqual(dummyTurns);
});
});
it('should return raw Content array via getContents()', () => {
const history = new AgentChatHistory(dummyTurns);
expect(history.getContents()).toEqual(
dummyTurns.map((turn) => turn.content),
);
});
it('should support mapping and flatMapping operations', () => {
const history = new AgentChatHistory(dummyTurns);
const mappedIds = history.map((turn) => turn.id);
expect(mappedIds).toEqual(['turn-1', 'turn-2', 'turn-3']);
const flatMappedParts = history.flatMap((turn) => turn.content.parts || []);
expect(flatMappedParts).toEqual([
{ text: 'Hello' },
{ text: 'Hi there' },
{ text: 'How are you?' },
]);
});
});
@@ -46,6 +46,16 @@ export class AgentChatHistory {
this.history = [];
}
/**
* Rolls back the history to a specified length.
* Useful when a stream fails and we need to remove the un-responded turn(s).
*/
rollback(length: number) {
if (length >= 0 && length <= this.history.length) {
this.history = this.history.slice(0, length);
}
}
get(): readonly HistoryTurn[] {
return this.history;
}
+13 -10
View File
@@ -154,6 +154,13 @@ export async function createContentGeneratorConfig(
vertexAiRouting,
};
const getEnv = (key: string) => {
if (config?.env && config.env[key] !== undefined) {
return config.env[key];
}
return process.env[key];
};
// If we are using Google auth or we are in Cloud Shell, there is nothing else to validate for now.
// Return before touching the API-key keychain: on Linux without a Secret Service
// (WSL/SSH/Docker/CI) keytar can block indefinitely on its functional probe.
@@ -165,16 +172,13 @@ export async function createContentGeneratorConfig(
}
const geminiApiKey =
apiKey ||
process.env['GEMINI_API_KEY'] ||
(await loadApiKey()) ||
undefined;
const googleApiKey = process.env['GOOGLE_API_KEY'] || undefined;
apiKey || getEnv('GEMINI_API_KEY') || (await loadApiKey()) || undefined;
const googleApiKey = getEnv('GOOGLE_API_KEY') || undefined;
const googleCloudProject =
process.env['GOOGLE_CLOUD_PROJECT'] ||
process.env['GOOGLE_CLOUD_PROJECT_ID'] ||
getEnv('GOOGLE_CLOUD_PROJECT') ||
getEnv('GOOGLE_CLOUD_PROJECT_ID') ||
undefined;
const googleCloudLocation = process.env['GOOGLE_CLOUD_LOCATION'] || undefined;
const googleCloudLocation = getEnv('GOOGLE_CLOUD_LOCATION') || undefined;
if (authType === AuthType.USE_GEMINI && geminiApiKey) {
contentGeneratorConfig.apiKey = geminiApiKey;
@@ -194,8 +198,7 @@ export async function createContentGeneratorConfig(
}
if (authType === AuthType.GATEWAY) {
contentGeneratorConfig.apiKey =
apiKey || process.env['GEMINI_API_KEY'] || '';
contentGeneratorConfig.apiKey = apiKey || getEnv('GEMINI_API_KEY') || '';
contentGeneratorConfig.vertexai = false;
return contentGeneratorConfig;
File diff suppressed because it is too large Load Diff
+184 -38
View File
@@ -221,7 +221,12 @@ export class InvalidStreamError extends Error {
| 'NO_FINISH_REASON'
| 'NO_RESPONSE_TEXT'
| 'MALFORMED_FUNCTION_CALL'
| 'UNEXPECTED_TOOL_CALL';
| 'UNEXPECTED_TOOL_CALL'
| 'MAX_TOKENS_EXCEEDED'
| 'SAFETY_BLOCKED'
| 'RECITATION_BLOCKED'
| 'OTHER_BLOCKED'
| 'THINKING_ONLY_RESPONSE';
constructor(
message: string,
@@ -229,7 +234,12 @@ export class InvalidStreamError extends Error {
| 'NO_FINISH_REASON'
| 'NO_RESPONSE_TEXT'
| 'MALFORMED_FUNCTION_CALL'
| 'UNEXPECTED_TOOL_CALL',
| 'UNEXPECTED_TOOL_CALL'
| 'MAX_TOKENS_EXCEEDED'
| 'SAFETY_BLOCKED'
| 'RECITATION_BLOCKED'
| 'OTHER_BLOCKED'
| 'THINKING_ONLY_RESPONSE',
) {
super(message);
this.name = 'InvalidStreamError';
@@ -383,6 +393,9 @@ export class GeminiChat {
): Promise<AsyncGenerator<StreamEvent>> {
await this.sendPromise;
const historyLengthBefore = this.agentHistory.length;
const baselinePromptTokenCount = this.lastPromptTokenCount;
let streamDoneResolver: () => void;
const streamDonePromise = new Promise<void>((resolve) => {
streamDoneResolver = resolve;
@@ -390,6 +403,7 @@ export class GeminiChat {
this.sendPromise = streamDonePromise;
let userContent = createUserContent(message);
const isOriginalFunctionResponse = isFunctionResponse(userContent);
const { model } =
this.context.config.modelConfigService.getResolvedConfig(modelConfigKey);
@@ -398,7 +412,7 @@ export class GeminiChat {
// Record user input - capture complete message with all parts (text, files, images, etc.)
// but skip recording function responses (tool call results) as they should be stored in tool call records
if (!isFunctionResponse(userContent)) {
if (!isOriginalFunctionResponse) {
const userMessageParts = userContent.parts || [];
const userMessageContent = partListUnionToString(userMessageParts);
@@ -515,6 +529,7 @@ export class GeminiChat {
): AsyncGenerator<StreamEvent, void, void> {
try {
const maxAttempts = this.context.config.getMaxAttempts();
let lastStreamError: unknown = undefined;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
let isConnectionPhase = true;
@@ -526,7 +541,7 @@ export class GeminiChat {
// If this is a retry, update the key with the new context.
const currentConfigKey =
attempt > 0
? { ...modelConfigKey, isRetry: true }
? { ...modelConfigKey, isRetry: true, lastStreamError }
: modelConfigKey;
isConnectionPhase = true;
@@ -545,6 +560,10 @@ export class GeminiChat {
return;
} catch (error) {
if (error instanceof InvalidStreamError) {
lastStreamError = error;
}
if (error instanceof AgentExecutionStoppedError) {
yield {
type: StreamEventType.AGENT_EXECUTION_STOPPED,
@@ -581,8 +600,7 @@ export class GeminiChat {
);
const isContentError = error instanceof InvalidStreamError;
const isRetryableContentError =
isContentError && error.type !== 'NO_RESPONSE_TEXT';
const isRetryableContentError = isContentError;
const errorType = isContentError
? error.type
: getRetryErrorType(error);
@@ -644,6 +662,15 @@ export class GeminiChat {
throw error;
}
}
} catch (error) {
if (!isOriginalFunctionResponse) {
this.agentHistory.rollback(historyLengthBefore);
this.chatRecordingService.updateMessagesFromHistory(
this.agentHistory.get(),
);
this.lastPromptTokenCount = baselinePromptTokenCount;
}
throw error;
} finally {
streamDoneResolver!();
}
@@ -683,10 +710,13 @@ export class GeminiChat {
): Promise<AsyncGenerator<GenerateContentResponse>> {
// Last mile scrubbing to remove internal tracking properties (e.g. callIndex)
// before sending to the Gemini API. This whitelists only standard Gemini fields.
const scrubbedHistory = this.context.config.isContextManagementEnabled()
let scrubbedHistory = this.context.config.isContextManagementEnabled()
? scrubHistory([...requestHistory])
: [...requestHistory];
// Always coalesce consecutive roles to prevent 400 Bad Request errors
scrubbedHistory = coalesceConsecutiveRoles(scrubbedHistory);
const scrubbedContents = scrubbedHistory.map((h) => h.content);
const requestContents = apiHistoryOverride
@@ -763,6 +793,30 @@ export class GeminiChat {
abortSignal,
};
// Apply Context-Aware Retries (On-Retry Nudging) to guide the model out of silent loops
if (
modelConfigKey.isRetry &&
modelConfigKey.lastStreamError instanceof InvalidStreamError
) {
const lastError = modelConfigKey.lastStreamError;
let nudgeMessage = '';
if (lastError.type === 'THINKING_ONLY_RESPONSE') {
nudgeMessage =
'\n[System: You previously generated thoughts but failed to provide a final user-facing response. Please ensure you provide your final answer or call a tool now.]';
} else if (lastError.type === 'NO_RESPONSE_TEXT') {
nudgeMessage =
'\n[System: You previously returned an empty response with no text or thoughts. Please ensure you provide your final answer or call a tool now.]';
}
if (nudgeMessage) {
if (typeof config.systemInstruction === 'string') {
config.systemInstruction += nudgeMessage;
} else if (config.systemInstruction === undefined) {
config.systemInstruction = nudgeMessage;
}
}
}
let contentsToUse: Content[] = supportsModernFeatures(modelToUse)
? [...contentsForPreviewModel]
: [...requestContents];
@@ -1119,6 +1173,13 @@ export class GeminiChat {
let hasThoughts = false;
let finishReason: FinishReason | undefined;
// Buffers to prevent failed stream attempts from polluting telemetry and logs
const bufferedThoughts: Array<{ subject: string; description: string }> =
[];
let bufferedUsageMetadata:
| GenerateContentResponse['usageMetadata']
| undefined = undefined;
// The SDK provides fully assembled FunctionCall objects in chunk.functionCalls
// We use a Map to ensure we only keep the latest version of each call (by ID)
const finalFunctionCallsMap = new Map<string, FunctionCall>();
@@ -1174,7 +1235,10 @@ export class GeminiChat {
if (content.parts.some((part) => part.thought)) {
// Record thoughts
hasThoughts = true;
this.recordThoughtFromContent(content);
const thought = this.extractThoughtFromContent(content);
if (thought) {
bufferedThoughts.push(thought);
}
}
if (content.parts.some((part) => part.functionCall)) {
hasToolCall = true;
@@ -1202,12 +1266,9 @@ export class GeminiChat {
}
}
// Record token usage if this chunk has usageMetadata
// Buffer token usage if this chunk has usageMetadata
if (chunk.usageMetadata) {
this.chatRecordingService.recordMessageTokens(chunk.usageMetadata);
if (chunk.usageMetadata.promptTokenCount !== undefined) {
this.lastPromptTokenCount = chunk.usageMetadata.promptTokenCount;
}
bufferedUsageMetadata = chunk.usageMetadata;
}
const hookSystem = this.context.config.getHookSystem();
@@ -1294,29 +1355,22 @@ export class GeminiChat {
}
}
const responseText = consolidatedParts
const rawResponseText = consolidatedParts
.filter((part) => part.text)
.map((part) => part.text)
.join('')
.trim();
.join('');
let id: string;
// Record model response text from the collected parts.
// Also flush when there are thoughts or a tool call (even with no text)
// so that BeforeTool hooks always see the latest transcript state.
if (responseText || hasThoughts || hasToolCall) {
id = this.chatRecordingService.recordMessage({
model,
type: 'gemini',
content: responseText,
});
} else {
// Still need a durable ID even if response is empty (e.g. only tool calls)
id = this.chatRecordingService.recordSyntheticMessage(
'gemini',
consolidatedParts,
);
}
// Clean zero-width/invisible characters and HTML comments to determine actual printable/visible content
let responseText = rawResponseText.replace(
/[\u200B-\u200D\uFEFF\u200E\u200F]/g,
'',
);
let previous: string;
do {
previous = responseText;
responseText = responseText.replace(/<!--[\s\S]*?-->/g, '');
} while (responseText !== previous);
responseText = responseText.trim();
// Stream validation logic: A stream is considered successful if:
// 1. There's a tool call OR
@@ -1346,6 +1400,36 @@ export class GeminiChat {
);
}
if (!responseText) {
if (finishReason === FinishReason.MAX_TOKENS) {
throw new InvalidStreamError(
'Model stream ended due to token limit exhaustion (MAX_TOKENS) with empty response text.',
'MAX_TOKENS_EXCEEDED',
);
}
if (finishReason === FinishReason.SAFETY) {
throw new InvalidStreamError(
'Model stream ended due to safety settings (SAFETY) with empty response text.',
'SAFETY_BLOCKED',
);
}
if (finishReason === FinishReason.RECITATION) {
throw new InvalidStreamError(
'Model stream ended due to recitation settings (RECITATION) with empty response text.',
'RECITATION_BLOCKED',
);
}
if (finishReason === FinishReason.OTHER) {
throw new InvalidStreamError(
'Model stream ended due to other settings (OTHER) with empty response text.',
'OTHER_BLOCKED',
);
}
if (hasThoughts) {
throw new InvalidStreamError(
'Model stream ended with empty response text but contained reasoning thoughts.',
'THINKING_ONLY_RESPONSE',
);
}
throw new InvalidStreamError(
'Model stream ended with empty response text.',
'NO_RESPONSE_TEXT',
@@ -1353,6 +1437,37 @@ export class GeminiChat {
}
}
// Flush buffered thoughts from the successful attempt
for (const thought of bufferedThoughts) {
this.chatRecordingService.recordThought(thought);
}
// Flush buffered usage metadata and token counts from the successful attempt
if (bufferedUsageMetadata) {
this.chatRecordingService.recordMessageTokens(bufferedUsageMetadata);
if (bufferedUsageMetadata.promptTokenCount !== undefined) {
this.lastPromptTokenCount = bufferedUsageMetadata.promptTokenCount;
}
}
let id: string;
// Record model response text from the collected parts.
// Also flush when there are thoughts or a tool call (even with no text)
// so that BeforeTool hooks always see the latest transcript state.
if (responseText || hasThoughts || hasToolCall) {
id = this.chatRecordingService.recordMessage({
model,
type: 'gemini',
content: responseText,
});
} else {
// Still need a durable ID even if response is empty (e.g. only tool calls)
id = this.chatRecordingService.recordSyntheticMessage(
'gemini',
consolidatedParts,
);
}
this.agentHistory.push({
id,
content: { role: 'model', parts: consolidatedParts },
@@ -1407,11 +1522,13 @@ export class GeminiChat {
}
/**
* Extracts and records thought from thought content.
* Extracts thought from thought content.
*/
private recordThoughtFromContent(content: Content): void {
private extractThoughtFromContent(
content: Content,
): { subject: string; description: string } | undefined {
if (!content.parts || content.parts.length === 0) {
return;
return undefined;
}
const thoughtPart = content.parts[0];
@@ -1424,11 +1541,12 @@ export class GeminiChat {
: '';
const description = rawText.replace(/\*\*(.*?)\*\*/s, '').trim();
this.chatRecordingService.recordThought({
return {
subject,
description,
});
};
}
return undefined;
}
}
@@ -1472,3 +1590,31 @@ export function stripToolCallIdPrefixes(contents: Content[]): Content[] {
}),
}));
}
export function coalesceConsecutiveRoles(
history: HistoryTurn[],
): HistoryTurn[] {
const result: HistoryTurn[] = [];
for (const turn of history) {
const lastIdx = result.length - 1;
const last = result[lastIdx];
if (last && last.content.role && last.content.role === turn.content.role) {
const hasParts = last.content.parts || turn.content.parts;
result[lastIdx] = {
id: last.id,
content: {
...last.content,
parts: hasParts
? [...(last.content.parts || []), ...(turn.content.parts || [])]
: undefined,
},
};
} else {
result.push({
id: turn.id,
content: { ...turn.content },
});
}
}
return result;
}
+9 -1
View File
@@ -254,7 +254,15 @@ describe('Turn', () => {
events.push(event);
}
expect(events).toEqual([{ type: GeminiEventType.InvalidStream }]);
expect(events).toEqual([
{
type: GeminiEventType.InvalidStream,
value: {
type: 'NO_FINISH_REASON',
message: 'Test invalid stream',
},
},
]);
expect(turn.getDebugResponses().length).toBe(0);
expect(reportError).not.toHaveBeenCalled(); // Should not report as error
});
+20 -1
View File
@@ -105,6 +105,19 @@ export type ServerGeminiContextWindowWillOverflowEvent = {
export type ServerGeminiInvalidStreamEvent = {
type: GeminiEventType.InvalidStream;
value: {
type:
| 'NO_FINISH_REASON'
| 'NO_RESPONSE_TEXT'
| 'MALFORMED_FUNCTION_CALL'
| 'UNEXPECTED_TOOL_CALL'
| 'MAX_TOKENS_EXCEEDED'
| 'SAFETY_BLOCKED'
| 'RECITATION_BLOCKED'
| 'OTHER_BLOCKED'
| 'THINKING_ONLY_RESPONSE';
message: string;
};
};
export type ServerGeminiModelInfoEvent = {
@@ -408,7 +421,13 @@ export class Turn {
}
if (e instanceof InvalidStreamError) {
yield { type: GeminiEventType.InvalidStream };
yield {
type: GeminiEventType.InvalidStream,
value: {
type: e.type,
message: e.message,
},
};
return;
}
+10
View File
@@ -413,6 +413,16 @@ export function renderOperationalGuidelines(
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
## Tool Usage
- **Tool Execution Response Rules:**
1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions:
a) Call another tool to proceed with the task.
b) Provide a user-facing text response explaining the tool output, your analysis, and next steps.
2. You MUST NEVER return an empty response with no text and no tool calls.
- **Post-Edit Response Rules:**
1. After an edit tool execution (e.g. ${formatToolName(EDIT_TOOL_NAME)}, ${formatToolName(WRITE_FILE_TOOL_NAME)}), you MUST ALWAYS generate a user-facing text response summarizing:
- What changes were made to the file.
- Your verification plan or next steps (e.g. running tests).
2. You MUST NEVER return an empty response with 0 text tokens after completing an edit.
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
- **File Editing Collisions:** Do NOT make multiple calls to the ${formatToolName(EDIT_TOOL_NAME)} tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
- **Command Execution:** Use the ${formatToolName(SHELL_TOOL_NAME)} tool for running shell commands, remembering the safety rule to explain modifying commands first.${toolUsageInteractive(
@@ -34,6 +34,10 @@ describe('CheckerRunner', () => {
beforeEach(() => {
mockContextBuilder = new ContextBuilder({} as Config);
vi.spyOn(mockContextBuilder, 'config', 'get').mockReturnValue({
env: {},
getWorkingDir: vi.fn().mockReturnValue('/mock/cwd'),
} as unknown as Config);
mockRegistry = new CheckerRegistry('/mock/dist');
CheckerRegistry.prototype.resolveInProcess = vi.fn();
@@ -168,6 +168,8 @@ export class CheckerRunner {
return new Promise((resolve) => {
const child = spawn(checkerPath, [], {
stdio: ['pipe', 'pipe', 'pipe'],
cwd: this.contextBuilder.config.getWorkingDir(),
env: { ...process.env, ...this.contextBuilder.config.env },
});
let stdout = '';
@@ -15,6 +15,10 @@ import type { AgentLoopContext } from '../config/agent-loop-context.js';
export class ContextBuilder {
constructor(private readonly context: AgentLoopContext) {}
get config() {
return this.context.config;
}
/**
* Builds the full context object with all available data.
*/
@@ -630,8 +630,8 @@ describe('Scheduler (Orchestrator)', () => {
CoreToolCallStatus.Cancelled,
'Operation cancelled by user',
);
// finalizeCall is handled by the processing loop, not synchronously by cancelAll
// expect(mockStateManager.finalizeCall).toHaveBeenCalledWith('call-1');
// finalizeCall is called synchronously by cancelAll to ensure completedBatch is populated and isActive is updated immediately
expect(mockStateManager.finalizeCall).toHaveBeenCalledWith('call-1');
expect(mockStateManager.cancelAllQueued).toHaveBeenCalledWith(
'Operation cancelled by user',
);
+9
View File
@@ -278,6 +278,7 @@ export class Scheduler {
CoreToolCallStatus.Cancelled,
'Operation cancelled by user',
);
this.state.finalizeCall(activeCall.request.callId);
}
}
@@ -438,6 +439,14 @@ export class Scheduler {
*/
private async _processNextItem(signal: AbortSignal): Promise<boolean> {
if (signal.aborted || this.isCancelling) {
// Finalize active calls that are terminal
const activeCalls = this.state.allActiveCalls;
for (const call of activeCalls) {
if (this.isTerminal(call.status)) {
this.state.finalizeCall(call.request.callId);
}
}
this.state.cancelAllQueued('Operation cancelled');
return false;
}
@@ -175,6 +175,93 @@ describe('LoopDetectionService', () => {
}
expect(loggers.logLoopDetected).not.toHaveBeenCalled();
});
it('should detect an alternating tool call loop (cycle of length 2)', () => {
const eventA = createToolCallRequestEvent('read_file', {
file_path: 'loop_a.txt',
});
const eventB = createToolCallRequestEvent('read_file', {
file_path: 'loop_b.txt',
});
let loopCount = 0;
for (let i = 0; i < 15; i++) {
const currentEvent = i % 2 === 0 ? eventA : eventB;
const result = service.addAndCheck(currentEvent);
if (result.count > 0) {
loopCount = result.count;
}
}
expect(loopCount).toBeGreaterThan(0);
});
it('should detect a cyclic tool call loop of length 3', () => {
const eventA = createToolCallRequestEvent('read_file', {
file_path: 'loop_a.txt',
});
const eventB = createToolCallRequestEvent('read_file', {
file_path: 'loop_b.txt',
});
const eventC = createToolCallRequestEvent('read_file', {
file_path: 'loop_c.txt',
});
let loopCount = 0;
const sequence = [eventA, eventB, eventC];
for (let i = 0; i < 20; i++) {
const result = service.addAndCheck(sequence[i % 3]);
if (result.count > 0) {
loopCount = result.count;
}
}
expect(loopCount).toBeGreaterThan(0);
});
it('should detect a cyclic tool call loop of length 5', () => {
const e1 = createToolCallRequestEvent('t', { id: 1 });
const e2 = createToolCallRequestEvent('t', { id: 2 });
const e3 = createToolCallRequestEvent('t', { id: 3 });
const e4 = createToolCallRequestEvent('t', { id: 4 });
const e5 = createToolCallRequestEvent('t', { id: 5 });
let loopCount = 0;
const sequence = [e1, e2, e3, e4, e5];
for (let i = 0; i < 30; i++) {
const result = service.addAndCheck(sequence[i % 5]);
if (result.count > 0) {
loopCount = result.count;
}
}
expect(loopCount).toBeGreaterThan(0);
});
it('should not detect loops for non-looping alternating sequences that vary', () => {
const eventA = createToolCallRequestEvent('read_file', {
file_path: 'loop_a.txt',
});
const eventB = createToolCallRequestEvent('read_file', {
file_path: 'loop_b.txt',
});
const eventC = createToolCallRequestEvent('read_file', {
file_path: 'loop_c.txt',
});
// Run some alternating calls, then break the pattern with eventC
for (let i = 0; i < 4; i++) {
expect(service.addAndCheck(i % 2 === 0 ? eventA : eventB).count).toBe(
0,
);
}
expect(service.addAndCheck(eventC).count).toBe(0);
for (let i = 0; i < 4; i++) {
expect(service.addAndCheck(i % 2 === 0 ? eventA : eventB).count).toBe(
0,
);
}
});
});
describe('Content Loop Detection', () => {
@@ -136,8 +136,7 @@ export class LoopDetectionService {
private userPrompt = '';
// Tool call tracking
private lastToolCallKey: string | null = null;
private toolCallRepetitionCount: number = 0;
private toolCallHistory: string[] = [];
// Content streaming tracking
private streamContentHistory = '';
@@ -313,15 +312,39 @@ export class LoopDetectionService {
private checkToolCallLoop(toolCall: { name: string; args: object }): boolean {
const key = this.getToolCallKey(toolCall);
if (this.lastToolCallKey === key) {
this.toolCallRepetitionCount++;
} else {
this.lastToolCallKey = key;
this.toolCallRepetitionCount = 1;
this.toolCallHistory.push(key);
const maxRequiredLength = 5 * TOOL_CALL_LOOP_THRESHOLD;
if (this.toolCallHistory.length > maxRequiredLength) {
this.toolCallHistory = this.toolCallHistory.slice(-maxRequiredLength);
}
if (this.toolCallRepetitionCount >= TOOL_CALL_LOOP_THRESHOLD) {
return true;
const n = this.toolCallHistory.length;
const R = TOOL_CALL_LOOP_THRESHOLD; // 5
// Check for repeating patterns of cycle length k from 1 to 5
for (let k = 1; k <= 5; k++) {
const requiredLength = k * R;
if (n >= requiredLength) {
const cycle = this.toolCallHistory.slice(-k);
let isPatternMatch = true;
for (let i = 0; i < requiredLength; i++) {
const indexFromEnd = requiredLength - i;
const actualKey = this.toolCallHistory[n - indexFromEnd];
const expectedKey = cycle[i % k];
if (actualKey !== expectedKey) {
isPatternMatch = false;
break;
}
}
if (isPatternMatch) {
return true;
}
}
}
return false;
}
@@ -739,8 +762,7 @@ export class LoopDetectionService {
}
private resetToolCallCount(): void {
this.lastToolCallKey = null;
this.toolCallRepetitionCount = 0;
this.toolCallHistory = [];
}
private resetContentTracking(resetHistory = true): void {
@@ -37,6 +37,9 @@ export interface ModelConfigKey {
// Indicates whether this request originates from the primary interactive chat model.
// Enables the default fallback configuration to `chat-base` when unknown.
isChatModel?: boolean;
// The last stream error that triggered this retry attempt, if any.
lastStreamError?: unknown;
}
export interface ModelConfig {
@@ -139,6 +139,7 @@ export interface ShellExecutionConfig {
backgroundCompletionBehavior?: 'inject' | 'notify' | 'silent';
originalCommand?: string;
sessionId?: string;
env?: Record<string, string>;
}
/**
@@ -461,9 +462,10 @@ export class ShellExecutionService {
const spawnArgs = [...argsPrefix, finalCommand];
// 2. Prepare Environment
const sourceEnv = shellExecutionConfig.env ?? process.env;
const gitConfigKeys: string[] = [];
if (!isInteractive) {
for (const key in process.env) {
for (const key in sourceEnv) {
if (key.startsWith('GIT_CONFIG_')) {
gitConfigKeys.push(key);
}
@@ -479,7 +481,7 @@ export class ShellExecutionService {
],
};
const sanitizedEnv = sanitizeEnvironment(process.env, sanitizationConfig);
const sanitizedEnv = sanitizeEnvironment(sourceEnv, sanitizationConfig);
const baseEnv: Record<string, string | undefined> = {
...sanitizedEnv,
@@ -493,7 +495,7 @@ export class ShellExecutionService {
if (!isInteractive) {
// Ensure all GIT_CONFIG_* variables are preserved even if they were redacted
for (const key of gitConfigKeys) {
baseEnv[key] = process.env[key];
baseEnv[key] = sourceEnv[key];
}
const gitConfigCount = parseInt(baseEnv['GIT_CONFIG_COUNT'] || '0', 10);
@@ -173,6 +173,7 @@ describe('UiTelemetryService', () => {
totalRequests: 1,
totalErrors: 0,
totalLatencyMs: 500,
errorsByType: {},
},
tokens: {
input: 5,
@@ -229,6 +230,7 @@ describe('UiTelemetryService', () => {
totalRequests: 2,
totalErrors: 0,
totalLatencyMs: 1100,
errorsByType: {},
},
tokens: {
input: 10,
@@ -305,6 +307,9 @@ describe('UiTelemetryService', () => {
totalRequests: 1,
totalErrors: 1,
totalLatencyMs: 300,
errorsByType: {
UNKNOWN: 1,
},
},
tokens: {
input: 0,
@@ -319,6 +324,42 @@ describe('UiTelemetryService', () => {
});
});
it('should track errors by error_type distinctly', () => {
const event1 = {
'event.name': EVENT_API_ERROR,
model: 'gemini-2.5-pro',
duration_ms: 200,
error: 'Empty response',
error_type: 'NO_RESPONSE_TEXT',
} as unknown as ApiErrorEvent & { 'event.name': typeof EVENT_API_ERROR };
const event2 = {
'event.name': EVENT_API_ERROR,
model: 'gemini-2.5-pro',
duration_ms: 250,
error: 'Malformed JSON',
error_type: 'MALFORMED_FUNCTION_CALL',
} as unknown as ApiErrorEvent & { 'event.name': typeof EVENT_API_ERROR };
const event3 = {
'event.name': EVENT_API_ERROR,
model: 'gemini-2.5-pro',
duration_ms: 100,
error: 'Another empty response',
error_type: 'NO_RESPONSE_TEXT',
} as unknown as ApiErrorEvent & { 'event.name': typeof EVENT_API_ERROR };
service.addEvent(event1);
service.addEvent(event2);
service.addEvent(event3);
const metrics = service.getMetrics();
expect(metrics.models['gemini-2.5-pro'].api.errorsByType).toEqual({
NO_RESPONSE_TEXT: 2,
MALFORMED_FUNCTION_CALL: 1,
});
});
it('should aggregate ApiErrorEvents and ApiResponseEvents', () => {
const responseEvent = {
'event.name': EVENT_API_RESPONSE,
@@ -351,6 +392,9 @@ describe('UiTelemetryService', () => {
totalRequests: 2,
totalErrors: 1,
totalLatencyMs: 800,
errorsByType: {
UNKNOWN: 1,
},
},
tokens: {
input: 5,
@@ -56,6 +56,7 @@ export interface ModelMetrics {
totalRequests: number;
totalErrors: number;
totalLatencyMs: number;
errorsByType?: Record<string, number>;
};
tokens: {
input: number;
@@ -110,6 +111,7 @@ const createInitialModelMetrics = (): ModelMetrics => ({
totalRequests: 0,
totalErrors: 0,
totalLatencyMs: 0,
errorsByType: {},
},
tokens: {
input: 0,
@@ -170,6 +172,23 @@ export class UiTelemetryService extends EventEmitter {
});
}
recordSemanticValidationError(model: string, errorType: string): void {
const modelMetrics = this.getOrCreateModelMetrics(model);
modelMetrics.api.totalErrors++;
if (!modelMetrics.api.errorsByType) {
modelMetrics.api.errorsByType = {};
}
const type = errorType || 'INVALID_STREAM';
modelMetrics.api.errorsByType[type] =
(modelMetrics.api.errorsByType[type] || 0) + 1;
this.emit('update', {
metrics: this.#metrics,
lastPromptTokenCount: this.#lastPromptTokenCount,
});
}
getMetrics(): SessionMetrics {
return this.#metrics;
}
@@ -326,6 +345,13 @@ export class UiTelemetryService extends EventEmitter {
modelMetrics.api.totalErrors++;
modelMetrics.api.totalLatencyMs += event.duration_ms;
if (!modelMetrics.api.errorsByType) {
modelMetrics.api.errorsByType = {};
}
const errorType = event.error_type || 'UNKNOWN';
modelMetrics.api.errorsByType[errorType] =
(modelMetrics.api.errorsByType[errorType] || 0) + 1;
if (event.role) {
if (!modelMetrics.roles[event.role]) {
modelMetrics.roles[event.role] = createInitialRoleMetrics();
+1
View File
@@ -656,6 +656,7 @@ export class ShellToolInvocation extends BaseToolInvocation<
this.context.config.isInteractiveShellEnabled(),
{
...shellExecutionConfig,
env: this.context.config.env,
sessionId: this.context.config?.getSessionId?.() ?? 'default',
pager: 'cat',
sanitizationConfig:
+3 -1
View File
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { Config } from '../config/config.js';
import { MessageBus } from '../confirmation-bus/message-bus.js';
import type { PolicyEngine } from '../policy/policy-engine.js';
@@ -30,6 +30,7 @@ describe('Tracker Tools Integration', () => {
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'tracker-tools-test-'));
vi.stubEnv('GEMINI_CLI_HOME', tempDir);
config = new Config({
sessionId: `test-session-${Math.random().toString(36).substring(7)}`,
targetDir: tempDir,
@@ -42,6 +43,7 @@ describe('Tracker Tools Integration', () => {
});
afterEach(async () => {
vi.unstubAllEnvs();
await fs.rm(tempDir, { recursive: true, force: true });
});
+21
View File
@@ -10,3 +10,24 @@ export const REFERENCE_CONTENT_END = '--- End of content ---';
export const DEFAULT_MAX_LINES_TEXT_FILE = 2000;
export const MAX_LINE_LENGTH_TEXT_FILE = 2000;
export const MAX_FILE_SIZE_MB = 20;
export const EMPTY_RESPONSE_COMPRESS_SUGGESTION =
'The model returned an empty text response. If your context window is near capacity, try using /compress.';
export const THINKING_ONLY_COMPRESS_SUGGESTION =
'The model returned reasoning thoughts but no final response text. If your context window is near capacity, try using /compress.';
export const MAX_TOKENS_EXCEEDED_SUGGESTION =
'Model response was truncated because it exceeded the token limit. Try using /compress to free up context space.';
export const SAFETY_BLOCKED_MESSAGE =
'The model response was blocked due to safety settings.';
export const RECITATION_BLOCKED_MESSAGE =
'The model response was blocked due to recitation/copyright filters.';
export const OTHER_BLOCKED_MESSAGE =
'The model response was blocked due to other policy settings.';
export const TRUE_EMPTY_RESPONSE_MESSAGE =
'The model returned an empty response with no text or thoughts. This may be a transient API issue; please try again.';
+4
View File
@@ -39,6 +39,10 @@ vi.mock('child_process', () => ({
spawnSync: vi.fn(() => ({ error: null, status: 0 })),
}));
vi.mock('./headless.js', () => ({
isHeadlessMode: vi.fn(() => false),
}));
const originalPlatform = process.platform;
describe('editor utils', () => {
+8
View File
@@ -9,6 +9,7 @@ import { promisify } from 'node:util';
import { once } from 'node:events';
import { debugLogger } from './debugLogger.js';
import { coreEvents, CoreEvent, type EditorSelectedPayload } from './events.js';
import { isHeadlessMode } from './headless.js';
const GUI_EDITORS = [
'vscode',
@@ -404,6 +405,13 @@ export async function openDiff(
newPath: string,
editor: EditorType,
): Promise<void> {
if (isHeadlessMode()) {
debugLogger.warn(
'External editor spawning is disabled in headless/server mode.',
);
return;
}
const diffCommand = getDiffCommand(oldPath, newPath, editor);
if (!diffCommand) {
debugLogger.error('No diff tool available. Install a supported editor.');
@@ -0,0 +1,167 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { isFunctionResponse, isFunctionCall } from './messageInspectors.js';
describe('messageInspectors', () => {
describe('isFunctionResponse', () => {
it('should return false if content role is not user', () => {
const content = {
role: 'model',
parts: [
{
functionResponse: {
name: 'test_tool',
response: { success: true },
},
},
],
};
expect(isFunctionResponse(content)).toBe(false);
});
it('should return false if content has no parts', () => {
const content = {
role: 'user',
};
expect(isFunctionResponse(content)).toBe(false);
});
it('should return false if parts are empty', () => {
const content = {
role: 'user',
parts: [],
};
expect(isFunctionResponse(content)).toBe(false);
});
it('should return false if none of the parts is a functionResponse', () => {
const content = {
role: 'user',
parts: [
{
text: 'Hello world',
},
{
fileData: {
mimeType: 'image/png',
fileUri: 'https://example.com/image.png',
},
},
],
};
expect(isFunctionResponse(content)).toBe(false);
});
it('should return true if all parts are functionResponses', () => {
const content = {
role: 'user',
parts: [
{
functionResponse: {
name: 'test_tool_1',
response: { success: true },
},
},
{
functionResponse: {
name: 'test_tool_2',
response: { value: 42 },
},
},
],
};
expect(isFunctionResponse(content)).toBe(true);
});
it('should return true if content is a mixed multimodal tool response containing functionResponse and sibling parts', () => {
const content = {
role: 'user',
parts: [
{
functionResponse: {
name: 'test_tool',
response: { success: true },
},
},
{
fileData: {
mimeType: 'image/png',
fileUri: 'https://example.com/image.png',
},
},
],
};
expect(isFunctionResponse(content)).toBe(true);
});
});
describe('isFunctionCall', () => {
it('should return false if content role is not model', () => {
const content = {
role: 'user',
parts: [
{
functionCall: {
name: 'test_tool',
args: {},
},
},
],
};
expect(isFunctionCall(content)).toBe(false);
});
it('should return false if content has no parts', () => {
const content = {
role: 'model',
};
expect(isFunctionCall(content)).toBe(false);
});
it('should return false if parts are empty', () => {
const content = {
role: 'model',
parts: [],
};
expect(isFunctionCall(content)).toBe(false);
});
it('should return false if none of the parts is a functionCall', () => {
const content = {
role: 'model',
parts: [
{
text: 'I am thinking...',
},
],
};
expect(isFunctionCall(content)).toBe(false);
});
it('should return true if all parts are functionCalls', () => {
const content = {
role: 'model',
parts: [
{
functionCall: {
name: 'test_tool_1',
args: {},
},
},
{
functionCall: {
name: 'test_tool_2',
args: { query: 'foo' },
},
},
],
};
expect(isFunctionCall(content)).toBe(true);
});
});
});
+2 -1
View File
@@ -10,7 +10,7 @@ export function isFunctionResponse(content: Content): boolean {
return (
content.role === 'user' &&
!!content.parts &&
content.parts.every((part) => !!part.functionResponse)
content.parts.some((part) => !!part.functionResponse)
);
}
@@ -18,6 +18,7 @@ export function isFunctionCall(content: Content): boolean {
return (
content.role === 'model' &&
!!content.parts &&
content.parts.length > 0 &&
content.parts.every((part) => !!part.functionCall)
);
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-devtools",
"version": "0.52.0",
"version": "0.53.1",
"license": "Apache-2.0",
"type": "module",
"main": "dist/src/index.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-sdk",
"version": "0.52.0",
"version": "0.53.1",
"description": "Gemini CLI SDK",
"license": "Apache-2.0",
"repository": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-test-utils",
"version": "0.52.0",
"version": "0.53.1",
"private": true,
"main": "src/index.ts",
"license": "Apache-2.0",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "gemini-cli-vscode-ide-companion",
"displayName": "Gemini CLI Companion",
"description": "Enable Gemini CLI with direct access to your IDE workspace.",
"version": "0.52.0",
"version": "0.53.1",
"publisher": "google",
"icon": "assets/icon.png",
"repository": {
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env tsx
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { collectInventory } from './utils/eval-inventory.js';
import { buildToolRegistry } from './utils/tool-registry.js';
import {
computeCoverage,
formatCoverageReport,
} from './utils/eval-coverage.js';
async function main() {
const rootFlagIndex = process.argv.indexOf('--root');
const rootFlagValue =
rootFlagIndex !== -1 ? process.argv[rootFlagIndex + 1] : undefined;
if (rootFlagIndex !== -1 && rootFlagValue === undefined) {
console.error(
'Error: --root requires a directory path argument but none was provided.',
);
process.exit(1);
}
if (rootFlagValue && rootFlagValue.startsWith('--')) {
console.error(
`Error: --root value "${rootFlagValue}" looks like a flag. Provide a valid directory path.`,
);
process.exit(1);
}
const repoRoot = rootFlagValue ?? process.cwd();
const inventory = await collectInventory(repoRoot);
if (inventory.totalFiles === 0) {
console.error('No eval files found under evals/.');
process.exit(1);
}
const registry = buildToolRegistry();
const result = computeCoverage(inventory, registry);
console.log(formatCoverageReport(result));
}
main().catch((error) => {
console.error('Fatal error:', error);
process.exit(1);
});
+510
View File
@@ -0,0 +1,510 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import {
computeCoverage,
formatCoverageReport,
type CoverageResult,
type CoveredToolEntry,
} from '../utils/eval-coverage.js';
import { buildToolRegistry } from '../utils/tool-registry.js';
import { collectInventory } from '../utils/eval-inventory.js';
import type { InventoryResult } from '../utils/eval-inventory.js';
import type {
EvalCaseRecord,
EvalFileAnalysis,
} from '../utils/eval-analysis.js';
function makeCase(overrides: Partial<EvalCaseRecord> = {}): EvalCaseRecord {
return {
filePath: '/repo/evals/test.eval.ts',
relativePath: 'evals/test.eval.ts',
helperName: 'evalTest',
baseHelperName: 'evalTest',
policy: 'USUALLY_PASSES',
name: 'test case',
hasFiles: false,
hasPrompt: true,
toolReferences: [],
location: { line: 1, column: 1 },
...overrides,
};
}
function makeInventory(
cases: EvalCaseRecord[],
overrides: Partial<InventoryResult> = {},
): InventoryResult {
return {
totalFiles: 1,
totalCases: cases.length,
repoRoot: '/repo',
files: [] as EvalFileAnalysis[],
cases,
diagnostics: [],
...overrides,
};
}
describe('eval-coverage', () => {
const registry = buildToolRegistry();
describe('computeCoverage', () => {
it('reports totals consistently with registry', () => {
const result = computeCoverage(makeInventory([]), registry);
expect(result.totalTools).toBe(registry.totalTools);
expect(result.coveredCount + result.uncoveredCount).toBe(
result.totalTools,
);
});
it('marks all tools uncovered when inventory is empty', () => {
const result = computeCoverage(makeInventory([]), registry);
expect(result.coveredCount).toBe(0);
expect(result.uncoveredCount).toBe(registry.totalTools);
expect(result.covered).toEqual([]);
expect(result.coveragePercent).toBe(0);
});
it('marks all tools uncovered when no eval case has tool references', () => {
const result = computeCoverage(
makeInventory([
makeCase({ toolReferences: [] }),
makeCase({ name: 'another', toolReferences: [] }),
]),
registry,
);
expect(result.coveredCount).toBe(0);
expect(result.uncoveredCount).toBe(registry.totalTools);
});
it('marks a single referenced tool as covered', () => {
const result = computeCoverage(
makeInventory([makeCase({ toolReferences: ['grep_search'] })]),
registry,
);
expect(result.coveredCount).toBe(1);
expect(result.covered[0].name).toBe('grep_search');
expect(result.covered[0].totalCaseCount).toBe(1);
});
it('counts multiple cases referencing the same tool', () => {
const result = computeCoverage(
makeInventory([
makeCase({
relativePath: 'evals/a.eval.ts',
toolReferences: ['grep_search'],
}),
makeCase({
relativePath: 'evals/a.eval.ts',
toolReferences: ['grep_search'],
}),
makeCase({
relativePath: 'evals/b.eval.ts',
toolReferences: ['grep_search'],
}),
]),
registry,
);
const grepEntry = result.covered.find((t) => t.name === 'grep_search');
expect(grepEntry).toBeDefined();
expect(grepEntry!.totalCaseCount).toBe(3);
expect(grepEntry!.files).toHaveLength(2);
});
it('correctly builds per-file case counts', () => {
const result = computeCoverage(
makeInventory([
makeCase({
relativePath: 'evals/file-a.eval.ts',
toolReferences: ['glob'],
}),
makeCase({
relativePath: 'evals/file-a.eval.ts',
toolReferences: ['glob'],
}),
makeCase({
relativePath: 'evals/file-b.eval.ts',
toolReferences: ['glob'],
}),
]),
registry,
);
const globEntry = result.covered.find((t) => t.name === 'glob');
expect(globEntry).toBeDefined();
const fileA = globEntry!.files.find(
(f) => f.relativePath === 'evals/file-a.eval.ts',
);
const fileB = globEntry!.files.find(
(f) => f.relativePath === 'evals/file-b.eval.ts',
);
expect(fileA?.caseCount).toBe(2);
expect(fileB?.caseCount).toBe(1);
});
it('computes correct policy distribution per tool', () => {
const result = computeCoverage(
makeInventory([
makeCase({
policy: 'ALWAYS_PASSES',
toolReferences: ['read_file'],
}),
makeCase({
policy: 'USUALLY_PASSES',
toolReferences: ['read_file'],
}),
makeCase({
policy: 'USUALLY_PASSES',
toolReferences: ['read_file'],
}),
]),
registry,
);
const readEntry = result.covered.find((t) => t.name === 'read_file');
expect(readEntry).toBeDefined();
expect(readEntry!.policyDistribution.ALWAYS_PASSES).toBe(1);
expect(readEntry!.policyDistribution.USUALLY_PASSES).toBe(2);
expect(readEntry!.policyDistribution.USUALLY_FAILS).toBeUndefined();
});
it('handles a case referencing multiple tools', () => {
const result = computeCoverage(
makeInventory([
makeCase({
toolReferences: ['glob', 'grep_search', 'read_file'],
}),
]),
registry,
);
const names = result.covered.map((t) => t.name);
expect(names).toContain('glob');
expect(names).toContain('grep_search');
expect(names).toContain('read_file');
expect(result.coveredCount).toBe(3);
});
it('resolves legacy aliases to canonical names', () => {
const result = computeCoverage(
makeInventory([makeCase({ toolReferences: ['search_file_content'] })]),
registry,
);
const names = result.covered.map((t) => t.name);
expect(names).toContain('grep_search');
expect(names).not.toContain('search_file_content');
});
it('ignores unrecognized tool names silently', () => {
const result = computeCoverage(
makeInventory([makeCase({ toolReferences: ['nonexistent_tool_xyz'] })]),
registry,
);
expect(result.covered.map((t) => t.name)).not.toContain(
'nonexistent_tool_xyz',
);
expect(result.coveredCount).toBe(0);
});
it('sorts covered tools alphabetically', () => {
const result = computeCoverage(
makeInventory([
makeCase({ toolReferences: ['write_file'] }),
makeCase({ toolReferences: ['glob'] }),
makeCase({ toolReferences: ['grep_search'] }),
]),
registry,
);
const names = result.covered.map((t) => t.name);
expect(names).toEqual([...names].sort());
});
it('sorts uncovered tools alphabetically', () => {
const result = computeCoverage(makeInventory([]), registry);
const names = result.uncovered.map((t) => t.name);
expect(names).toEqual([...names].sort());
});
it('sorts files within a covered entry alphabetically', () => {
const result = computeCoverage(
makeInventory([
makeCase({
relativePath: 'evals/z-last.eval.ts',
toolReferences: ['glob'],
}),
makeCase({
relativePath: 'evals/a-first.eval.ts',
toolReferences: ['glob'],
}),
]),
registry,
);
const globEntry = result.covered.find((t) => t.name === 'glob')!;
expect(globEntry.files[0].relativePath).toBe('evals/a-first.eval.ts');
expect(globEntry.files[1].relativePath).toBe('evals/z-last.eval.ts');
});
it('computes coverage percent correctly', () => {
const totalTools = registry.totalTools;
const halfTools = [...registry.tools.keys()].slice(
0,
Math.floor(totalTools / 2),
);
const cases = halfTools.map((name) =>
makeCase({ toolReferences: [name] }),
);
const result = computeCoverage(makeInventory(cases), registry);
const expected = Math.round((halfTools.length / totalTools) * 1000) / 10;
expect(result.coveragePercent).toBe(expected);
});
it('coveragePercent is 0 for empty inventory', () => {
const result = computeCoverage(makeInventory([]), registry);
expect(result.coveragePercent).toBe(0);
});
it('resolves absolute diagnostic file paths to relative paths', () => {
const diagnostic = {
severity: 'warning' as const,
message: 'Could not resolve policy',
filePath: '/repo/evals/bad.eval.ts',
location: { line: 5, column: 3 },
};
const result = computeCoverage(
makeInventory([], { diagnostics: [diagnostic], repoRoot: '/repo' }),
registry,
);
expect(result.diagnostics).toHaveLength(1);
expect(result.diagnostics[0]).toMatchObject({
severity: 'warning',
message: 'Could not resolve policy',
filePath: 'evals/bad.eval.ts',
location: { line: 5, column: 3 },
});
});
});
describe('formatCoverageReport', () => {
function makeCoverageResult(
overrides: Partial<CoverageResult> = {},
): CoverageResult {
return {
totalTools: 26,
coveredCount: 0,
uncoveredCount: 26,
coveragePercent: 0,
covered: [],
uncovered: [],
diagnostics: [],
...overrides,
};
}
it('includes the title and summary line', () => {
const result = makeCoverageResult({
totalTools: 26,
coveredCount: 10,
uncoveredCount: 16,
coveragePercent: 38.5,
});
const report = formatCoverageReport(result);
expect(report).toContain('Eval Coverage Report');
expect(report).toContain('10 / 26 tools covered (38.5%)');
});
it('includes Covered Tools section header', () => {
const report = formatCoverageReport(makeCoverageResult());
expect(report).toContain('Covered Tools');
});
it('includes Uncovered Tools section header', () => {
const report = formatCoverageReport(makeCoverageResult());
expect(report).toContain('Uncovered Tools');
});
it('shows (none) when no tools are covered', () => {
const report = formatCoverageReport(makeCoverageResult());
expect(report).toContain('(none)');
});
it('shows full-coverage message when all tools are covered', () => {
const result = makeCoverageResult({
coveredCount: 26,
uncoveredCount: 0,
uncovered: [],
});
const report = formatCoverageReport(result);
expect(report).toContain('(none — full coverage!)');
});
it('lists covered tools with case and file counts', () => {
const coveredEntry: CoveredToolEntry = {
name: 'grep_search',
category: 'file-system',
totalCaseCount: 5,
files: [
{
relativePath: 'evals/grep_search_functionality.eval.ts',
caseCount: 5,
policyDistribution: { USUALLY_PASSES: 5 },
},
],
policyDistribution: { USUALLY_PASSES: 5 },
};
const result = makeCoverageResult({
coveredCount: 1,
uncoveredCount: 25,
covered: [coveredEntry],
});
const report = formatCoverageReport(result);
expect(report).toContain('grep_search');
expect(report).toContain('5 cases');
expect(report).toContain('1 file');
expect(report).toContain('evals/grep_search_functionality.eval.ts');
expect(report).toContain('5 USUALLY_PASSES');
});
it('groups uncovered tools by category', () => {
const result = makeCoverageResult({
uncovered: [
{ name: 'web_fetch', category: 'web' },
{ name: 'google_web_search', category: 'web' },
{ name: 'glob', category: 'file-system' },
],
});
const report = formatCoverageReport(result);
expect(report).toContain('[web]');
expect(report).toContain('[file-system]');
expect(report).toContain('web_fetch');
expect(report).toContain('google_web_search');
expect(report).toContain('glob');
});
it('does not crash when a tool has an undefined category', () => {
const result = makeCoverageResult({
uncovered: [
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{ name: 'mystery_tool', category: undefined as any },
],
});
expect(() => formatCoverageReport(result)).not.toThrow();
const report = formatCoverageReport(result);
expect(report).toContain('mystery_tool');
});
it('shows diagnostics section when diagnostics exist', () => {
const result = makeCoverageResult({
diagnostics: [
{
severity: 'warning',
message: 'Could not resolve policy',
filePath: 'evals/bad.eval.ts',
location: { line: 5, column: 3 },
},
],
});
const report = formatCoverageReport(result);
expect(report).toContain('Diagnostics');
expect(report).toContain('⚠');
expect(report).toContain('Could not resolve policy');
});
it('omits diagnostics section when there are no diagnostics', () => {
const result = makeCoverageResult({ diagnostics: [] });
const report = formatCoverageReport(result);
expect(report).not.toContain('Diagnostics');
expect(report).not.toContain('⚠');
});
it('shows policy distribution inside file entries', () => {
const coveredEntry: CoveredToolEntry = {
name: 'glob',
category: 'file-system',
totalCaseCount: 3,
files: [
{
relativePath: 'evals/frugal.eval.ts',
caseCount: 3,
policyDistribution: {
ALWAYS_PASSES: 1,
USUALLY_PASSES: 2,
},
},
],
policyDistribution: { ALWAYS_PASSES: 1, USUALLY_PASSES: 2 },
};
const result = makeCoverageResult({
coveredCount: 1,
covered: [coveredEntry],
});
const report = formatCoverageReport(result);
expect(report).toContain('1 ALWAYS_PASSES');
expect(report).toContain('2 USUALLY_PASSES');
});
});
describe('integration — real evals directory', () => {
it('produces a valid coverage result from the real eval suite', async () => {
const repoRoot = path.resolve(import.meta.dirname, '../../');
const inventory = await collectInventory(repoRoot);
const result = computeCoverage(inventory, registry);
expect(result.totalTools).toBe(registry.totalTools);
expect(result.coveredCount + result.uncoveredCount).toBe(
result.totalTools,
);
expect(result.coveragePercent).toBeGreaterThanOrEqual(0);
expect(result.coveragePercent).toBeLessThanOrEqual(100);
expect(result.coveredCount).toBeGreaterThanOrEqual(5);
const grepEntry = result.covered.find((t) => t.name === 'grep_search');
expect(grepEntry).toBeDefined();
expect(grepEntry!.totalCaseCount).toBeGreaterThanOrEqual(1);
});
it('formats the real coverage report without throwing', async () => {
const repoRoot = path.resolve(import.meta.dirname, '../../');
const inventory = await collectInventory(repoRoot);
const result = computeCoverage(inventory, registry);
const report = formatCoverageReport(result);
expect(typeof report).toBe('string');
expect(report).toContain('Eval Coverage Report');
expect(report).toContain('Covered Tools');
expect(report).toContain('Uncovered Tools');
});
});
});
+294
View File
@@ -0,0 +1,294 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import path from 'node:path';
import type { EvalAnalysisDiagnostic, EvalPolicy } from './eval-analysis.js';
import type { InventoryResult } from './eval-inventory.js';
import { type ToolCategory, type ToolRegistry } from './tool-registry.js';
const POLICY_ORDER: EvalPolicy[] = [
'ALWAYS_PASSES',
'USUALLY_PASSES',
'USUALLY_FAILS',
'unknown',
];
const CATEGORY_ORDER: ToolCategory[] = [
'file-system',
'shell',
'web',
'planning',
'user-interaction',
'skills',
'task-tracker',
'agent',
'mcp',
];
export type PolicyDistribution = Partial<Record<EvalPolicy, number>>;
export interface ToolEvalFileEntry {
relativePath: string;
caseCount: number;
policyDistribution: PolicyDistribution;
}
export interface CoveredToolEntry {
name: string;
category: ToolCategory;
totalCaseCount: number;
files: ToolEvalFileEntry[];
policyDistribution: PolicyDistribution;
}
export interface UncoveredToolEntry {
name: string;
category: ToolCategory;
}
export interface CoverageResult {
totalTools: number;
coveredCount: number;
uncoveredCount: number;
coveragePercent: number;
covered: CoveredToolEntry[];
uncovered: UncoveredToolEntry[];
diagnostics: readonly EvalAnalysisDiagnostic[];
}
/**
* Computes eval coverage by cross-referencing the inventory's tool references
* against the tool registry.
*/
export function computeCoverage(
inventory: InventoryResult,
registry: ToolRegistry,
): CoverageResult {
const toolFileMap = new Map<
string,
Map<string, { caseCount: number; policyDist: PolicyDistribution }>
>();
for (const toolName of registry.tools.keys()) {
toolFileMap.set(toolName, new Map());
}
for (const evalCase of inventory.cases) {
for (const toolName of evalCase.toolReferences) {
const canonicalName = registry.aliasLookup.get(toolName) ?? toolName;
if (!registry.tools.has(canonicalName)) {
continue;
}
let fileMap = toolFileMap.get(canonicalName);
if (!fileMap) {
fileMap = new Map();
toolFileMap.set(canonicalName, fileMap);
}
const existingEntry = fileMap.get(evalCase.relativePath);
if (existingEntry) {
existingEntry.caseCount += 1;
existingEntry.policyDist[evalCase.policy] =
(existingEntry.policyDist[evalCase.policy] ?? 0) + 1;
} else {
const policyDist: PolicyDistribution = {};
policyDist[evalCase.policy] = 1;
fileMap.set(evalCase.relativePath, { caseCount: 1, policyDist });
}
}
}
const covered: CoveredToolEntry[] = [];
const uncovered: UncoveredToolEntry[] = [];
for (const [toolName, fileMap] of toolFileMap) {
const entry = registry.tools.get(toolName);
if (!entry) {
continue;
}
if (fileMap.size === 0) {
uncovered.push({ name: toolName, category: entry.category });
continue;
}
const files: ToolEvalFileEntry[] = [];
const aggregateDist: PolicyDistribution = {};
let totalCaseCount = 0;
for (const relativePath of [...fileMap.keys()].sort()) {
const fileEntry = fileMap.get(relativePath)!;
files.push({
relativePath,
caseCount: fileEntry.caseCount,
policyDistribution: fileEntry.policyDist,
});
totalCaseCount += fileEntry.caseCount;
for (const policy of POLICY_ORDER) {
const count = fileEntry.policyDist[policy];
if (count !== undefined) {
aggregateDist[policy] = (aggregateDist[policy] ?? 0) + count;
}
}
}
covered.push({
name: toolName,
category: entry.category,
totalCaseCount,
files,
policyDistribution: aggregateDist,
});
}
covered.sort((a, b) => a.name.localeCompare(b.name, 'en'));
uncovered.sort((a, b) => a.name.localeCompare(b.name, 'en'));
const totalTools = registry.totalTools;
const coveredCount = covered.length;
const uncoveredCount = uncovered.length;
const coveragePercent =
totalTools === 0 ? 0 : Math.round((coveredCount / totalTools) * 1000) / 10;
const filePathLookup = new Map<string, string>();
for (const f of inventory.files) {
filePathLookup.set(f.filePath, f.relativePath);
}
const resolvedDiagnostics: EvalAnalysisDiagnostic[] =
inventory.diagnostics.map((d) => {
if (d.filePath === '<inline>') {
return d;
}
const relative = filePathLookup.get(d.filePath);
if (relative !== undefined) {
return { ...d, filePath: relative };
}
if (path.isAbsolute(d.filePath) && inventory.repoRoot) {
return {
...d,
filePath: path
.relative(inventory.repoRoot, d.filePath)
.replace(/\\/g, '/'),
};
}
return d;
});
return {
totalTools,
coveredCount,
uncoveredCount,
coveragePercent,
covered,
uncovered,
diagnostics: resolvedDiagnostics,
};
}
/**
* Formats a CoverageResult as a human-readable report string.
*/
export function formatCoverageReport(result: CoverageResult): string {
const lines: string[] = [];
lines.push('Eval Coverage Report');
lines.push('════════════════════');
lines.push('');
lines.push(
`${result.coveredCount} / ${result.totalTools} tools covered (${result.coveragePercent}%)`,
);
lines.push('');
lines.push('Covered Tools');
lines.push('─────────────');
if (result.covered.length === 0) {
lines.push(' (none)');
} else {
for (const tool of result.covered) {
const caseLabel = tool.totalCaseCount === 1 ? 'case' : 'cases';
const fileLabel = tool.files.length === 1 ? 'file' : 'files';
lines.push(
`${tool.name} (${tool.totalCaseCount} ${caseLabel} across ${tool.files.length} ${fileLabel})`,
);
for (const file of tool.files) {
const policyParts = formatPolicyDistribution(file.policyDistribution);
lines.push(` ${file.relativePath} (${policyParts})`);
}
}
}
lines.push('');
lines.push('Uncovered Tools');
lines.push('───────────────');
if (result.uncovered.length === 0) {
lines.push(' (none — full coverage!)');
} else {
const byCategory = new Map<string, string[]>();
for (const tool of result.uncovered) {
const category = tool.category || 'unknown';
const group = byCategory.get(category);
if (group) {
group.push(tool.name);
} else {
byCategory.set(category, [tool.name]);
}
}
const maxCatLen = Math.max(
...CATEGORY_ORDER.filter((c) => byCategory.has(c)).map((c) => c.length),
...[...byCategory.keys()]
.filter((c) => !CATEGORY_ORDER.includes(c as ToolCategory))
.map((c) => c.length),
);
const renderCategory = (category: string) => {
const names = byCategory.get(category);
if (!names || names.length === 0) {
return;
}
const padded = `[${category}]`.padEnd(maxCatLen + 2);
lines.push(`${padded} ${names.join(', ')}`);
};
for (const category of CATEGORY_ORDER) {
renderCategory(category);
}
for (const category of byCategory.keys()) {
if (!CATEGORY_ORDER.includes(category as ToolCategory)) {
renderCategory(category);
}
}
}
lines.push('');
if (result.diagnostics.length > 0) {
lines.push(`Diagnostics (${result.diagnostics.length})`);
lines.push('────────────────');
for (const diagnostic of result.diagnostics) {
lines.push(
`${diagnostic.filePath}:${diagnostic.location.line}:${diagnostic.location.column}${diagnostic.message}`,
);
}
lines.push('');
}
return lines.join('\n');
}
function formatPolicyDistribution(dist: PolicyDistribution): string {
const parts: string[] = [];
for (const policy of POLICY_ORDER) {
const count = dist[policy];
if (count !== undefined && count > 0) {
parts.push(`${count} ${policy}`);
}
}
return parts.length > 0 ? parts.join(', ') : '0 cases';
}
@@ -0,0 +1,9 @@
__pycache__/
*.pyc
*.pyo
*.pyd
.pytest_cache/
venv/
experimental/
tests/
.env
@@ -9,7 +9,7 @@ You are a triage coordinator agent. When presented with a GitHub issue:
### Triage Workflow:
1. **Invoke the `quality` skill** to analyze the issue's quality.
2. If the quality is **"OK"**:
- **Codebase Exploration:** Explore the repository codebase using your search and navigation tools (such as `list_dir`, `find_by_name`, and `grep_search`) to locate the actual files, functions, and test files related to the issue. Do not guess or assume file paths.
- **Codebase Exploration:** Explore the repository codebase using your search and navigation tools (such as `list_directory`, `find_file`, and `search_directory`) to locate the actual files, functions, and test files related to the issue. Do not guess or assume file paths.
- **Invoke the `effort` skill** to estimate the work required.
- **Invoke the `spec_generator` skill** to create the technical implementation plan that follows the strict template.
3. If the quality is **not "OK"** (e.g., SPAM, EMPTY, FEATURE, or NEEDS_INFO), populate empty/default values for the effort and spec fields as specified below.
@@ -0,0 +1,11 @@
FROM python:3.13-slim
WORKDIR /app
ENV PYTHONUNBUFFERED=1
RUN apt-get update && apt-get install -y \
git \
curl \
&& rm -rf /var/lib/apt/lists/*
RUN git clone https://github.com/google-gemini/gemini-cli.git /opt/gemini-cli
COPY . .
RUN pip install --no-cache-dir -r requirements.txt
CMD ["python", "main.py"]
@@ -1,2 +1,5 @@
google-cloud-firestore>=2.15.0, <3.0.0
google-cloud-pubsub
google-cloud-storage
google-cloud-pubsub
google-antigravity>=0.1.0
python-dotenv
@@ -0,0 +1,52 @@
import unittest
from unittest.mock import patch
from google.antigravity.types import Text
from utils.agent_logger import extract_final_output, log_agent_run
from triage_orchestrator import process_issue_triage
class TestAgentLogger(unittest.TestCase):
def test_extract_final_output(self):
"""Verifies final step output extraction and step filtering."""
self.assertEqual(extract_final_output(None), "")
self.assertEqual(extract_final_output([]), "")
chunks = [
Text(text="Thought 1", step_index=0),
Text(text="Result: SUCCESS", step_index=1),
Text(text=" Additional", step_index=1),
]
self.assertEqual(
extract_final_output(chunks), "Result: SUCCESS Additional"
)
@patch("utils.agent_logger.upload_to_bucket")
def test_log_agent_run(self, mock_upload):
"""Verifies log routing to GCS and serialization behavior."""
chunks = [Text(text="Thought", step_index=0)]
# Test OFF mode (should not call upload)
log_agent_run("repo", 42, chunks, mode="OFF")
mock_upload.assert_not_called()
# Test GCS mode (should call upload and serialize the chunks)
log_agent_run("repo", 42, chunks, mode="GCS")
mock_upload.assert_called_once()
# Verify GCS upload payload contains our serialized text
args, _ = mock_upload.call_args
self.assertIn('"text": "Thought"', args[2])
@patch("triage_orchestrator.upload_to_bucket")
@patch("triage_orchestrator.Agent")
def test_process_issue_triage_error(self, mock_agent, mock_upload):
"""Verifies error handling and GCS upload on SDK failures."""
mock_agent.return_value.__aenter__.side_effect = Exception("API Error")
success, raw_output = process_issue_triage({"issue_number": 42})
self.assertFalse(success)
self.assertIn("API Error", raw_output)
mock_upload.assert_called_once()
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,299 @@
"""
Integration tests for main.py.
Verifies workflow execution across main.py and issues_store.py.
External network boundaries (Firestore database client and LLM inference)
are mocked.
"""
import unittest
from unittest.mock import patch, MagicMock
import os
import json
import base64
from db.issues_store import IssuesStore, ClaimAction, ReleaseAction
import main as main_module
from main import main
VALID_WORKABLE_SPEC = {
"issue_id": "owner/repo#42",
"summary": {"problem": "p", "root_cause": "r", "context": "c"},
"implementation_plan": {
"files_to_modify": ["src/app.ts"], "steps": ["Fix bug"]
},
"testing_strategy": {
"test_file": "tests/app.test.ts",
"expected_behavior": "Pass",
"verification_steps": ["Check"],
"framework": "Vitest"
}
}
INTEGRATION_OK_PAYLOAD = {
"triage_metadata": {
"quality": "OK",
"reasoning": "Actionable bug report.",
"comment": "",
"effort_estimate": "SMALL",
"effort_reasoning": "Easy fix."
},
"workable_spec": VALID_WORKABLE_SPEC
}
INTEGRATION_NEEDS_INFO_PAYLOAD = {
"triage_metadata": {
"quality": "NEEDS_INFO",
"reasoning": (
"The issue reports a crash on startup, but lacks any actual "
"details."
),
"comment": (
"Hi! Thanks for commenting on this issue, we need more "
"information to triage the bug."
),
"effort_estimate": "",
"effort_reasoning": ""
},
"workable_spec": {}
}
INTEGRATION_INVALID_EFFORT_PAYLOAD = {
"triage_metadata": {
"quality": "OK",
"reasoning": "Some reasoning.",
"comment": "",
"effort_estimate": "HUGE",
"effort_reasoning": "This will take a while to fix."
},
"workable_spec": VALID_WORKABLE_SPEC
}
class TestIntegrationMain(unittest.TestCase):
def setUp(self):
# Mock environment variables
self.env_patcher = patch.dict(os.environ, {
"ISSUE_DETAILS": base64.b64encode(json.dumps({
"issue_number": 42,
"repository": "owner/repo",
"title": "Fix crash",
"body": "App crashes on start"
}).encode("utf-8")).decode("utf-8"),
"WORKFLOW_EXECUTION_ID": "test-workflow-exec-101",
"PROJECT_ID": "test-gcp-project",
"EGRESS_TOPIC_ID": "test-egress-actions"
})
self.env_patcher.start()
# Mock the Firestore database client at the network boundary
self.mock_db = MagicMock()
self.db_patcher = patch(
"main.firestore.Client", return_value=self.mock_db
)
self.db_patcher.start()
self.mock_doc_ref = MagicMock()
self.mock_snapshot = MagicMock()
self.mock_transaction = MagicMock()
self.mock_db.collection.return_value.document.return_value = (
self.mock_doc_ref
)
self.mock_db.transaction.return_value = self.mock_transaction
self.mock_doc_ref.get.return_value = self.mock_snapshot
self.mock_snapshot.exists = True
# In-memory document state simulation
self.stored_data = {}
self.mock_snapshot.to_dict.side_effect = lambda: self.stored_data
def mock_update(doc_ref, updates):
if "status" in updates:
self.stored_data["status"] = updates["status"]
if "workable_spec" in updates:
self.stored_data["workable_spec"] = updates["workable_spec"]
if "lock.holder" in updates:
if "lock" not in self.stored_data:
self.stored_data["lock"] = {}
self.stored_data["lock"]["holder"] = updates["lock.holder"]
# Bind mock_update to execute whenever transaction.update is invoked
self.mock_transaction.update.side_effect = mock_update
# Mock IssuesStore instance
self.mock_store = MagicMock()
self.store_patcher = patch(
"main.IssuesStore", return_value=self.mock_store
)
self.store_patcher.start()
# Wire mock_store methods to execute real store logic against mock_db
real_store = IssuesStore(self.mock_db, "issues")
self.mock_store.acquire_lock.side_effect = real_store.acquire_lock
self.mock_store.release_lock.side_effect = real_store.release_lock
def tearDown(self):
self.store_patcher.stop()
self.db_patcher.stop()
self.env_patcher.stop()
@patch("main.process_issue_triage")
@patch("main.send_label_action")
def test_ok_quality_flow(self, mock_send_label, mock_triage):
"""Verifies end-to-end flow for OK quality issues."""
self.stored_data = {
"status": "UNTRIAGED",
"triage_attempts": 0,
"lock": {"holder": None, "expires_at": None}
}
mock_triage.return_value = (True, json.dumps(INTEGRATION_OK_PAYLOAD))
with self.assertRaises(SystemExit) as ctx:
main()
self.assertEqual(ctx.exception.code, 0)
self.mock_store.acquire_lock.assert_called_once_with(
"owner", "repo", 42, "test-workflow-exec-101"
)
self.mock_store.release_lock.assert_called_once_with(
"owner",
"repo",
42,
"test-workflow-exec-101",
success=True,
status="TRIAGED",
workable_spec=INTEGRATION_OK_PAYLOAD["workable_spec"],
)
mock_send_label.assert_called_once_with(
"owner", "repo", 42, ["effort/small"]
)
# Verify state transition in store data
self.assertEqual(self.stored_data["status"], "TRIAGED")
self.assertEqual(
self.stored_data["workable_spec"], VALID_WORKABLE_SPEC
)
self.assertIsNone(self.stored_data["lock"]["holder"])
@patch("main.process_issue_triage")
@patch("main.send_comment_action")
def test_needs_info_flow(self, mock_send_comment, mock_triage):
"""Verifies end-to-end flow for NEEDS_INFO issues."""
self.stored_data = {
"status": "UNTRIAGED",
"triage_attempts": 0,
"lock": {"holder": None, "expires_at": None}
}
payload_data = json.dumps(INTEGRATION_NEEDS_INFO_PAYLOAD)
mock_triage.return_value = (True, payload_data)
with self.assertRaises(SystemExit) as ctx:
main()
self.assertEqual(ctx.exception.code, 0)
self.mock_store.acquire_lock.assert_called_once_with(
"owner", "repo", 42, "test-workflow-exec-101"
)
self.mock_store.release_lock.assert_called_once_with(
"owner",
"repo",
42,
"test-workflow-exec-101",
success=True,
status="NEEDS_INFO",
)
expected_comment = (
INTEGRATION_NEEDS_INFO_PAYLOAD["triage_metadata"]["comment"]
)
mock_send_comment.assert_called_once_with(
"owner", "repo", 42, expected_comment
)
self.assertEqual(self.stored_data["status"], "NEEDS_INFO")
self.assertIsNone(self.stored_data["lock"]["holder"])
@patch("main.process_issue_triage")
@patch("main.send_label_action")
def test_auto_close_flows(self, mock_send_label, mock_triage):
"""Verifies end-to-end flow for auto-closed issues."""
for quality in ["SPAM", "EMPTY", "FEATURE"]:
self.mock_store.acquire_lock.reset_mock()
self.mock_store.release_lock.reset_mock()
mock_send_label.reset_mock()
mock_triage.reset_mock()
self.stored_data = {
"status": "UNTRIAGED",
"triage_attempts": 0,
"lock": {"holder": None, "expires_at": None}
}
payload = {"triage_metadata": {"quality": quality}}
mock_triage.return_value = (True, json.dumps(payload))
with self.assertRaises(SystemExit) as ctx:
main()
self.assertEqual(ctx.exception.code, 0)
self.mock_store.acquire_lock.assert_called_once_with(
"owner", "repo", 42, "test-workflow-exec-101"
)
self.mock_store.release_lock.assert_called_once_with(
"owner",
"repo",
42,
"test-workflow-exec-101",
success=True,
status="AUTO_CLOSE",
)
mock_send_label.assert_called_once_with(
"owner", "repo", 42, ["auto-close"]
)
self.assertEqual(self.stored_data["status"], "AUTO_CLOSE")
self.assertIsNone(self.stored_data["lock"]["holder"])
@patch("main.process_issue_triage")
def test_validation_failure_triggers_retry(self, mock_triage):
"""Verifies retry state transition when validation fails."""
self.stored_data = {
"status": "UNTRIAGED",
"triage_attempts": 0,
"lock": {"holder": None, "expires_at": None}
}
payload_data = json.dumps(INTEGRATION_INVALID_EFFORT_PAYLOAD)
mock_triage.return_value = (True, payload_data)
with self.assertRaises(SystemExit) as ctx:
main()
self.assertEqual(ctx.exception.code, 1)
self.mock_store.acquire_lock.assert_called_once_with(
"owner", "repo", 42, "test-workflow-exec-101"
)
self.mock_store.release_lock.assert_called_once_with(
"owner", "repo", 42, "test-workflow-exec-101", success=False
)
self.assertEqual(self.stored_data["status"], "UNTRIAGED")
self.assertIsNone(self.stored_data["lock"]["holder"])
def test_max_attempts_escalates_to_needs_human(self):
"""Verifies escalation to NEEDS_HUMAN when triage_attempts >= 2."""
self.stored_data = {
"status": "UNTRIAGED",
"triage_attempts": 2,
"lock": {"holder": None, "expires_at": None},
}
with self.assertRaises(SystemExit) as ctx:
main()
self.assertEqual(ctx.exception.code, 0)
self.mock_store.acquire_lock.assert_called_once_with(
"owner", "repo", 42, "test-workflow-exec-101"
)
self.assertEqual(self.stored_data["status"], "NEEDS_HUMAN")
if __name__ == "__main__":
unittest.main()
@@ -1,16 +1,95 @@
"""
Handles LLM inference for issue triage.
(Stubbed implementation for execution loop integration).
"""
import os
import asyncio
from utils.agent_logger import (
upload_to_bucket,
log_agent_run,
extract_final_output,
)
from google.antigravity import Agent, LocalAgentConfig
from google.antigravity.hooks.policy import allow, deny
def process_issue_triage(payload: dict) -> tuple[bool, str]:
"""
Stubbed entrypoint for issue triage processing.
Args:
payload: Dictionary containing issue details (issue_number, repository).
Returns:
tuple[bool, str]: (success, raw_output)
LLM inference via Antigravity SDK.
"""
return False, "Not implemented: LLM triage orchestrator"
issue_num = payload.get("issue_number")
title = payload.get("title", "")
body = payload.get("body", "")
repo_name = payload.get("repository", "")
current_dir = os.path.dirname(os.path.abspath(__file__))
system_prompt_path = os.path.join(
current_dir, ".gemini", "triage_orchestrator.md"
)
target_cwd = os.environ.get("TARGET_CWD", "/opt/gemini-cli")
gcs_logging = os.environ.get("GCS_LOGGING", "GCS").upper()
policies = [
# Deny all tools by default
deny("*"),
# Whitelist specific read-only and skill tools
allow("view_file"),
allow("list_directory"),
allow("find_file"),
allow("search_directory"),
allow("activate_skill"),
allow("finish")
]
with open(system_prompt_path, "r", encoding="utf-8") as f:
system_instructions = f.read()
skills_dir = os.path.join(current_dir, ".gemini", "skills")
prompt = (
f"Repository: {repo_name}\n"
f"Issue Number: {issue_num}\n"
f"Title: {title}\n"
f"Description: {body}"
)
async def run_triage():
config = LocalAgentConfig(
system_instructions=system_instructions,
skills_paths=[skills_dir],
api_key=os.environ.get("GEMINI_API_KEY"),
workspaces=[target_cwd, skills_dir],
policies=policies,
)
print(
f"[LOGIC] [Issue #{issue_num}] Initializing Antigravity Agent..."
)
async with Agent(config) as agent:
print(
f"[LOGIC] [Issue #{issue_num}] Sending triage request..."
)
response = await agent.chat(prompt)
# Resolve all execution chunks (thoughts, tool calls, and results)
resolved_chunks = await response.resolve()
# Extract the final step's output
text_output = extract_final_output(resolved_chunks)
log_agent_run(
repo_name,
issue_num,
resolved_chunks,
mode=gcs_logging,
)
print(f"[LOGIC] Agent Response:\n{text_output}")
return True, text_output
try:
success, raw_output = asyncio.run(run_triage())
return success, raw_output
except Exception as e:
error_msg = f"Error during Antigravity Agent run: {e}"
print(f"[LOGIC] {error_msg}")
if gcs_logging == "GCS":
# If agent failed/crashed before chunks resolved, upload traceback string directly
upload_to_bucket(repo_name, issue_num, error_msg)
return False, error_msg
@@ -0,0 +1,119 @@
import json
import datetime
import os
import uuid
from google.cloud import storage
from google.antigravity.types import Text
BUCKET_NAME = os.environ.get("TRIAGE_DEBUG_LOGS_BUCKET")
_storage_client = None
def _get_storage_client() -> storage.Client:
global _storage_client
if _storage_client is None:
_storage_client = storage.Client()
return _storage_client
def upload_to_bucket(repository: str, issue_number: str | int, payload: str) -> None:
"""
Uploads a string payload directly to the triage debug logs GCS bucket.
"""
if not payload or not BUCKET_NAME:
if not BUCKET_NAME:
print("[LOGIC] Warning: Missing TRIAGE_DEBUG_LOGS_BUCKET, skipping GCS upload.")
return
try:
storage_client = _get_storage_client()
bucket = storage_client.bucket(BUCKET_NAME)
timestamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%d_%H%M%S")
safe_repo = str(repository).replace("/", "_") if repository else "unknown"
unique_id = uuid.uuid4().hex[:8]
blob_name = f"{safe_repo}/issue_{issue_number}_{timestamp}_{unique_id}_debug.log"
blob = bucket.blob(blob_name)
blob.upload_from_string(payload, content_type="text/plain")
print(f"[LOGIC] Uploaded debug logs to gs://{BUCKET_NAME}/{blob_name}")
except Exception as e:
print(f"[LOGIC] Error uploading debug logs to GCS: {e}")
def _format_debug_trajectory(resolved_chunks: list) -> str:
"""
Formats Antigravity Agent resolved stream chunks (thoughts, tool calls,
tool outputs) into a structured, human-readable JSON string.
"""
if not resolved_chunks:
return "[]"
serializable = []
for chunk in resolved_chunks:
try:
dumped = chunk.model_dump()
except AttributeError:
dumped = chunk.dict()
dumped["chunk_type"] = chunk.__class__.__name__
serializable.append(dumped)
return json.dumps(serializable, indent=2, default=str)
def log_agent_run(
repository: str,
issue_number: str | int,
resolved_chunks: list,
mode: str = "GCS",
) -> None:
"""
Logs the agent execution trajectory based on the mode parameter.
Modes:
- "LOCAL": Saves log file to local disk (requires LOCAL_LOG_DIR env var).
- "GCS": Uploads log file to the GCS bucket.
- Any other mode (e.g. "OFF"): Skips logging.
"""
mode_upper = str(mode).upper()
if not resolved_chunks or mode_upper not in ("LOCAL", "GCS"):
return
try:
debug_log_str = _format_debug_trajectory(resolved_chunks)
if mode_upper == "LOCAL":
local_dir = os.environ.get("LOCAL_LOG_DIR")
if not local_dir:
print(
"[LOGIC] Error: LOCAL_LOG_DIR env var is not configured."
)
return
os.makedirs(local_dir, exist_ok=True)
log_path = os.path.join(
local_dir, f"gemini_cli_{issue_number}_debug.json"
)
with open(log_path, "w", encoding="utf-8") as f:
f.write(debug_log_str)
print(f"[LOGIC] 📄 Saved local agent trajectory log to: {log_path}")
elif mode_upper == "GCS":
upload_to_bucket(repository, issue_number, debug_log_str)
except Exception as e:
print(f"[LOGIC] Error logging agent run: {e}")
def extract_final_output(resolved_chunks: list) -> str:
"""
Extracts the final response text generated by the agent during the last execution step.
Since the agent outputs conversational planning text during its tool-calling turns,
this function isolates the final turn (highest step_index) and filters for text chunks,
stripping away any intermediate planning text, thoughts, or markdown backticks around the JSON.
"""
if not resolved_chunks:
return ""
# Filter for Text chunks (skip Thought and ToolCall chunks)
text_chunks = [c for c in resolved_chunks if isinstance(c, Text)]
if not text_chunks:
return ""
# Final output is in the last step
last_step = text_chunks[-1].step_index
return "".join(c.text for c in text_chunks if c.step_index == last_step).strip()