Compare commits

...

5 Commits

Author SHA1 Message Date
Michael Bleigh fc8928c089 feat: add support for Gemini Enterprise (Discovery Engine) assistant
- Implemented EnterpriseAgentProtocol and EnterpriseAgentSession in core
- Authenticates seamlessly via Application Default Credentials (ADC)
- Added robust brace-counting JSON stream parser with partial chunk caching
- Extracted and rendered immersive docArtifacts (markdown tables) E2E
- Integrated with CLI config schema and enabled default 'all tools' execution
- Added comprehensive unit tests verifying all stream events (thoughts, tools, tables)

TAG=agy
CONV=81e82460-f8cd-4c7b-a037-2cbedda4d3c0
2026-05-18 00:59:55 +00:00
Adam Weidman 5611ff40e7 feat(core): add adk.agentSessionSubagentEnabled flag (#26947) 2026-05-17 17:38:34 +00:00
David Pierce 77e65c0db5 fix(core): use hasAccessToPreview for auto model resolution and fix disappearing models (#27112) 2026-05-15 17:26:59 +00:00
Anish Sabharwal b36788eb2a fix(core): add aliases and thinking config for gemini-3.1 models (#27007) 2026-05-15 16:29:15 +00:00
PROTHAM d32c9b77df Fix/web fetch ctrl c abort (#24320)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-05-15 16:26:03 +00:00
21 changed files with 1038 additions and 36 deletions
+24
View File
@@ -550,6 +550,24 @@ their corresponding top-level category object in your `settings.json` file.
"model": "gemini-3-flash-preview"
}
},
"gemini-3.1-pro-preview": {
"extends": "chat-base-3",
"modelConfig": {
"model": "gemini-3.1-pro-preview"
}
},
"gemini-3.1-pro-preview-customtools": {
"extends": "chat-base-3",
"modelConfig": {
"model": "gemini-3.1-pro-preview-customtools"
}
},
"gemini-3.1-flash-lite-preview": {
"extends": "chat-base-3",
"modelConfig": {
"model": "gemini-3.1-flash-lite-preview"
}
},
"gemini-2.5-pro": {
"extends": "chat-base-2.5",
"modelConfig": {
@@ -1836,6 +1854,12 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `false`
- **Requires restart:** Yes
- **`experimental.adk.agentSessionSubagentEnabled`** (boolean):
- **Description:** Route subagent invocations through the AgentSession
protocol instead of legacy executors.
- **Default:** `false`
- **Requires restart:** Yes
- **`experimental.enableAgents`** (boolean):
- **Description:** Enable local and remote subagents.
- **Default:** `true`
+1
View File
@@ -974,6 +974,7 @@ export async function loadCliConfig(
mcpEnabled,
extensionsEnabled,
agents: settings.agents,
enterprise: settings.enterprise,
adminSkillsEnabled,
allowedMcpServers: mcpEnabled
? (argv.allowedMcpServerNames ?? settings.mcp?.allowed)
@@ -571,6 +571,18 @@ describe('SettingsSchema', () => {
expect(agentSessionNoninteractiveEnabled.description).toBe(
'Enable non-interactive agent sessions.',
);
const agentSessionSubagentEnabled =
adk.properties.agentSessionSubagentEnabled;
expect(agentSessionSubagentEnabled).toBeDefined();
expect(agentSessionSubagentEnabled.type).toBe('boolean');
expect(agentSessionSubagentEnabled.category).toBe('Experimental');
expect(agentSessionSubagentEnabled.default).toBe(false);
expect(agentSessionSubagentEnabled.requiresRestart).toBe(true);
expect(agentSessionSubagentEnabled.showInDialog).toBe(false);
expect(agentSessionSubagentEnabled.description).toBe(
'Route subagent invocations through the AgentSession protocol instead of legacy executors.',
);
});
});
+48
View File
@@ -1366,6 +1366,44 @@ const SETTINGS_SCHEMA = {
},
},
},
enterprise: {
type: 'object',
label: 'Gemini Enterprise',
category: 'Advanced',
requiresRestart: true,
default: {},
description: 'Settings for Gemini Enterprise integration.',
showInDialog: false,
properties: {
projectId: {
type: 'string',
label: 'Project ID',
category: 'Advanced',
requiresRestart: true,
default: undefined as string | undefined,
description: 'Google Cloud Project ID for Gemini Enterprise.',
showInDialog: true,
},
engineId: {
type: 'string',
label: 'Engine ID',
category: 'Advanced',
requiresRestart: true,
default: undefined as string | undefined,
description: 'Discovery Engine ID for Gemini Enterprise.',
showInDialog: true,
},
location: {
type: 'string',
label: 'Location',
category: 'Advanced',
requiresRestart: true,
default: 'global',
description: 'Google Cloud Location for Gemini Enterprise.',
showInDialog: true,
},
},
},
context: {
type: 'object',
@@ -2194,6 +2232,16 @@ const SETTINGS_SCHEMA = {
'Enable the agent session implementation for the interactive CLI.',
showInDialog: false,
},
agentSessionSubagentEnabled: {
type: 'boolean',
label: 'Agent Session Subagent Enabled',
category: 'Experimental',
requiresRestart: true,
default: false,
description:
'Route subagent invocations through the AgentSession protocol instead of legacy executors.',
showInDialog: false,
},
},
},
enableAgents: {
+12 -8
View File
@@ -507,7 +507,8 @@ export async function main() {
// the sandbox because the sandbox will interfere with the Oauth2 web
// redirect.
let initialAuthFailed = false;
if (!settings.merged.security.auth.useExternal && !argv.isCommand) {
const useEnterprise = process.env['GEMINI_CLI_ENTERPRISE_AGENT'] === 'true';
if (!settings.merged.security.auth.useExternal && !argv.isCommand && !useEnterprise) {
try {
if (
partialConfig.isInteractive() &&
@@ -858,13 +859,16 @@ export async function main() {
),
);
const authType = await validateNonInteractiveAuth(
settings.merged.security.auth.selectedType,
settings.merged.security.auth.useExternal,
config,
settings,
);
await config.refreshAuth(authType);
const useEnterprise = process.env['GEMINI_CLI_ENTERPRISE_AGENT'] === 'true';
if (!useEnterprise) {
const authType = await validateNonInteractiveAuth(
settings.merged.security.auth.selectedType,
settings.merged.security.auth.useExternal,
config,
settings,
);
await config.refreshAuth(authType);
}
if (config.getDebugMode()) {
debugLogger.log('Session ID: %s', sessionId);
+3 -1
View File
@@ -66,7 +66,9 @@ interface RunNonInteractiveParams {
export async function runNonInteractive(
params: RunNonInteractiveParams,
): Promise<void> {
const useAgentSession = params.config.getAgentSessionNoninteractiveEnabled();
const useAgentSession =
params.config.getAgentSessionNoninteractiveEnabled() ||
process.env['GEMINI_CLI_ENTERPRISE_AGENT'] === 'true';
if (useAgentSession) {
debugLogger.debug(
'[ADK] Running non-interactive mode with ADK agent session',
@@ -35,6 +35,7 @@ import {
Scheduler,
ROOT_SCHEDULER_ID,
LegacyAgentSession,
EnterpriseAgentSession,
ToolErrorType,
geminiPartsToContentParts,
displayContentToString,
@@ -295,13 +296,16 @@ export async function runNonInteractive({
});
}
// Create LegacyAgentSession — owns the agentic loop
const session = new LegacyAgentSession({
client: geminiClient,
scheduler,
config,
promptId: prompt_id,
});
const useEnterprise = process.env['GEMINI_CLI_ENTERPRISE_AGENT'] === 'true';
// Create AgentSession — owns the agentic loop
const session = useEnterprise
? new EnterpriseAgentSession({ config, promptId: prompt_id })
: new LegacyAgentSession({
client: geminiClient,
scheduler,
config,
promptId: prompt_id,
});
// Wire Ctrl+C to session abort
abortSession = () => {
+8 -3
View File
@@ -90,6 +90,7 @@ import {
logBillingEvent,
ApiKeyUpdatedEvent,
LegacyAgentProtocol,
EnterpriseAgentProtocol,
type InjectionSource,
} from '@google/gemini-cli-core';
import { validateAuthMethod } from '../config/auth.js';
@@ -1173,10 +1174,14 @@ Logging in with Google... Restarting Gemini CLI to continue.
}, [config]);
const streamAgent = useMemo(
() =>
config?.getAgentSessionInteractiveEnabled()
() => {
if (process.env['GEMINI_CLI_ENTERPRISE_AGENT'] === 'true') {
return new EnterpriseAgentProtocol({ config });
}
return config?.getAgentSessionInteractiveEnabled()
? new LegacyAgentProtocol({ config, getPreferredEditor })
: undefined,
: undefined;
},
[config, getPreferredEditor],
);
@@ -0,0 +1,253 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it, vi, beforeEach, afterEach, type Mock } from 'vitest';
import { EnterpriseAgentSession } from './enterprise-agent-session.js';
import type { Config } from '../config/config.js';
import type { AgentEvent } from './types.js';
import { GoogleAuth } from 'google-auth-library';
// Mock google-auth-library
vi.mock('google-auth-library', () => ({
GoogleAuth: vi.fn(),
}));
describe('EnterpriseAgentSession', () => {
let mockConfig: Config;
let globalFetch: typeof fetch;
beforeEach(() => {
vi.clearAllMocks();
(GoogleAuth as unknown as Mock).mockImplementation(() => ({
getClient: vi.fn().mockResolvedValue({
getAccessToken: vi.fn().mockResolvedValue({ token: 'fake-token' }),
}),
}));
mockConfig = {
getSessionId: vi.fn().mockReturnValue('test-session'),
getEnterpriseConfig: vi.fn().mockReturnValue({
projectId: 'test-project',
engineId: 'test-engine',
location: 'global',
}),
} as unknown as Config;
globalFetch = global.fetch;
});
afterEach(() => {
global.fetch = globalFetch;
vi.restoreAllMocks();
});
const mockFetchResponse = (chunks: string[]) => {
const stream = new ReadableStream({
start(controller) {
for (const chunk of chunks) {
controller.enqueue(new TextEncoder().encode(chunk));
}
controller.close();
},
});
global.fetch = vi.fn().mockResolvedValue({
ok: true,
body: stream,
headers: new Headers(),
} as Response);
};
it('should successfully call Enterprise API and stream responses', async () => {
const chunk1 = JSON.stringify({
sessionInfo: { session: 'projects/test-project/locations/global/collections/default_collection/engines/test-engine/sessions/s1' },
answer: {
replies: [
{
groundedContent: {
content: { text: 'Hello' },
},
},
],
},
}) + '\n';
const chunk2 = JSON.stringify({
answer: {
replies: [
{
groundedContent: {
content: { text: ' World' },
},
},
],
},
}) + '\n';
mockFetchResponse([chunk1, chunk2]);
const session = new EnterpriseAgentSession({ config: mockConfig });
const { streamId } = await session.send({
message: { content: [{ type: 'text', text: 'hi' }] },
});
expect(streamId).toBe('enterprise-stream-1');
const events: AgentEvent[] = [];
for await (const event of session.stream({ streamId: streamId! })) {
events.push(event);
}
expect(events.map(e => e.type)).toEqual([
'agent_start',
'message', // Hello
'message', // World
'agent_end',
]);
const messages = events.filter((e): e is AgentEvent<'message'> => e.type === 'message' && e.role === 'agent');
expect(messages[0].content).toEqual([{ type: 'text', text: 'Hello' }]);
expect(messages[1].content).toEqual([{ type: 'text', text: ' World' }]);
});
it('should handle thoughts', async () => {
const chunk = JSON.stringify({
answer: {
replies: [
{
groundedContent: {
content: { text: 'Thinking...', thought: true },
},
},
{
groundedContent: {
content: { text: 'Final answer' },
},
},
],
},
}) + '\n';
mockFetchResponse([chunk]);
const session = new EnterpriseAgentSession({ config: mockConfig });
const { streamId } = await session.send({
message: { content: [{ type: 'text', text: 'hi' }] },
});
const events: AgentEvent[] = [];
for await (const event of session.stream({ streamId: streamId! })) {
events.push(event);
}
const thoughts = events.filter((e): e is AgentEvent<'message'> => e.type === 'message' && e.content[0]?.type === 'thought');
expect(thoughts).toHaveLength(1);
expect(thoughts[0].content).toEqual([{ type: 'thought', thought: 'Thinking...' }]);
const texts = events.filter((e): e is AgentEvent<'message'> => e.type === 'message' && e.content[0]?.type === 'text' && e.role === 'agent');
expect(texts).toHaveLength(1);
expect(texts[0].content).toEqual([{ type: 'text', text: 'Final answer' }]);
});
it('should handle tool requests and responses (executable code)', async () => {
const chunk1 = JSON.stringify({
answer: {
replies: [
{
groundedContent: {
content: {
executableCode: { code: 'print("hello")' },
},
},
},
],
},
}) + '\n';
const chunk2 = JSON.stringify({
answer: {
replies: [
{
groundedContent: {
content: {
codeExecutionResult: { outcome: 'OUTCOME_OK', output: 'hello\n' },
},
},
},
],
},
}) + '\n';
mockFetchResponse([chunk1, chunk2]);
const session = new EnterpriseAgentSession({ config: mockConfig });
const { streamId } = await session.send({
message: { content: [{ type: 'text', text: 'run code' }] },
});
const events: AgentEvent[] = [];
for await (const event of session.stream({ streamId: streamId! })) {
events.push(event);
}
expect(events.map(e => e.type)).toEqual([
'agent_start',
'tool_request',
'tool_response',
'agent_end',
]);
const toolReq = events.find(e => e.type === 'tool_request') as AgentEvent<'tool_request'>;
expect(toolReq.name).toBe('python_interpreter');
expect(toolReq.args).toEqual({ code: 'print("hello")' });
const toolResp = events.find(e => e.type === 'tool_response') as AgentEvent<'tool_response'>;
expect(toolResp.name).toBe('python_interpreter');
expect(toolResp.content).toEqual([{ type: 'text', text: 'hello\n' }]);
expect(toolResp.isError).toBe(false);
});
it('should handle immersive artifacts (tables/docs)', async () => {
const chunk = JSON.stringify({
answer: {
replies: [
{
groundedContent: {
content: { text: 'Here is the table:\n' },
},
immersiveArtifact: [
{
docArtifact: { text: '| Col 1 | Col 2 |\n|---|---|\n| Val 1 | Val 2 |' },
},
],
},
],
},
}) + '\n';
mockFetchResponse([chunk]);
const session = new EnterpriseAgentSession({ config: mockConfig });
const { streamId } = await session.send({
message: { content: [{ type: 'text', text: 'show table' }] },
});
const events: AgentEvent[] = [];
for await (const event of session.stream({ streamId: streamId! })) {
events.push(event);
}
const texts = events.filter((e): e is AgentEvent<'message'> => e.type === 'message' && e.role === 'agent');
expect(texts).toHaveLength(2);
expect(texts[0].content).toEqual([{ type: 'text', text: 'Here is the table:\n' }]);
expect(texts[1].content).toEqual([{ type: 'text', text: '| Col 1 | Col 2 |\n|---|---|\n| Val 1 | Val 2 |' }]);
});
});
@@ -0,0 +1,420 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { GoogleAuth } from 'google-auth-library';
import type { Config } from '../config/config.js';
import { AgentSession } from './agent-session.js';
import type {
AgentEvent,
AgentProtocol,
AgentSend,
ContentPart,
Unsubscribe,
} from './types.js';
import { fetchWithTimeout } from '../utils/fetch.js';
import { debugLogger } from '../utils/debugLogger.js';
export interface EnterpriseAgentSessionDeps {
config: Config;
promptId?: string;
streamId?: string;
}
export class EnterpriseAgentProtocol implements AgentProtocol {
private _events: AgentEvent[] = [];
private _subscribers = new Set<(event: AgentEvent) => void>();
private _activeStreamId?: string;
private _abortController = new AbortController();
private _streamCounter = 0;
private _eventCounter = 0;
private readonly _config: Config;
private _sessionResourceName?: string;
constructor(deps: EnterpriseAgentSessionDeps) {
this._config = deps.config;
this._sessionResourceName = deps.streamId;
}
get events(): readonly AgentEvent[] {
return this._events;
}
subscribe(callback: (event: AgentEvent) => void): Unsubscribe {
this._subscribers.add(callback);
return () => {
this._subscribers.delete(callback);
};
}
async abort(): Promise<void> {
this._abortController.abort();
}
async send(payload: AgentSend): Promise<{ streamId: string }> {
const message = 'message' in payload ? payload.message : undefined;
if (!message) {
throw new Error(
'EnterpriseAgentSession.send() only supports message sends for the moment.',
);
}
if (this._activeStreamId) {
throw new Error(
'EnterpriseAgentSession.send() cannot be called while a stream is active.',
);
}
this._beginNewStream();
const streamId = this._activeStreamId!;
const userMessage = this._makeUserMessageEvent(
message.content,
message.displayContent,
payload._meta,
);
this._emit([userMessage]);
this._scheduleRunLoop(message.content.map(p => p.type === 'text' ? p.text : '').join(' '));
return { streamId };
}
private _beginNewStream(): void {
this._streamCounter++;
this._eventCounter = 0;
this._abortController = new AbortController();
this._activeStreamId = `enterprise-stream-${this._streamCounter}`;
}
private _scheduleRunLoop(queryText: string): void {
setTimeout(() => {
void this._runLoopInBackground(queryText);
}, 0);
}
private async _runLoopInBackground(queryText: string): Promise<void> {
this._ensureAgentStart();
try {
const enterpriseConfig = this._config.getEnterpriseConfig();
if (!enterpriseConfig?.projectId || !enterpriseConfig?.engineId) {
throw new Error('Gemini Enterprise is not fully configured. projectId and engineId are required in ~/.gemini/settings.json.');
}
const projectId = enterpriseConfig.projectId;
const engineId = enterpriseConfig.engineId;
const location = enterpriseConfig.location ?? 'global';
// Get Auth Token
const auth = new GoogleAuth({
scopes: ['https://www.googleapis.com/auth/cloud-platform'],
});
const client = await auth.getClient();
const tokenResponse = await client.getAccessToken();
const token = tokenResponse.token;
if (!token) {
throw new Error('Failed to retrieve ADC access token.');
}
const endpoint = `https://discoveryengine.googleapis.com/v1alpha/projects/${projectId}/locations/${location}/collections/default_collection/engines/${engineId}/assistants/default_assistant:streamAssist`;
const requestBody = {
query: {
text: queryText,
},
session: this._sessionResourceName || '-',
assistSkippingMode: 'REQUEST_ASSIST',
toolsSpec: {
vertexAiSearchSpec: {},
webGroundingSpec: {},
},
};
debugLogger.debug(`Calling Enterprise API: ${endpoint}`);
debugLogger.debug(`Request Body: ${JSON.stringify(requestBody)}`);
const response = await fetchWithTimeout(endpoint, 60000, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
signal: this._abortController.signal,
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Enterprise API call failed with status ${response.status}: ${errorText}`);
}
if (!response.body) {
throw new Error('Response body is empty.');
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let braceCount = 0;
let insideString = false;
let escapeNext = false;
let objectStart = -1;
let lastScannedIndex = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
for (let i = lastScannedIndex; i < buffer.length; i++) {
const char = buffer[i];
if (escapeNext) {
escapeNext = false;
lastScannedIndex = i + 1;
continue;
}
if (char === '\\') {
escapeNext = true;
lastScannedIndex = i + 1;
continue;
}
if (char === '"') {
insideString = !insideString;
lastScannedIndex = i + 1;
continue;
}
if (!insideString) {
if (char === '{') {
if (braceCount === 0) {
objectStart = i;
}
braceCount++;
} else if (char === '}') {
braceCount--;
if (braceCount === 0 && objectStart !== -1) {
const objectStr = buffer.substring(objectStart, i + 1);
await this._parseAndEmitChunk(objectStr);
// Remove parsed object from buffer and reset index
buffer = buffer.substring(i + 1);
i = -1;
lastScannedIndex = 0;
objectStart = -1;
}
}
}
lastScannedIndex = i + 1;
}
}
this._emit([this._makeAgentEndEvent('completed')]);
} catch (err: unknown) {
if (this._abortController.signal.aborted) {
this._emit([this._makeAgentEndEvent('aborted')]);
} else {
const message = err instanceof Error ? err.message : String(err);
this._emit([
this._makeErrorEvent({
status: 'INTERNAL',
message,
fatal: true,
}),
]);
this._emit([this._makeAgentEndEvent('failed')]);
}
} finally {
this._activeStreamId = undefined;
}
}
private _ensureAgentStart(): void {
// We can emit agent_start early or wait until we get the first chunk.
// For now, emit it at the start of background run.
this._emit([this._makeAgentStartEvent()]);
}
private async _parseAndEmitChunk(line: string): Promise<void> {
try {
const response = JSON.parse(line);
debugLogger.debug(`Received Enterprise Chunk: ${JSON.stringify(response)}`);
if (response.sessionInfo?.session) {
this._sessionResourceName = response.sessionInfo.session;
debugLogger.debug(`Session updated: ${this._sessionResourceName}`);
}
const answer = response.answer;
if (answer) {
if (answer.replies) {
for (const reply of answer.replies) {
if (reply.groundedContent?.content) {
const content = reply.groundedContent.content;
// Handle Thought
if (content.thought) {
if (content.text) {
this._emit([
this._makeMessageEvent('agent', [
{ type: 'thought', thought: content.text }
])
]);
}
}
// Handle Tool Use (Executable Code)
else if (content.executableCode) {
this._emit([
this._makeToolRequestEvent({
requestId: `ent-tool-${Date.now()}`,
name: 'python_interpreter',
args: { code: content.executableCode.code },
display: {
name: 'Python Interpreter',
description: 'Executing code server-side',
}
})
]);
}
// Handle Tool Response (Code Execution Result)
else if (content.codeExecutionResult) {
this._emit([
this._makeToolResponseEvent({
requestId: `ent-tool-${Date.now()}`,
name: 'python_interpreter',
content: [{ type: 'text', text: content.codeExecutionResult.output || '' }],
isError: content.codeExecutionResult.outcome === 'OUTCOME_FAILED',
})
]);
}
// Handle standard Text
else if (content.text) {
this._emit([
this._makeMessageEvent('agent', [
{ type: 'text', text: content.text }
])
]);
}
}
if (reply.immersiveArtifact) {
for (const artifact of reply.immersiveArtifact) {
if (artifact.docArtifact?.text) {
this._emit([
this._makeMessageEvent('agent', [
{ type: 'text', text: artifact.docArtifact.text }
])
]);
}
}
}
}
}
}
} catch (e) {
debugLogger.error(`Failed to parse line: ${line}`, e);
}
}
private _emit(events: AgentEvent[]): void {
if (events.length === 0) return;
const subscribers = [...this._subscribers];
for (const event of events) {
this._events.push(event);
for (const subscriber of subscribers) {
subscriber(event);
}
}
}
private _nextEventFields() {
return {
id: `${this._activeStreamId}-${this._eventCounter++}`,
timestamp: new Date().toISOString(),
streamId: this._activeStreamId!,
};
}
private _makeUserMessageEvent(
content: ContentPart[],
displayContent?: string,
meta?: Record<string, unknown>,
): AgentEvent<'message'> {
const eventContent: ContentPart[] = displayContent
? [{ type: 'text', text: displayContent }]
: content;
return {
...this._nextEventFields(),
type: 'message',
role: 'user',
content: eventContent,
...(meta ? { _meta: meta } : {}),
};
}
private _makeMessageEvent(
role: 'agent' | 'user' | 'developer',
content: ContentPart[],
): AgentEvent<'message'> {
return {
...this._nextEventFields(),
type: 'message',
role,
content,
};
}
private _makeAgentStartEvent(): AgentEvent<'agent_start'> {
return {
...this._nextEventFields(),
type: 'agent_start',
};
}
private _makeAgentEndEvent(reason: string): AgentEvent<'agent_end'> {
return {
...this._nextEventFields(),
type: 'agent_end',
reason,
};
}
private _makeErrorEvent(payload: Omit<AgentEvent<'error'>, 'id' | 'timestamp' | 'streamId' | 'type'>): AgentEvent<'error'> {
return {
...this._nextEventFields(),
type: 'error',
...payload,
};
}
private _makeToolRequestEvent(payload: Omit<AgentEvent<'tool_request'>, 'id' | 'timestamp' | 'streamId' | 'type'>): AgentEvent<'tool_request'> {
return {
...this._nextEventFields(),
type: 'tool_request',
...payload,
};
}
private _makeToolResponseEvent(payload: Omit<AgentEvent<'tool_response'>, 'id' | 'timestamp' | 'streamId' | 'type'>): AgentEvent<'tool_response'> {
return {
...this._nextEventFields(),
type: 'tool_response',
...payload,
};
}
}
export class EnterpriseAgentSession extends AgentSession {
constructor(deps: EnterpriseAgentSessionDeps) {
super(new EnterpriseAgentProtocol(deps));
}
}
-1
View File
@@ -2019,7 +2019,6 @@ describe('Server Config (config.ts)', () => {
expect(configInternal.lastEmittedQuotaRemaining).toBeUndefined();
expect(configInternal.lastEmittedQuotaLimit).toBeUndefined();
expect(configInternal.lastQuotaFetchTime).toBe(0);
expect(configInternal.hasAccessToPreviewModel).toBeNull();
// Event emission
expect(emitQuotaSpy).toHaveBeenCalledWith(undefined, undefined, undefined);
+21 -1
View File
@@ -208,6 +208,12 @@ export interface PlanSettings {
modelRouting?: boolean;
}
export interface EnterpriseSettings {
projectId?: string;
engineId?: string;
location?: string;
}
export interface TelemetrySettings {
enabled?: boolean;
traces?: boolean;
@@ -237,6 +243,7 @@ export interface GemmaModelRouterSettings {
export interface ADKSettings {
agentSessionNoninteractiveEnabled?: boolean;
agentSessionInteractiveEnabled?: boolean;
agentSessionSubagentEnabled?: boolean;
}
export interface ExtensionSetting {
@@ -741,6 +748,7 @@ export interface ConfigParameters {
};
vertexAiRouting?: VertexAiRoutingConfig;
logRagSnippets?: boolean;
enterprise?: EnterpriseSettings;
}
export class Config implements McpContext, AgentLoopContext {
@@ -913,6 +921,7 @@ export class Config implements McpContext, AgentLoopContext {
private readonly gemmaModelRouter: GemmaModelRouterSettings;
private readonly agentSessionNoninteractiveEnabled: boolean;
private readonly agentSessionInteractiveEnabled: boolean;
private readonly agentSessionSubagentEnabled: boolean;
private readonly retryFetchErrors: boolean;
private readonly maxAttempts: number;
@@ -976,12 +985,14 @@ export class Config implements McpContext, AgentLoopContext {
private lastModeSwitchTime: number = performance.now();
readonly injectionService: InjectionService;
private approvedPlanPath: string | undefined;
private readonly enterprise?: EnterpriseSettings;
constructor(params: ConfigParameters) {
this._sessionId = params.sessionId;
this.clientName = params.clientName;
this._clientVersion = params.clientVersion ?? 'unknown';
this.approvedPlanPath = undefined;
this.enterprise = params.enterprise;
this.embeddingModel =
params.embeddingModel ?? DEFAULT_GEMINI_EMBEDDING_MODEL;
@@ -1359,6 +1370,8 @@ export class Config implements McpContext, AgentLoopContext {
params.adk?.agentSessionNoninteractiveEnabled ?? false;
this.agentSessionInteractiveEnabled =
params.adk?.agentSessionInteractiveEnabled ?? false;
this.agentSessionSubagentEnabled =
params.adk?.agentSessionSubagentEnabled ?? false;
this.retryFetchErrors = params.retryFetchErrors ?? true;
this.maxAttempts = Math.min(
params.maxAttempts ?? DEFAULT_MAX_ATTEMPTS,
@@ -1813,6 +1826,10 @@ export class Config implements McpContext, AgentLoopContext {
return this.clientName;
}
getEnterpriseConfig(): EnterpriseSettings | undefined {
return this.enterprise;
}
setSessionId(sessionId: string): void {
const previousPlansDir = this.storage.isInitialized()
? this.storage.getPlansDir()
@@ -1833,7 +1850,6 @@ export class Config implements McpContext, AgentLoopContext {
this.modelQuotas.clear();
this.lastRetrievedQuota = undefined;
this.lastQuotaFetchTime = 0;
this.hasAccessToPreviewModel = null;
// Force an event emission to clear the UI display
coreEvents.emitQuotaChanged(undefined, undefined, undefined);
@@ -2574,6 +2590,10 @@ export class Config implements McpContext, AgentLoopContext {
return this.contextManagement.enabled;
}
isAgentSessionSubagentEnabled(): boolean {
return this.agentSessionSubagentEnabled;
}
getMemoryBoundaryMarkers(): readonly string[] {
return this.memoryBoundaryMarkers;
}
@@ -71,6 +71,24 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
model: 'gemini-3-flash-preview',
},
},
'gemini-3.1-pro-preview': {
extends: 'chat-base-3',
modelConfig: {
model: 'gemini-3.1-pro-preview',
},
},
'gemini-3.1-pro-preview-customtools': {
extends: 'chat-base-3',
modelConfig: {
model: 'gemini-3.1-pro-preview-customtools',
},
},
'gemini-3.1-flash-lite-preview': {
extends: 'chat-base-3',
modelConfig: {
model: 'gemini-3.1-flash-lite-preview',
},
},
'gemini-2.5-pro': {
extends: 'chat-base-2.5',
modelConfig: {
+32
View File
@@ -672,3 +672,35 @@ describe('isActiveModel', () => {
).toBe(false);
});
});
describe('Gemini 3.1 Config Resolution', () => {
it('PREVIEW_GEMINI_3_1_MODEL should resolve to chat-base-3 config (including thinkingLevel)', () => {
const resolved = modelConfigService.getResolvedConfig({
model: PREVIEW_GEMINI_3_1_MODEL,
isChatModel: true,
});
expect(
resolved.generateContentConfig?.thinkingConfig?.thinkingLevel,
).toBeDefined();
});
it('PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL should resolve to chat-base-3 config (including thinkingLevel)', () => {
const resolved = modelConfigService.getResolvedConfig({
model: PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
isChatModel: true,
});
expect(
resolved.generateContentConfig?.thinkingConfig?.thinkingLevel,
).toBeDefined();
});
it('PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL should resolve to chat-base-3 config (including thinkingLevel)', () => {
const resolved = modelConfigService.getResolvedConfig({
model: PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
isChatModel: true,
});
expect(
resolved.generateContentConfig?.thinkingConfig?.thinkingLevel,
).toBeDefined();
});
});
+1 -1
View File
@@ -164,7 +164,7 @@ export function resolveModel(
switch (normalizedModel) {
case GEMINI_MODEL_ALIAS_AUTO:
case GEMINI_MODEL_ALIAS_PRO: {
if (currentReleaseChannel === 'stable') {
if (!hasAccessToPreview) {
resolved = DEFAULT_GEMINI_MODEL;
break;
}
+1
View File
@@ -196,6 +196,7 @@ export { resetBrowserSession } from './agents/browser/browserAgentFactory.js';
// Export agent session interface
export * from './agent/agent-session.js';
export * from './agent/legacy-agent-session.js';
export * from './agent/enterprise-agent-session.js';
export * from './agent/event-translator.js';
export * from './agent/content-utils.js';
export * from './agent/tool-display-utils.js';
@@ -61,6 +61,42 @@
"topK": 64
}
},
"gemini-3.1-pro-preview": {
"model": "gemini-3.1-pro-preview",
"generateContentConfig": {
"temperature": 1,
"topP": 0.95,
"thinkingConfig": {
"includeThoughts": true,
"thinkingLevel": "HIGH"
},
"topK": 64
}
},
"gemini-3.1-pro-preview-customtools": {
"model": "gemini-3.1-pro-preview-customtools",
"generateContentConfig": {
"temperature": 1,
"topP": 0.95,
"thinkingConfig": {
"includeThoughts": true,
"thinkingLevel": "HIGH"
},
"topK": 64
}
},
"gemini-3.1-flash-lite-preview": {
"model": "gemini-3.1-flash-lite-preview",
"generateContentConfig": {
"temperature": 1,
"topP": 0.95,
"thinkingConfig": {
"includeThoughts": true,
"thinkingLevel": "HIGH"
},
"topK": 64
}
},
"gemini-2.5-pro": {
"model": "gemini-2.5-pro",
"generateContentConfig": {
@@ -61,6 +61,42 @@
"topK": 64
}
},
"gemini-3.1-pro-preview": {
"model": "gemini-3.1-pro-preview",
"generateContentConfig": {
"temperature": 1,
"topP": 0.95,
"thinkingConfig": {
"includeThoughts": true,
"thinkingLevel": "HIGH"
},
"topK": 64
}
},
"gemini-3.1-pro-preview-customtools": {
"model": "gemini-3.1-pro-preview-customtools",
"generateContentConfig": {
"temperature": 1,
"topP": 0.95,
"thinkingConfig": {
"includeThoughts": true,
"thinkingLevel": "HIGH"
},
"topK": 64
}
},
"gemini-3.1-flash-lite-preview": {
"model": "gemini-3.1-flash-lite-preview",
"generateContentConfig": {
"temperature": 1,
"topP": 0.95,
"thinkingConfig": {
"includeThoughts": true,
"thinkingLevel": "HIGH"
},
"topK": 64
}
},
"gemini-2.5-pro": {
"model": "gemini-2.5-pro",
"generateContentConfig": {
+46 -10
View File
@@ -5,7 +5,7 @@
*/
import { updateGlobalFetchTimeouts } from './fetch.js';
import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import * as dnsPromises from 'node:dns/promises';
import type { LookupAddress, LookupAllOptions } from 'node:dns';
import ipaddr from 'ipaddr.js';
@@ -34,18 +34,14 @@ const {
fetchWithTimeout,
setGlobalProxy,
} = await import('./fetch.js');
// Mock global fetch
const originalFetch = global.fetch;
global.fetch = vi.fn();
interface ErrorWithCode extends Error {
code?: string;
}
describe('fetch utils', () => {
beforeEach(() => {
vi.clearAllMocks();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.spyOn(global, 'fetch').mockImplementation(vi.fn() as any);
// Default DNS lookup to return a public IP, or the IP itself if valid
vi.mocked(
dnsPromises.lookup as (
@@ -60,8 +56,8 @@ describe('fetch utils', () => {
});
});
afterAll(() => {
global.fetch = originalFetch;
afterEach(() => {
vi.restoreAllMocks();
});
describe('isAddressPrivate', () => {
@@ -177,7 +173,7 @@ describe('fetch utils', () => {
});
describe('fetchWithTimeout', () => {
it('should handle timeouts', async () => {
it('should throw FetchError with ETIMEDOUT on an internal timeout', async () => {
vi.mocked(global.fetch).mockImplementation(
(_input, init) =>
new Promise((_resolve, reject) => {
@@ -198,6 +194,46 @@ describe('fetch utils', () => {
'Request timed out after 50ms',
);
});
it('should throw an AbortError (not ETIMEDOUT) when the caller signal is aborted', async () => {
vi.mocked(global.fetch).mockImplementation(
(_input, init) =>
new Promise((_resolve, reject) => {
const rejectWithAbortError = () => {
const error = new Error('The operation was aborted');
error.name = 'AbortError';
// @ts-expect-error - for mocking purposes
error.code = 'ABORT_ERR';
reject(error);
};
// Handle the case where the signal is already aborted before
// fetch is called (e.g. controller.abort() called synchronously).
if (init?.signal?.aborted) {
rejectWithAbortError();
return;
}
if (init?.signal) {
init.signal.addEventListener('abort', rejectWithAbortError, {
once: true,
});
}
}),
);
const controller = new AbortController();
// Abort the external signal before the request even starts
controller.abort();
const rejection = fetchWithTimeout('http://example.com', 10_000, {
signal: controller.signal,
});
await expect(rejection).rejects.toMatchObject({ name: 'AbortError' });
// Must NOT be classified as a timeout
await expect(rejection).rejects.not.toThrow('timed out');
});
});
describe('setGlobalProxy', () => {
+10 -2
View File
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { getErrorMessage, isNodeError } from './errors.js';
import { getErrorMessage, isAbortError } from './errors.js';
import { URL } from 'node:url';
import { Agent, ProxyAgent, setGlobalDispatcher } from 'undici';
import ipaddr from 'ipaddr.js';
@@ -202,7 +202,15 @@ export async function fetchWithTimeout(
});
return response;
} catch (error) {
if (isNodeError(error) && error.code === 'ABORT_ERR') {
if (isAbortError(error)) {
// If the caller's own signal was already aborted, this is a user-initiated
// cancellation (e.g. Ctrl+C), not an internal timeout. Re-throw as a plain
// AbortError so the retry layer does NOT treat it as a retryable ETIMEDOUT.
if (options?.signal?.aborted) {
// Rethrow the original abort reason or the caught error to preserve
// the stack trace and any custom abort reason (e.g. from Ctrl+C).
throw options.signal.reason ?? error;
}
throw new FetchError(`Request timed out after ${timeout}ms`, 'ETIMEDOUT');
}
throw new FetchError(getErrorMessage(error), undefined, { cause: error });
File diff suppressed because one or more lines are too long