fix(core): use tilth budget to format output and remove redundant prefix

This commit is contained in:
Michael Bleigh
2026-03-19 19:10:24 -07:00
parent c9a336976b
commit 7237159d9b
26 changed files with 1210 additions and 1038 deletions
+61
View File
@@ -106,6 +106,67 @@ organization.
ensures users maintain final control over which permitted servers are actually
active in their environment.
#### Required MCP Servers (preview)
**Default**: empty
Allows administrators to define MCP servers that are **always injected** into
the user's environment. Unlike the allowlist (which filters user-configured
servers), required servers are automatically added regardless of the user's
local configuration.
**Required Servers Format:**
```json
{
"requiredMcpServers": {
"corp-compliance-tool": {
"url": "https://mcp.corp/compliance",
"type": "http",
"trust": true,
"description": "Corporate compliance tool"
},
"internal-registry": {
"url": "https://registry.corp/mcp",
"type": "sse",
"authProviderType": "google_credentials",
"oauth": {
"scopes": ["https://www.googleapis.com/auth/scope"]
}
}
}
}
```
**Supported Fields:**
- `url`: (Required) The full URL of the MCP server endpoint.
- `type`: (Required) The connection type (`sse` or `http`).
- `trust`: (Optional) If set to `true`, tool execution will not require user
approval. Defaults to `true` for required servers.
- `description`: (Optional) Human-readable description of the server.
- `authProviderType`: (Optional) Authentication provider (`dynamic_discovery`,
`google_credentials`, or `service_account_impersonation`).
- `oauth`: (Optional) OAuth configuration including `scopes`, `clientId`, and
`clientSecret`.
- `targetAudience`: (Optional) OAuth target audience for service-to-service
auth.
- `targetServiceAccount`: (Optional) Service account email to impersonate.
- `headers`: (Optional) Additional HTTP headers to send with requests.
- `includeTools` / `excludeTools`: (Optional) Tool filtering lists.
- `timeout`: (Optional) Timeout in milliseconds for MCP requests.
**Client Enforcement Logic:**
- Required servers are injected **after** allowlist filtering, so they are
always available even if the allowlist is active.
- If a required server has the **same name** as a locally configured server, the
admin configuration **completely overrides** the local one.
- Required servers only support remote transports (`sse`, `http`). Local
execution fields (`command`, `args`, `env`, `cwd`) are not supported.
- Required servers can coexist with allowlisted servers — both features work
independently.
### Unmanaged Capabilities
**Enabled/Disabled** | Default: disabled
+5 -1
View File
@@ -1728,7 +1728,11 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `true`
- **`admin.mcp.config`** (object):
- **Description:** Admin-configured MCP servers.
- **Description:** Admin-configured MCP servers (allowlist).
- **Default:** `{}`
- **`admin.mcp.requiredConfig`** (object):
- **Description:** Admin-required MCP servers that are always injected.
- **Default:** `{}`
- **`admin.skills.enabled`** (boolean):
+1 -1
View File
@@ -14,7 +14,7 @@ export class AcpFileSystemService implements FileSystemService {
constructor(
private readonly connection: acp.AgentSideConnection,
private readonly sessionId: string,
private readonly capabilities: acp.FileSystemCapabilities,
private readonly capabilities: acp.FileSystemCapability,
private readonly fallback: FileSystemService,
) {}
@@ -264,6 +264,7 @@ describe('mcp list command', () => {
config: {
'allowed-server': { url: 'http://allowed' },
},
requiredConfig: {},
},
};
+20
View File
@@ -36,6 +36,7 @@ import {
Config,
resolveToRealPath,
applyAdminAllowlist,
applyRequiredServers,
getAdminBlockedMcpServersMessage,
type HookDefinition,
type HookEventName,
@@ -750,6 +751,25 @@ export async function loadCliConfig(
}
}
// Apply admin-required MCP servers (injected regardless of allowlist)
if (mcpEnabled) {
const requiredMcpConfig = settings.admin?.mcp?.requiredConfig;
if (requiredMcpConfig && Object.keys(requiredMcpConfig).length > 0) {
const requiredResult = applyRequiredServers(
mcpServers ?? {},
requiredMcpConfig,
);
mcpServers = requiredResult.mcpServers;
if (requiredResult.requiredServerNames.length > 0) {
coreEvents.emitConsoleLog(
'info',
`Admin-required MCP servers injected: ${requiredResult.requiredServerNames.join(', ')}`,
);
}
}
}
const isAcpMode = !!argv.acp || !!argv.experimentalAcp;
let clientName: string | undefined = undefined;
if (isAcpMode) {
+22
View File
@@ -2751,6 +2751,28 @@ describe('Settings Loading and Merging', () => {
expect(loadedSettings.merged.admin?.mcp?.config).toEqual(mcpServers);
});
it('should map requiredMcpConfig from remote settings', () => {
const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR);
const requiredMcpConfig = {
'corp-tool': {
url: 'https://mcp.corp/tool',
type: 'http' as const,
trust: true,
},
};
loadedSettings.setRemoteAdminSettings({
mcpSetting: {
mcpEnabled: true,
requiredMcpConfig,
},
});
expect(loadedSettings.merged.admin?.mcp?.requiredConfig).toEqual(
requiredMcpConfig,
);
});
it('should set skills based on unmanagedCapabilitiesEnabled', () => {
const loadedSettings = loadSettings();
loadedSettings.setRemoteAdminSettings({
+1
View File
@@ -480,6 +480,7 @@ export class LoadedSettings {
admin.mcp = {
enabled: mcpSetting?.mcpEnabled,
config: mcpSetting?.mcpConfig?.mcpServers,
requiredConfig: mcpSetting?.requiredMcpConfig,
};
admin.extensions = {
enabled: cliFeatureSetting?.extensionsSetting?.extensionsEnabled,
+83 -6
View File
@@ -12,7 +12,9 @@
import {
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
DEFAULT_MODEL_CONFIGS,
AuthProviderType,
type MCPServerConfig,
type RequiredMcpServerConfig,
type BugCommandSettings,
type TelemetrySettings,
type AuthType,
@@ -2435,7 +2437,7 @@ const SETTINGS_SCHEMA = {
category: 'Admin',
requiresRestart: false,
default: {} as Record<string, MCPServerConfig>,
description: 'Admin-configured MCP servers.',
description: 'Admin-configured MCP servers (allowlist).',
showInDialog: false,
mergeStrategy: MergeStrategy.REPLACE,
additionalProperties: {
@@ -2443,6 +2445,20 @@ const SETTINGS_SCHEMA = {
ref: 'MCPServerConfig',
},
},
requiredConfig: {
type: 'object',
label: 'Required MCP Config',
category: 'Admin',
requiresRestart: false,
default: {} as Record<string, RequiredMcpServerConfig>,
description: 'Admin-required MCP servers that are always injected.',
showInDialog: false,
mergeStrategy: MergeStrategy.REPLACE,
additionalProperties: {
type: 'object',
ref: 'RequiredMcpServerConfig',
},
},
},
},
skills: {
@@ -2567,11 +2583,72 @@ export const SETTINGS_SCHEMA_DEFINITIONS: Record<
type: 'string',
description:
'Authentication provider used for acquiring credentials (for example `dynamic_discovery`).',
enum: [
'dynamic_discovery',
'google_credentials',
'service_account_impersonation',
],
enum: Object.values(AuthProviderType),
},
targetAudience: {
type: 'string',
description:
'OAuth target audience (CLIENT_ID.apps.googleusercontent.com).',
},
targetServiceAccount: {
type: 'string',
description:
'Service account email to impersonate (name@project.iam.gserviceaccount.com).',
},
},
},
RequiredMcpServerConfig: {
type: 'object',
description:
'Admin-required MCP server configuration (remote transports only).',
additionalProperties: false,
properties: {
url: {
type: 'string',
description: 'URL for the required MCP server.',
},
type: {
type: 'string',
description: 'Transport type for the required server.',
enum: ['sse', 'http'],
},
headers: {
type: 'object',
description: 'Additional HTTP headers sent to the server.',
additionalProperties: { type: 'string' },
},
timeout: {
type: 'number',
description: 'Timeout in milliseconds for MCP requests.',
},
trust: {
type: 'boolean',
description:
'Marks the server as trusted. Defaults to true for admin-required servers.',
},
description: {
type: 'string',
description: 'Human-readable description of the server.',
},
includeTools: {
type: 'array',
description: 'Subset of tools enabled for this server.',
items: { type: 'string' },
},
excludeTools: {
type: 'array',
description: 'Tools disabled for this server.',
items: { type: 'string' },
},
oauth: {
type: 'object',
description: 'OAuth configuration for authenticating with the server.',
additionalProperties: true,
},
authProviderType: {
type: 'string',
description: 'Authentication provider used for acquiring credentials.',
enum: Object.values(AuthProviderType),
},
targetAudience: {
type: 'string',
@@ -224,6 +224,89 @@ describe('Admin Controls', () => {
const result = sanitizeAdminSettings(input);
expect(result.strictModeDisabled).toBe(true);
});
it('should parse requiredMcpServers from mcpConfigJson', () => {
const mcpConfig = {
mcpServers: {
'allowed-server': {
url: 'http://allowed.com',
type: 'sse' as const,
},
},
requiredMcpServers: {
'corp-tool': {
url: 'https://mcp.corp/tool',
type: 'http' as const,
trust: true,
description: 'Corp compliance tool',
},
},
};
const input: FetchAdminControlsResponse = {
mcpSetting: {
mcpEnabled: true,
mcpConfigJson: JSON.stringify(mcpConfig),
},
};
const result = sanitizeAdminSettings(input);
expect(result.mcpSetting?.mcpConfig?.mcpServers).toEqual(
mcpConfig.mcpServers,
);
expect(result.mcpSetting?.requiredMcpConfig).toEqual(
mcpConfig.requiredMcpServers,
);
});
it('should sort requiredMcpServers tool lists for stable comparison', () => {
const mcpConfig = {
requiredMcpServers: {
'corp-tool': {
url: 'https://mcp.corp/tool',
type: 'http' as const,
includeTools: ['toolC', 'toolA', 'toolB'],
excludeTools: ['toolZ', 'toolX'],
},
},
};
const input: FetchAdminControlsResponse = {
mcpSetting: {
mcpEnabled: true,
mcpConfigJson: JSON.stringify(mcpConfig),
},
};
const result = sanitizeAdminSettings(input);
const corpTool = result.mcpSetting?.requiredMcpConfig?.['corp-tool'];
expect(corpTool?.includeTools).toEqual(['toolA', 'toolB', 'toolC']);
expect(corpTool?.excludeTools).toEqual(['toolX', 'toolZ']);
});
it('should handle mcpConfigJson with only requiredMcpServers and no mcpServers', () => {
const mcpConfig = {
requiredMcpServers: {
'required-only': {
url: 'https://required.corp/tool',
type: 'http' as const,
},
},
};
const input: FetchAdminControlsResponse = {
mcpSetting: {
mcpEnabled: true,
mcpConfigJson: JSON.stringify(mcpConfig),
},
};
const result = sanitizeAdminSettings(input);
expect(result.mcpSetting?.mcpConfig?.mcpServers).toBeUndefined();
expect(result.mcpSetting?.requiredMcpConfig).toEqual(
mcpConfig.requiredMcpServers,
);
});
});
describe('isDeepStrictEqual verification', () => {
@@ -48,6 +48,16 @@ export function sanitizeAdminSettings(
}
}
}
if (mcpConfig.requiredMcpServers) {
for (const server of Object.values(mcpConfig.requiredMcpServers)) {
if (server.includeTools) {
server.includeTools.sort();
}
if (server.excludeTools) {
server.excludeTools.sort();
}
}
}
}
} catch (_e) {
// Ignore parsing errors
@@ -77,6 +87,7 @@ export function sanitizeAdminSettings(
mcpSetting: {
mcpEnabled: sanitized.mcpSetting?.mcpEnabled ?? false,
mcpConfig: mcpConfig ?? {},
requiredMcpConfig: mcpConfig?.requiredMcpServers,
},
};
}
@@ -5,8 +5,10 @@
*/
import { describe, it, expect } from 'vitest';
import { applyAdminAllowlist } from './mcpUtils.js';
import { applyAdminAllowlist, applyRequiredServers } from './mcpUtils.js';
import type { MCPServerConfig } from '../../config/config.js';
import { AuthProviderType } from '../../config/config.js';
import type { RequiredMcpServerConfig } from '../types.js';
describe('applyAdminAllowlist', () => {
it('should return original servers if no allowlist provided', () => {
@@ -111,3 +113,147 @@ describe('applyAdminAllowlist', () => {
expect(result.mcpServers['server1']?.includeTools).toEqual(['local-tool']);
});
});
describe('applyRequiredServers', () => {
it('should return original servers if no required servers provided', () => {
const mcpServers: Record<string, MCPServerConfig> = {
server1: { command: 'cmd1' },
};
const result = applyRequiredServers(mcpServers, undefined);
expect(result.mcpServers).toEqual(mcpServers);
expect(result.requiredServerNames).toEqual([]);
});
it('should return original servers if required servers is empty', () => {
const mcpServers: Record<string, MCPServerConfig> = {
server1: { command: 'cmd1' },
};
const result = applyRequiredServers(mcpServers, {});
expect(result.mcpServers).toEqual(mcpServers);
expect(result.requiredServerNames).toEqual([]);
});
it('should inject required servers when no local config exists', () => {
const mcpServers: Record<string, MCPServerConfig> = {
'local-server': { command: 'cmd1' },
};
const required: Record<string, RequiredMcpServerConfig> = {
'corp-tool': {
url: 'https://mcp.corp.internal/tool',
type: 'http',
description: 'Corp compliance tool',
},
};
const result = applyRequiredServers(mcpServers, required);
expect(Object.keys(result.mcpServers)).toContain('local-server');
expect(Object.keys(result.mcpServers)).toContain('corp-tool');
expect(result.requiredServerNames).toEqual(['corp-tool']);
const corpTool = result.mcpServers['corp-tool'];
expect(corpTool).toBeDefined();
expect(corpTool?.url).toBe('https://mcp.corp.internal/tool');
expect(corpTool?.type).toBe('http');
expect(corpTool?.description).toBe('Corp compliance tool');
// trust defaults to true for admin-forced servers
expect(corpTool?.trust).toBe(true);
// stdio fields should not be set
expect(corpTool?.command).toBeUndefined();
expect(corpTool?.args).toBeUndefined();
});
it('should override local server with same name', () => {
const mcpServers: Record<string, MCPServerConfig> = {
'shared-server': {
command: 'local-cmd',
args: ['local-arg'],
description: 'Local version',
},
};
const required: Record<string, RequiredMcpServerConfig> = {
'shared-server': {
url: 'https://admin.corp/shared',
type: 'sse',
trust: false,
description: 'Admin-mandated version',
},
};
const result = applyRequiredServers(mcpServers, required);
const server = result.mcpServers['shared-server'];
// Admin config should completely override local
expect(server?.url).toBe('https://admin.corp/shared');
expect(server?.type).toBe('sse');
expect(server?.trust).toBe(false);
expect(server?.description).toBe('Admin-mandated version');
// Local fields should NOT be preserved
expect(server?.command).toBeUndefined();
expect(server?.args).toBeUndefined();
});
it('should preserve auth configuration', () => {
const required: Record<string, RequiredMcpServerConfig> = {
'auth-server': {
url: 'https://auth.corp/tool',
type: 'http',
authProviderType: AuthProviderType.GOOGLE_CREDENTIALS,
oauth: {
scopes: ['https://www.googleapis.com/auth/scope1'],
},
targetAudience: 'client-id.apps.googleusercontent.com',
headers: { 'X-Custom': 'value' },
},
};
const result = applyRequiredServers({}, required);
const server = result.mcpServers['auth-server'];
expect(server?.authProviderType).toBe(AuthProviderType.GOOGLE_CREDENTIALS);
expect(server?.oauth).toEqual({
scopes: ['https://www.googleapis.com/auth/scope1'],
});
expect(server?.targetAudience).toBe('client-id.apps.googleusercontent.com');
expect(server?.headers).toEqual({ 'X-Custom': 'value' });
});
it('should preserve tool filtering', () => {
const required: Record<string, RequiredMcpServerConfig> = {
'filtered-server': {
url: 'https://corp/tool',
type: 'http',
includeTools: ['toolA', 'toolB'],
excludeTools: ['toolC'],
},
};
const result = applyRequiredServers({}, required);
const server = result.mcpServers['filtered-server'];
expect(server?.includeTools).toEqual(['toolA', 'toolB']);
expect(server?.excludeTools).toEqual(['toolC']);
});
it('should coexist with allowlisted servers', () => {
// Simulate post-allowlist filtering
const afterAllowlist: Record<string, MCPServerConfig> = {
'allowed-server': {
url: 'http://allowed',
type: 'sse',
trust: true,
},
};
const required: Record<string, RequiredMcpServerConfig> = {
'required-server': {
url: 'https://required.corp/tool',
type: 'http',
},
};
const result = applyRequiredServers(afterAllowlist, required);
expect(Object.keys(result.mcpServers)).toHaveLength(2);
expect(result.mcpServers['allowed-server']).toBeDefined();
expect(result.mcpServers['required-server']).toBeDefined();
expect(result.requiredServerNames).toEqual(['required-server']);
});
});
@@ -4,7 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { MCPServerConfig } from '../../config/config.js';
import { MCPServerConfig } from '../../config/config.js';
import type { RequiredMcpServerConfig } from '../types.js';
/**
* Applies the admin allowlist to the local MCP servers.
@@ -65,3 +66,58 @@ export function applyAdminAllowlist(
}
return { mcpServers: filteredMcpServers, blockedServerNames };
}
/**
* Applies admin-required MCP servers by injecting them into the MCP server
* list. Required servers always take precedence over locally configured servers
* with the same name and cannot be disabled by the user.
*
* @param mcpServers The current MCP servers (after allowlist filtering).
* @param requiredServers The admin-required MCP server configurations.
* @returns The MCP servers with required servers injected, and the list of
* required server names for informational purposes.
*/
export function applyRequiredServers(
mcpServers: Record<string, MCPServerConfig>,
requiredServers: Record<string, RequiredMcpServerConfig> | undefined,
): {
mcpServers: Record<string, MCPServerConfig>;
requiredServerNames: string[];
} {
if (!requiredServers || Object.keys(requiredServers).length === 0) {
return { mcpServers, requiredServerNames: [] };
}
const result: Record<string, MCPServerConfig> = { ...mcpServers };
const requiredServerNames: string[] = [];
for (const [serverId, requiredConfig] of Object.entries(requiredServers)) {
requiredServerNames.push(serverId);
// Convert RequiredMcpServerConfig to MCPServerConfig.
// Required servers completely override any local config with the same name.
result[serverId] = new MCPServerConfig(
undefined, // command (stdio not supported for required servers)
undefined, // args
undefined, // env
undefined, // cwd
requiredConfig.url, // url
undefined, // httpUrl (use url + type instead)
requiredConfig.headers, // headers
undefined, // tcp
requiredConfig.type, // type
requiredConfig.timeout, // timeout
requiredConfig.trust ?? true, // trust defaults to true for admin-forced
requiredConfig.description, // description
requiredConfig.includeTools, // includeTools
requiredConfig.excludeTools, // excludeTools
undefined, // extension
requiredConfig.oauth, // oauth
requiredConfig.authProviderType, // authProviderType
requiredConfig.targetAudience, // targetAudience
requiredConfig.targetServiceAccount, // targetServiceAccount
);
}
return { mcpServers: result, requiredServerNames };
}
+35
View File
@@ -5,6 +5,7 @@
*/
import { z } from 'zod';
import { AuthProviderType } from '../config/config.js';
export interface ClientMetadata {
ideType?: ClientMetadataIdeType;
@@ -359,8 +360,41 @@ const McpServerConfigSchema = z.object({
excludeTools: z.array(z.string()).optional(),
});
const RequiredMcpServerOAuthSchema = z.object({
scopes: z.array(z.string()).optional(),
clientId: z.string().optional(),
clientSecret: z.string().optional(),
});
export const RequiredMcpServerConfigSchema = z.object({
// Connection (required for forced servers)
url: z.string(),
type: z.enum(['sse', 'http']),
// Auth
authProviderType: z.nativeEnum(AuthProviderType).optional(),
oauth: RequiredMcpServerOAuthSchema.optional(),
targetAudience: z.string().optional(),
targetServiceAccount: z.string().optional(),
headers: z.record(z.string()).optional(),
// Common
trust: z.boolean().optional(),
timeout: z.number().optional(),
description: z.string().optional(),
// Tool filtering
includeTools: z.array(z.string()).optional(),
excludeTools: z.array(z.string()).optional(),
});
export type RequiredMcpServerConfig = z.infer<
typeof RequiredMcpServerConfigSchema
>;
export const McpConfigDefinitionSchema = z.object({
mcpServers: z.record(McpServerConfigSchema).optional(),
requiredMcpServers: z.record(RequiredMcpServerConfigSchema).optional(),
});
export type McpConfigDefinition = z.infer<typeof McpConfigDefinitionSchema>;
@@ -377,6 +411,7 @@ export const AdminControlsSettingsSchema = z.object({
.object({
mcpEnabled: z.boolean().optional(),
mcpConfig: McpConfigDefinitionSchema.optional(),
requiredMcpConfig: z.record(RequiredMcpServerConfigSchema).optional(),
})
.optional(),
cliFeatureSetting: CliFeatureSettingSchema.optional(),
+2 -1
View File
@@ -25,6 +25,7 @@ import {
GREP_PARAM_AFTER,
READ_FILE_PARAM_START_LINE,
READ_FILE_PARAM_END_LINE,
READ_FILE_PARAM_FULL,
SHELL_PARAM_IS_BACKGROUND,
EDIT_PARAM_OLD_STRING,
TRACKER_CREATE_TASK_TOOL_NAME,
@@ -215,7 +216,7 @@ Use the following guidelines to optimize your search and read patterns.
- **Searching:** utilize search tools like ${GREP_TOOL_NAME} and ${GLOB_TOOL_NAME} with a conservative result count (\`${GREP_PARAM_TOTAL_MAX_MATCHES}\`) and a narrow scope (\`${GREP_PARAM_INCLUDE_PATTERN}\` and \`${GREP_PARAM_EXCLUDE_PATTERN}\` parameters).
- **Searching and editing:** utilize search tools like ${GREP_TOOL_NAME} with a conservative result count and a narrow scope. Use \`${GREP_PARAM_CONTEXT}\`, \`${GREP_PARAM_BEFORE}\`, and/or \`${GREP_PARAM_AFTER}\` to request enough context to avoid the need to read the file before editing matches.
- **Understanding:** minimize turns needed to understand a file. It's most efficient to read small files in their entirety.
- **Large files:** utilize search tools like ${GREP_TOOL_NAME} and/or ${READ_FILE_TOOL_NAME} called in parallel with '${READ_FILE_PARAM_START_LINE}' and '${READ_FILE_PARAM_END_LINE}' to reduce the impact on context. Minimize extra turns, unless unavoidable due to the file being too large.
- **Large files:** utilize ${READ_FILE_TOOL_NAME} to get a summary/outline of the file, then use '${READ_FILE_PARAM_START_LINE}' and '${READ_FILE_PARAM_END_LINE}' for targeted reads of relevant sections, or use '${READ_FILE_PARAM_FULL}: true' for small to medium files when the complete context is required.
- **Navigating:** read the minimum required to not require additional turns spent reading the file.
</examples>
@@ -1,7 +1,7 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`ReadFileTool > getSchema > should return the Gemini 3 schema when a Gemini 3 modelId is provided 1`] = `"Reads and returns the content of a specified file. To maintain context efficiency, you MUST use 'start_line' and 'end_line' for targeted, surgical reads of specific sections. For your safety, the tool will automatically truncate output exceeding 2000 lines, 2000 characters per line, or 20MB in size; however, triggering these limits is considered token-inefficient. Always retrieve only the minimum content necessary for your next step. Handles text, images (PNG, JPG, GIF, WEBP, SVG, BMP), audio files (MP3, WAV, AIFF, AAC, OGG, FLAC), and PDF files."`;
exports[`ReadFileTool > getSchema > should return the Gemini 3 schema when a Gemini 3 modelId is provided 1`] = `"Reads and returns the content of a specified file. For large files, the tool will return a summary/outline to help you understand the file's structure. You can then use 'start_line' and 'end_line' for targeted reads of specific sections, or 'full: true' to retrieve the complete content. For your safety, the tool will automatically truncate output exceeding 2000 lines, 2000 characters per line, or 20MB in size; however, triggering these limits is considered token-inefficient. Handles text, images (PNG, JPG, GIF, WEBP, SVG, BMP), audio files (MP3, WAV, AIFF, AAC, OGG, FLAC), and PDF files."`;
exports[`ReadFileTool > getSchema > should return the base schema when no modelId is provided 1`] = `"Reads and returns the content of a specified file. If the file is large, the content will be truncated. The tool's response will clearly indicate if truncation has occurred and will provide details on how to read more of the file using the 'start_line' and 'end_line' parameters. Handles text, images (PNG, JPG, GIF, WEBP, SVG, BMP), audio files (MP3, WAV, AIFF, AAC, OGG, FLAC), and PDF files. For text files, it can read specific line ranges."`;
exports[`ReadFileTool > getSchema > should return the base schema when no modelId is provided 1`] = `"Reads and returns the content of a specified file. For large files, the tool will return a summary/outline to help you understand the file's structure. You can then use 'start_line' and 'end_line' for targeted reads of specific sections, or 'full: true' to retrieve the complete content. Handles text, images (PNG, JPG, GIF, WEBP, SVG, BMP), audio files (MP3, WAV, AIFF, AAC, OGG, FLAC), and PDF files."`;
exports[`ReadFileTool > getSchema > should return the schema from the resolver when modelId is provided 1`] = `"Reads and returns the content of a specified file. If the file is large, the content will be truncated. The tool's response will clearly indicate if truncation has occurred and will provide details on how to read more of the file using the 'start_line' and 'end_line' parameters. Handles text, images (PNG, JPG, GIF, WEBP, SVG, BMP), audio files (MP3, WAV, AIFF, AAC, OGG, FLAC), and PDF files. For text files, it can read specific line ranges."`;
exports[`ReadFileTool > getSchema > should return the schema from the resolver when modelId is provided 1`] = `"Reads and returns the content of a specified file. For large files, the tool will return a summary/outline to help you understand the file's structure. You can then use 'start_line' and 'end_line' for targeted reads of specific sections, or 'full: true' to retrieve the complete content. Handles text, images (PNG, JPG, GIF, WEBP, SVG, BMP), audio files (MP3, WAV, AIFF, AAC, OGG, FLAC), and PDF files."`;
@@ -410,7 +410,7 @@ exports[`coreTools snapshots for specific models > Model: gemini-2.5-pro > snaps
exports[`coreTools snapshots for specific models > Model: gemini-2.5-pro > snapshot for tool: read_file 1`] = `
{
"description": "Reads and returns the content of a specified file. If the file is large, the content will be truncated. The tool's response will clearly indicate if truncation has occurred and will provide details on how to read more of the file using the 'start_line' and 'end_line' parameters. Handles text, images (PNG, JPG, GIF, WEBP, SVG, BMP), audio files (MP3, WAV, AIFF, AAC, OGG, FLAC), and PDF files. For text files, it can read specific line ranges.",
"description": "Reads and returns the content of a specified file. For large files, the tool will return a summary/outline to help you understand the file's structure. You can then use 'start_line' and 'end_line' for targeted reads of specific sections, or 'full: true' to retrieve the complete content. Handles text, images (PNG, JPG, GIF, WEBP, SVG, BMP), audio files (MP3, WAV, AIFF, AAC, OGG, FLAC), and PDF files.",
"name": "read_file",
"parametersJsonSchema": {
"properties": {
@@ -422,6 +422,10 @@ exports[`coreTools snapshots for specific models > Model: gemini-2.5-pro > snaps
"description": "The path to the file to read.",
"type": "string",
},
"full": {
"description": "Optional: If true, returns the full file contents. If false (default), large files may be summarized for efficiency.",
"type": "boolean",
},
"start_line": {
"description": "Optional: The 1-based line number to start reading from.",
"type": "number",
@@ -1199,7 +1203,7 @@ exports[`coreTools snapshots for specific models > Model: gemini-3-pro-preview >
exports[`coreTools snapshots for specific models > Model: gemini-3-pro-preview > snapshot for tool: read_file 1`] = `
{
"description": "Reads and returns the content of a specified file. To maintain context efficiency, you MUST use 'start_line' and 'end_line' for targeted, surgical reads of specific sections. For your safety, the tool will automatically truncate output exceeding 2000 lines, 2000 characters per line, or 20MB in size; however, triggering these limits is considered token-inefficient. Always retrieve only the minimum content necessary for your next step. Handles text, images (PNG, JPG, GIF, WEBP, SVG, BMP), audio files (MP3, WAV, AIFF, AAC, OGG, FLAC), and PDF files.",
"description": "Reads and returns the content of a specified file. For large files, the tool will return a summary/outline to help you understand the file's structure. You can then use 'start_line' and 'end_line' for targeted reads of specific sections, or 'full: true' to retrieve the complete content. For your safety, the tool will automatically truncate output exceeding 2000 lines, 2000 characters per line, or 20MB in size; however, triggering these limits is considered token-inefficient. Handles text, images (PNG, JPG, GIF, WEBP, SVG, BMP), audio files (MP3, WAV, AIFF, AAC, OGG, FLAC), and PDF files.",
"name": "read_file",
"parametersJsonSchema": {
"properties": {
@@ -1211,6 +1215,10 @@ exports[`coreTools snapshots for specific models > Model: gemini-3-pro-preview >
"description": "The path to the file to read.",
"type": "string",
},
"full": {
"description": "Optional: If true, returns the full file contents. If false (default), large files may be summarized for efficiency.",
"type": "boolean",
},
"start_line": {
"description": "Optional: The 1-based line number to start reading from.",
"type": "number",
@@ -51,6 +51,7 @@ export const LS_PARAM_IGNORE = 'ignore';
export const READ_FILE_TOOL_NAME = 'read_file';
export const READ_FILE_PARAM_START_LINE = 'start_line';
export const READ_FILE_PARAM_END_LINE = 'end_line';
export const READ_FILE_PARAM_FULL = 'full';
// -- run_shell_command --
export const SHELL_TOOL_NAME = 'run_shell_command';
@@ -50,6 +50,7 @@ export {
// Tool-specific parameter names
READ_FILE_PARAM_START_LINE,
READ_FILE_PARAM_END_LINE,
READ_FILE_PARAM_FULL,
WRITE_FILE_PARAM_CONTENT,
GREP_PARAM_INCLUDE_PATTERN,
GREP_PARAM_EXCLUDE_PATTERN,
@@ -36,6 +36,7 @@ import {
// Tool-specific parameter names
READ_FILE_PARAM_START_LINE,
READ_FILE_PARAM_END_LINE,
READ_FILE_PARAM_FULL,
WRITE_FILE_PARAM_CONTENT,
GREP_PARAM_INCLUDE_PATTERN,
GREP_PARAM_EXCLUDE_PATTERN,
@@ -83,7 +84,7 @@ import {
export const DEFAULT_LEGACY_SET: CoreToolSet = {
read_file: {
name: READ_FILE_TOOL_NAME,
description: `Reads and returns the content of a specified file. If the file is large, the content will be truncated. The tool's response will clearly indicate if truncation has occurred and will provide details on how to read more of the file using the 'start_line' and 'end_line' parameters. Handles text, images (PNG, JPG, GIF, WEBP, SVG, BMP), audio files (MP3, WAV, AIFF, AAC, OGG, FLAC), and PDF files. For text files, it can read specific line ranges.`,
description: `Reads and returns the content of a specified file. For large files, the tool will return a summary/outline to help you understand the file's structure. You can then use 'start_line' and 'end_line' for targeted reads of specific sections, or 'full: true' to retrieve the complete content. Handles text, images (PNG, JPG, GIF, WEBP, SVG, BMP), audio files (MP3, WAV, AIFF, AAC, OGG, FLAC), and PDF files.`,
parametersJsonSchema: {
type: 'object',
properties: {
@@ -101,6 +102,11 @@ export const DEFAULT_LEGACY_SET: CoreToolSet = {
'Optional: The 1-based line number to end reading at (inclusive).',
type: 'number',
},
[READ_FILE_PARAM_FULL]: {
description:
'Optional: If true, returns the full file contents. If false (default), large files may be summarized for efficiency.',
type: 'boolean',
},
},
required: [PARAM_FILE_PATH],
},
@@ -36,6 +36,7 @@ import {
// Tool-specific parameter names
READ_FILE_PARAM_START_LINE,
READ_FILE_PARAM_END_LINE,
READ_FILE_PARAM_FULL,
WRITE_FILE_PARAM_CONTENT,
GREP_PARAM_INCLUDE_PATTERN,
GREP_PARAM_EXCLUDE_PATTERN,
@@ -91,7 +92,7 @@ import {
export const GEMINI_3_SET: CoreToolSet = {
read_file: {
name: READ_FILE_TOOL_NAME,
description: `Reads and returns the content of a specified file. To maintain context efficiency, you MUST use 'start_line' and 'end_line' for targeted, surgical reads of specific sections. For your safety, the tool will automatically truncate output exceeding ${DEFAULT_MAX_LINES_TEXT_FILE} lines, ${MAX_LINE_LENGTH_TEXT_FILE} characters per line, or ${MAX_FILE_SIZE_MB}MB in size; however, triggering these limits is considered token-inefficient. Always retrieve only the minimum content necessary for your next step. Handles text, images (PNG, JPG, GIF, WEBP, SVG, BMP), audio files (MP3, WAV, AIFF, AAC, OGG, FLAC), and PDF files.`,
description: `Reads and returns the content of a specified file. For large files, the tool will return a summary/outline to help you understand the file's structure. You can then use 'start_line' and 'end_line' for targeted reads of specific sections, or 'full: true' to retrieve the complete content. For your safety, the tool will automatically truncate output exceeding ${DEFAULT_MAX_LINES_TEXT_FILE} lines, ${MAX_LINE_LENGTH_TEXT_FILE} characters per line, or ${MAX_FILE_SIZE_MB}MB in size; however, triggering these limits is considered token-inefficient. Handles text, images (PNG, JPG, GIF, WEBP, SVG, BMP), audio files (MP3, WAV, AIFF, AAC, OGG, FLAC), and PDF files.`,
parametersJsonSchema: {
type: 'object',
properties: {
@@ -109,6 +110,11 @@ export const GEMINI_3_SET: CoreToolSet = {
'Optional: The 1-based line number to end reading at (inclusive).',
type: 'number',
},
[READ_FILE_PARAM_FULL]: {
description:
'Optional: If true, returns the full file contents. If false (default), large files may be summarized for efficiency.',
type: 'boolean',
},
},
required: [PARAM_FILE_PATH],
},
+130 -1
View File
@@ -8,6 +8,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { ReadFileTool, type ReadFileToolParams } from './read-file.js';
import { ToolErrorType } from './tool-error.js';
import path from 'node:path';
import { execSync } from 'node:child_process';
import { isSubpath } from '../utils/paths.js';
import os from 'node:os';
import fs from 'node:fs';
@@ -41,6 +42,14 @@ vi.mock('./jit-context.js', () => ({
JIT_CONTEXT_SUFFIX: '\n--- End Project Context ---',
}));
vi.mock('node:child_process', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:child_process')>();
return {
...actual,
execSync: vi.fn(),
};
});
describe('ReadFileTool', () => {
let tempRootDir: string;
let tool: ReadFileTool;
@@ -610,7 +619,7 @@ describe('ReadFileTool', () => {
const schema = tool.getSchema(modelId);
expect(schema.name).toBe(ReadFileTool.Name);
expect(schema.description).toMatchSnapshot();
expect(schema.description).toContain('surgical reads');
expect(schema.description).toContain('targeted reads');
});
});
@@ -685,4 +694,124 @@ describe('ReadFileTool', () => {
);
});
});
describe('tilth and full parameter', () => {
it('should use tilth for large files when full is false', async () => {
const filePath = path.join(tempRootDir, 'large.txt');
const content = 'A'.repeat(5000); // Exceeds default 4096 threshold
fs.writeFileSync(filePath, content);
vi.mocked(execSync).mockReturnValue('tilth summary');
const params: ReadFileToolParams = {
file_path: 'large.txt',
full: false,
};
const invocation = tool.build(params);
const result = await invocation.execute(abortSignal);
expect(execSync).toHaveBeenCalledWith(
expect.stringContaining(`npx -y tilth --budget 1000 "${filePath}"`),
expect.any(Object),
);
expect(result.llmContent).toContain('tilth summary');
expect(result.returnDisplay).toContain('Summarized large file');
});
it('should NOT use tilth when full is true', async () => {
const filePath = path.join(tempRootDir, 'large.txt');
const content = 'A'.repeat(5000);
fs.writeFileSync(filePath, content);
vi.mocked(execSync).mockClear();
const params: ReadFileToolParams = {
file_path: 'large.txt',
full: true,
};
const invocation = tool.build(params);
const result = await invocation.execute(abortSignal);
expect(execSync).not.toHaveBeenCalled();
// Should return full content (clamped by MAX_LINE_LENGTH_TEXT_FILE per line, but not summarized by tilth)
expect(result.llmContent).not.toContain('--- FILE SUMMARY ---');
});
it('should use tilth when start_line is 1 and no end_line is provided', async () => {
const filePath = path.join(tempRootDir, 'large.txt');
const content = 'A\n'.repeat(3000); // 6000 bytes
fs.writeFileSync(filePath, content);
vi.mocked(execSync).mockClear();
vi.mocked(execSync).mockReturnValue('tilth summary for line 1');
const params: ReadFileToolParams = {
file_path: 'large.txt',
start_line: 1,
};
const invocation = tool.build(params);
const result = await invocation.execute(abortSignal);
expect(execSync).toHaveBeenCalled();
expect(result.llmContent).toContain('tilth summary for line 1');
});
it('should NOT use tilth when custom start_line (not 1) is provided', async () => {
const filePath = path.join(tempRootDir, 'large.txt');
const content = 'A\n'.repeat(3000);
fs.writeFileSync(filePath, content);
vi.mocked(execSync).mockClear();
const params: ReadFileToolParams = {
file_path: 'large.txt',
start_line: 10,
};
const invocation = tool.build(params);
const result = await invocation.execute(abortSignal);
expect(execSync).not.toHaveBeenCalled();
expect(result.llmContent).not.toContain('--- FILE SUMMARY ---');
});
it('should read to end when start_line > 1 is provided without end_line', async () => {
const filePath = path.join(tempRootDir, 'large.txt');
const lines = Array.from({ length: 3000 }, (_, i) => `Line ${i + 1}`);
fs.writeFileSync(filePath, lines.join('\n'));
vi.mocked(execSync).mockClear();
const params: ReadFileToolParams = {
file_path: 'large.txt',
start_line: 10,
};
const invocation = tool.build(params);
const result = await invocation.execute(abortSignal);
expect(execSync).not.toHaveBeenCalled();
expect(result.llmContent).toContain('Line 10');
expect(result.llmContent).not.toContain('Line 1\n');
expect(result.returnDisplay).toContain('Read lines 10-'); // Clamped by default max lines, but direct read
});
it('should respect custom GEMINI_CLI_FULL_READ_THRESHOLD', async () => {
const filePath = path.join(tempRootDir, 'medium.txt');
const content = 'A'.repeat(1000);
fs.writeFileSync(filePath, content);
vi.stubEnv('GEMINI_CLI_FULL_READ_THRESHOLD', '500');
vi.mocked(execSync).mockReturnValue('tilth summary');
const params: ReadFileToolParams = {
file_path: 'medium.txt',
};
const invocation = tool.build(params);
const result = await invocation.execute(abortSignal);
expect(execSync).toHaveBeenCalled();
expect(result.llmContent).toContain('tilth summary');
vi.unstubAllEnvs();
});
});
});
+90 -7
View File
@@ -6,6 +6,8 @@
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import path from 'node:path';
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import { makeRelative, shortenPath } from '../utils/paths.js';
import {
BaseDeclarativeTool,
@@ -24,6 +26,8 @@ import type { PartListUnion } from '@google/genai';
import {
processSingleFileContent,
getSpecificMimeType,
detectFileType,
type ProcessedFileReadResult,
} from '../utils/fileUtils.js';
import type { Config } from '../config/config.js';
import { FileOperation } from '../telemetry/metrics.js';
@@ -34,6 +38,7 @@ import { READ_FILE_TOOL_NAME, READ_FILE_DISPLAY_NAME } from './tool-names.js';
import { FileDiscoveryService } from '../services/fileDiscoveryService.js';
import { READ_FILE_DEFINITION } from './definitions/coreTools.js';
import { resolveToolDeclaration } from './definitions/resolver.js';
import { MAX_FILE_SIZE_MB } from '../utils/constants.js';
import {
discoverJitContext,
appendJitContext,
@@ -58,6 +63,12 @@ export interface ReadFileToolParams {
* The line number to end reading at (optional, 1-based, inclusive)
*/
end_line?: number;
/**
* If true, returns the full file contents.
* If false (default), large files may be summarized for efficiency.
*/
full?: boolean;
}
class ReadFileToolInvocation extends BaseToolInvocation<
@@ -120,13 +131,81 @@ class ReadFileToolInvocation extends BaseToolInvocation<
};
}
const result = await processSingleFileContent(
this.resolvedPath,
this.config.getTargetDir(),
this.config.getFileSystemService(),
this.params.start_line,
this.params.end_line,
);
let result: ProcessedFileReadResult;
if (!fs.existsSync(this.resolvedPath)) {
result = await processSingleFileContent(
this.resolvedPath,
this.config.getTargetDir(),
this.config.getFileSystemService(),
this.params.start_line,
this.params.end_line,
this.params.full,
);
} else {
const stats = await fs.promises.stat(this.resolvedPath);
const fileSizeInMB = stats.size / (1024 * 1024);
if (fileSizeInMB > MAX_FILE_SIZE_MB) {
result = await processSingleFileContent(
this.resolvedPath,
this.config.getTargetDir(),
this.config.getFileSystemService(),
this.params.start_line,
this.params.end_line,
this.params.full,
);
} else {
const fileType = await detectFileType(this.resolvedPath);
const fullReadThreshold = parseInt(
process.env['GEMINI_CLI_FULL_READ_THRESHOLD'] ?? '4096',
10,
);
const isExplicitLineRange =
(this.params.start_line !== undefined &&
this.params.start_line !== 1) ||
this.params.end_line !== undefined;
const isDirectReadRequired =
this.params.full === true ||
isExplicitLineRange ||
stats.size < fullReadThreshold ||
fileType !== 'text';
if (isDirectReadRequired) {
result = await processSingleFileContent(
this.resolvedPath,
this.config.getTargetDir(),
this.config.getFileSystemService(),
this.params.start_line,
this.params.end_line,
this.params.full,
);
} else {
try {
const summary = execSync(
`npx -y tilth --budget 1000 "${this.resolvedPath}"`,
{
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
},
).toString();
result = {
llmContent: summary,
returnDisplay: `Summarized large file: ${shortenPath(makeRelative(this.resolvedPath, this.config.getTargetDir()))}`,
};
} catch (_error) {
// Fallback to normal read if tilth fails
result = await processSingleFileContent(
this.resolvedPath,
this.config.getTargetDir(),
this.config.getFileSystemService(),
this.params.start_line,
this.params.end_line,
this.params.full,
);
}
}
}
}
if (result.error) {
return {
@@ -256,6 +335,10 @@ export class ReadFileTool extends BaseDeclarativeTool<
return 'start_line cannot be greater than end_line';
}
if (params.full !== undefined && typeof params.full !== 'boolean') {
return 'full must be a boolean';
}
const fileFilteringOptions = this.config.getFileFilteringOptions();
if (
this.fileDiscoveryService.shouldIgnoreFile(
+2
View File
@@ -34,6 +34,7 @@ import {
// Tool-specific parameter names
READ_FILE_PARAM_START_LINE,
READ_FILE_PARAM_END_LINE,
READ_FILE_PARAM_FULL,
WRITE_FILE_PARAM_CONTENT,
GREP_PARAM_INCLUDE_PATTERN,
GREP_PARAM_EXCLUDE_PATTERN,
@@ -107,6 +108,7 @@ export {
// Tool-specific parameter names
READ_FILE_PARAM_START_LINE,
READ_FILE_PARAM_END_LINE,
READ_FILE_PARAM_FULL,
WRITE_FILE_PARAM_CONTENT,
GREP_PARAM_INCLUDE_PATTERN,
GREP_PARAM_EXCLUDE_PATTERN,
File diff suppressed because it is too large Load Diff
+10 -6
View File
@@ -402,6 +402,7 @@ export interface ProcessedFileReadResult {
* @param _fileSystemService Currently unused in this function; kept for signature stability.
* @param startLine Optional 1-based line number to start reading from.
* @param endLine Optional 1-based line number to end reading at (inclusive).
* @param full Optional boolean to indicate if full file content should be returned without line truncation.
* @returns ProcessedFileReadResult object.
*/
export async function processSingleFileContent(
@@ -410,6 +411,7 @@ export async function processSingleFileContent(
_fileSystemService: FileSystemService,
startLine?: number,
endLine?: number,
full?: boolean,
): Promise<ProcessedFileReadResult> {
try {
if (!fs.existsSync(filePath)) {
@@ -482,11 +484,13 @@ export async function processSingleFileContent(
sliceStart = startLine ? startLine - 1 : 0;
sliceEnd = endLine
? Math.min(endLine, originalLineCount)
: Math.min(
sliceStart + DEFAULT_MAX_LINES_TEXT_FILE,
originalLineCount,
);
} else {
: full
? originalLineCount
: Math.min(
sliceStart + DEFAULT_MAX_LINES_TEXT_FILE,
originalLineCount,
);
} else if (!full) {
sliceEnd = Math.min(DEFAULT_MAX_LINES_TEXT_FILE, originalLineCount);
}
@@ -496,7 +500,7 @@ export async function processSingleFileContent(
let linesWereTruncatedInLength = false;
const formattedLines = selectedLines.map((line) => {
if (line.length > MAX_LINE_LENGTH_TEXT_FILE) {
if (!full && line.length > MAX_LINE_LENGTH_TEXT_FILE) {
linesWereTruncatedInLength = true;
return (
line.substring(0, MAX_LINE_LENGTH_TEXT_FILE) + '... [truncated]'
+83 -2
View File
@@ -3040,13 +3040,23 @@
},
"config": {
"title": "MCP Config",
"description": "Admin-configured MCP servers.",
"markdownDescription": "Admin-configured MCP servers.\n\n- Category: `Admin`\n- Requires restart: `no`\n- Default: `{}`",
"description": "Admin-configured MCP servers (allowlist).",
"markdownDescription": "Admin-configured MCP servers (allowlist).\n\n- Category: `Admin`\n- Requires restart: `no`\n- Default: `{}`",
"default": {},
"type": "object",
"additionalProperties": {
"$ref": "#/$defs/MCPServerConfig"
}
},
"requiredConfig": {
"title": "Required MCP Config",
"description": "Admin-required MCP servers that are always injected.",
"markdownDescription": "Admin-required MCP servers that are always injected.\n\n- Category: `Admin`\n- Requires restart: `no`\n- Default: `{}`",
"default": {},
"type": "object",
"additionalProperties": {
"$ref": "#/$defs/RequiredMcpServerConfig"
}
}
},
"additionalProperties": false
@@ -3181,6 +3191,77 @@
}
}
},
"RequiredMcpServerConfig": {
"type": "object",
"description": "Admin-required MCP server configuration (remote transports only).",
"additionalProperties": false,
"properties": {
"url": {
"type": "string",
"description": "URL for the required MCP server."
},
"type": {
"type": "string",
"description": "Transport type for the required server.",
"enum": ["sse", "http"]
},
"headers": {
"type": "object",
"description": "Additional HTTP headers sent to the server.",
"additionalProperties": {
"type": "string"
}
},
"timeout": {
"type": "number",
"description": "Timeout in milliseconds for MCP requests."
},
"trust": {
"type": "boolean",
"description": "Marks the server as trusted. Defaults to true for admin-required servers."
},
"description": {
"type": "string",
"description": "Human-readable description of the server."
},
"includeTools": {
"type": "array",
"description": "Subset of tools enabled for this server.",
"items": {
"type": "string"
}
},
"excludeTools": {
"type": "array",
"description": "Tools disabled for this server.",
"items": {
"type": "string"
}
},
"oauth": {
"type": "object",
"description": "OAuth configuration for authenticating with the server.",
"additionalProperties": true
},
"authProviderType": {
"type": "string",
"description": "Authentication provider used for acquiring credentials.",
"enum": [
"dynamic_discovery",
"google_credentials",
"service_account_impersonation"
]
},
"targetAudience": {
"type": "string",
"description": "OAuth target audience (CLIENT_ID.apps.googleusercontent.com)."
},
"targetServiceAccount": {
"type": "string",
"description": "Service account email to impersonate (name@project.iam.gserviceaccount.com)."
}
}
},
"TelemetrySettings": {
"type": "object",
"description": "Telemetry configuration for Gemini CLI.",