feat(sdk): implements SessionContext for SDK tool calls (#18862)

This commit is contained in:
Michael Bleigh
2026-02-12 23:28:48 -08:00
committed by GitHub
parent 83acf54589
commit 89f1d168af
15 changed files with 903 additions and 27 deletions
+46 -9
View File
@@ -7,29 +7,38 @@
import {
Config,
type ConfigParameters,
AuthType,
PREVIEW_GEMINI_MODEL_AUTO,
GeminiEventType,
type ToolCallRequestInfo,
type ServerGeminiStreamEvent,
type GeminiClient,
type Content,
scheduleAgentTools,
getAuthTypeFromEnv,
AuthType,
type ToolRegistry,
} from '@google/gemini-cli-core';
import { type Tool, SdkTool, type z } from './tool.js';
import { type Tool, SdkTool } from './tool.js';
import { SdkAgentFilesystem } from './fs.js';
import { SdkAgentShell } from './shell.js';
import type { SessionContext } from './types.js';
export interface GeminiCliAgentOptions {
instructions: string;
tools?: Array<Tool<z.ZodType>>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
tools?: Array<Tool<any>>;
model?: string;
cwd?: string;
debug?: boolean;
recordResponses?: string;
fakeResponses?: string;
}
export class GeminiCliAgent {
private readonly config: Config;
private readonly tools: Array<Tool<z.ZodType>>;
private config: Config;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private tools: Array<Tool<any>>;
constructor(options: GeminiCliAgentOptions) {
const cwd = options.cwd || process.cwd();
@@ -46,6 +55,8 @@ export class GeminiCliAgent {
enableHooks: false,
mcpEnabled: false,
extensionsEnabled: false,
recordResponses: options.recordResponses,
fakeResponses: options.fakeResponses,
};
this.config = new Config(configParams);
@@ -67,18 +78,21 @@ export class GeminiCliAgent {
const messageBus = this.config.getMessageBus();
for (const toolDef of this.tools) {
const sdkTool = new SdkTool(toolDef, messageBus);
const sdkTool = new SdkTool(toolDef, messageBus, this);
registry.registerTool(sdkTool);
}
}
const client = this.config.getGeminiClient();
const abortSignal = signal ?? new AbortController().signal;
const sessionId = this.config.getSessionId();
const fs = new SdkAgentFilesystem(this.config);
const shell = new SdkAgentShell(this.config);
let request: Parameters<GeminiClient['sendMessageStream']>[0] = [
{ text: prompt },
];
const abortSignal = signal ?? new AbortController().signal;
const sessionId = this.config.getSessionId();
while (true) {
// sendMessageStream returns AsyncGenerator<ServerGeminiStreamEvent, Turn>
@@ -107,12 +121,35 @@ export class GeminiCliAgent {
break;
}
// Prepare SessionContext
const transcript: Content[] = client.getHistory();
const context: SessionContext = {
sessionId,
transcript,
cwd: this.config.getWorkingDir(),
timestamp: new Date().toISOString(),
fs,
shell,
agent: this,
};
// Create a scoped registry for this turn to bind context safely
const originalRegistry = this.config.getToolRegistry();
const scopedRegistry: ToolRegistry = Object.create(originalRegistry);
scopedRegistry.getTool = (name: string) => {
const tool = originalRegistry.getTool(name);
if (tool instanceof SdkTool) {
return tool.bindContext(context);
}
return tool;
};
const completedCalls = await scheduleAgentTools(
this.config,
toolCallsToSchedule,
{
schedulerId: sessionId,
toolRegistry: this.config.getToolRegistry(),
toolRegistry: scopedRegistry,
signal: abortSignal,
},
);
+35
View File
@@ -0,0 +1,35 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { Config as CoreConfig } from '@google/gemini-cli-core';
import type { AgentFilesystem } from './types.js';
import fs from 'node:fs/promises';
export class SdkAgentFilesystem implements AgentFilesystem {
constructor(private readonly config: CoreConfig) {}
async readFile(path: string): Promise<string | null> {
const error = this.config.validatePathAccess(path, 'read');
if (error) {
// For now, if access is denied, we can either throw or return null.
// Returning null makes sense for "file not found or readable".
return null;
}
try {
return await fs.readFile(path, 'utf-8');
} catch {
return null;
}
}
async writeFile(path: string, content: string): Promise<void> {
const error = this.config.validatePathAccess(path, 'write');
if (error) {
throw new Error(error);
}
await fs.writeFile(path, content, 'utf-8');
}
}
+1
View File
@@ -6,3 +6,4 @@
export * from './agent.js';
export * from './tool.js';
export * from './types.js';
+69
View File
@@ -0,0 +1,69 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { Config as CoreConfig } from '@google/gemini-cli-core';
import { ShellExecutionService, ShellTool } from '@google/gemini-cli-core';
import type {
AgentShell,
AgentShellResult,
AgentShellOptions,
} from './types.js';
export class SdkAgentShell implements AgentShell {
constructor(private readonly config: CoreConfig) {}
async exec(
command: string,
options?: AgentShellOptions,
): Promise<AgentShellResult> {
const cwd = options?.cwd || this.config.getWorkingDir();
const abortController = new AbortController();
// Use ShellTool to check policy
const shellTool = new ShellTool(this.config, this.config.getMessageBus());
try {
const invocation = shellTool.build({
command,
dir_path: cwd,
});
const confirmation = await invocation.shouldConfirmExecute(
abortController.signal,
);
if (confirmation) {
throw new Error(
'Command execution requires confirmation but no interactive session is available.',
);
}
} catch (error) {
return {
output: '',
stdout: '',
stderr: '',
exitCode: 1,
error: error instanceof Error ? error : new Error(String(error)),
};
}
const handle = await ShellExecutionService.execute(
command,
cwd,
() => {}, // No-op output event handler for now
abortController.signal,
false, // shouldUseNodePty: false for headless execution
this.config.getShellExecutionConfig(),
);
const result = await handle.result;
return {
output: result.output,
stdout: result.output, // ShellExecutionService combines stdout/stderr usually
stderr: '', // ShellExecutionService currently combines, so stderr is empty or mixed
exitCode: result.exitCode,
};
}
}
+147
View File
@@ -0,0 +1,147 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { GeminiCliAgent } from './agent.js';
import * as path from 'node:path';
import { z } from 'zod';
import { tool, ModelVisibleError } from './tool.js';
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Set this to true locally when you need to update snapshots
const RECORD_MODE = process.env['RECORD_NEW_RESPONSES'] === 'true';
const getGoldenPath = (name: string) =>
path.resolve(__dirname, '../test-data', `${name}.json`);
describe('GeminiCliAgent Tool Integration', () => {
it('handles tool execution success', async () => {
const goldenFile = getGoldenPath('tool-success');
const agent = new GeminiCliAgent({
instructions: 'You are a helpful assistant.',
// If recording, use real model + record path.
// If testing, use auto model + fake path.
model: RECORD_MODE ? 'gemini-2.0-flash' : undefined,
recordResponses: RECORD_MODE ? goldenFile : undefined,
fakeResponses: RECORD_MODE ? undefined : goldenFile,
tools: [
tool(
{
name: 'add',
description: 'Adds two numbers',
inputSchema: z.object({ a: z.number(), b: z.number() }),
},
async ({ a, b }) => a + b,
),
],
});
const events = [];
const stream = agent.sendStream('What is 5 + 3?');
for await (const event of stream) {
events.push(event);
}
const textEvents = events.filter((e) => e.type === 'content');
const responseText = textEvents
.map((e) => (typeof e.value === 'string' ? e.value : ''))
.join('');
expect(responseText).toContain('8');
});
it('handles ModelVisibleError correctly', async () => {
const goldenFile = getGoldenPath('tool-error-recovery');
const agent = new GeminiCliAgent({
instructions: 'You are a helpful assistant.',
model: RECORD_MODE ? 'gemini-2.0-flash' : undefined,
recordResponses: RECORD_MODE ? goldenFile : undefined,
fakeResponses: RECORD_MODE ? undefined : goldenFile,
tools: [
tool(
{
name: 'failVisible',
description: 'Fails with a visible error if input is "fail"',
inputSchema: z.object({ input: z.string() }),
},
async ({ input }) => {
if (input === 'fail') {
throw new ModelVisibleError('Tool failed visibly');
}
return 'Success';
},
),
],
});
const events = [];
// Force the model to trigger the error first, then hopefully recover or at least acknowledge it.
// The prompt is crafted to make the model try 'fail' first.
const stream = agent.sendStream(
'Call the tool with "fail". If it fails, tell me the error message.',
);
for await (const event of stream) {
events.push(event);
}
const textEvents = events.filter((e) => e.type === 'content');
const responseText = textEvents
.map((e) => (typeof e.value === 'string' ? e.value : ''))
.join('');
// The model should see the error "Tool failed visibly" and report it back.
expect(responseText).toContain('Tool failed visibly');
});
it('handles sendErrorsToModel: true correctly', async () => {
const goldenFile = getGoldenPath('tool-catchall-error');
const agent = new GeminiCliAgent({
instructions: 'You are a helpful assistant.',
model: RECORD_MODE ? 'gemini-2.0-flash' : undefined,
recordResponses: RECORD_MODE ? goldenFile : undefined,
fakeResponses: RECORD_MODE ? undefined : goldenFile,
tools: [
tool(
{
name: 'checkSystemStatus',
description: 'Checks the current system status',
inputSchema: z.object({}),
sendErrorsToModel: true,
},
async () => {
throw new Error('Standard error caught');
},
),
],
});
const events = [];
const stream = agent.sendStream(
'Check the system status and report any errors.',
);
for await (const event of stream) {
events.push(event);
}
const textEvents = events.filter((e) => e.type === 'content');
const responseText = textEvents
.map((e) => (typeof e.value === 'string' ? e.value : ''))
.join('');
// The model should report the caught standard error.
expect(responseText.toLowerCase()).toContain('error');
});
});
+143
View File
@@ -0,0 +1,143 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { z } from 'zod';
import { SdkTool, tool, ModelVisibleError } from './tool.js';
import type { MessageBus } from '@google/gemini-cli-core';
// Mock MessageBus
const mockMessageBus = {} as unknown as MessageBus;
describe('tool()', () => {
it('creates a tool definition with defaults', () => {
const definition = tool(
{
name: 'testTool',
description: 'A test tool',
inputSchema: z.object({ foo: z.string() }),
},
async () => 'result',
);
expect(definition.name).toBe('testTool');
expect(definition.description).toBe('A test tool');
expect(definition.sendErrorsToModel).toBeUndefined();
});
it('creates a tool definition with explicit configuration', () => {
const definition = tool(
{
name: 'testTool',
description: 'A test tool',
inputSchema: z.object({ foo: z.string() }),
sendErrorsToModel: true,
},
async () => 'result',
);
expect(definition.sendErrorsToModel).toBe(true);
});
});
describe('SdkTool Execution', () => {
it('executes successfully', async () => {
const definition = tool(
{
name: 'successTool',
description: 'Always succeeds',
inputSchema: z.object({ val: z.string() }),
},
async ({ val }) => `Success: ${val}`,
);
const sdkTool = new SdkTool(definition, mockMessageBus);
const invocation = sdkTool.createInvocationWithContext(
{ val: 'test' },
mockMessageBus,
undefined,
);
const result = await invocation.execute(new AbortController().signal);
expect(result.llmContent).toBe('Success: test');
expect(result.error).toBeUndefined();
});
it('throws standard Error by default', async () => {
const definition = tool(
{
name: 'failTool',
description: 'Always fails',
inputSchema: z.object({}),
},
async () => {
throw new Error('Standard error');
},
);
const sdkTool = new SdkTool(definition, mockMessageBus);
const invocation = sdkTool.createInvocationWithContext(
{},
mockMessageBus,
undefined,
);
await expect(
invocation.execute(new AbortController().signal),
).rejects.toThrow('Standard error');
});
it('catches ModelVisibleError and returns ToolResult error', async () => {
const definition = tool(
{
name: 'visibleErrorTool',
description: 'Fails with visible error',
inputSchema: z.object({}),
},
async () => {
throw new ModelVisibleError('Visible error');
},
);
const sdkTool = new SdkTool(definition, mockMessageBus);
const invocation = sdkTool.createInvocationWithContext(
{},
mockMessageBus,
undefined,
);
const result = await invocation.execute(new AbortController().signal);
expect(result.error).toBeDefined();
expect(result.error?.message).toBe('Visible error');
expect(result.llmContent).toContain('Error: Visible error');
});
it('catches standard Error when sendErrorsToModel is true', async () => {
const definition = tool(
{
name: 'catchAllTool',
description: 'Catches all errors',
inputSchema: z.object({}),
sendErrorsToModel: true,
},
async () => {
throw new Error('Standard error');
},
);
const sdkTool = new SdkTool(definition, mockMessageBus);
const invocation = sdkTool.createInvocationWithContext(
{},
mockMessageBus,
undefined,
);
const result = await invocation.execute(new AbortController().signal);
expect(result.error).toBeDefined();
expect(result.error?.message).toBe('Standard error');
expect(result.llmContent).toContain('Error: Standard error');
});
});
+59 -18
View File
@@ -14,28 +14,42 @@ import {
Kind,
type MessageBus,
} from '@google/gemini-cli-core';
import type { SessionContext } from './types.js';
export { z };
export interface ToolDefinition<T extends z.ZodType> {
export class ModelVisibleError extends Error {
constructor(message: string | Error) {
super(message instanceof Error ? message.message : message);
this.name = 'ModelVisibleError';
}
}
export interface ToolDefinition<T extends z.ZodTypeAny> {
name: string;
description: string;
inputSchema: T;
sendErrorsToModel?: boolean;
}
export interface Tool<T extends z.ZodType> extends ToolDefinition<T> {
action: (params: z.infer<T>) => Promise<unknown>;
export interface Tool<T extends z.ZodTypeAny> extends ToolDefinition<T> {
action: (params: z.infer<T>, context?: SessionContext) => Promise<unknown>;
}
class SdkToolInvocation<T extends z.ZodType> extends BaseToolInvocation<
class SdkToolInvocation<T extends z.ZodTypeAny> extends BaseToolInvocation<
z.infer<T>,
ToolResult
> {
constructor(
params: z.infer<T>,
messageBus: MessageBus,
private readonly action: (params: z.infer<T>) => Promise<unknown>,
private readonly action: (
params: z.infer<T>,
context?: SessionContext,
) => Promise<unknown>,
private readonly context: SessionContext | undefined,
toolName: string,
private readonly sendErrorsToModel: boolean = false,
) {
super(params, messageBus, toolName);
}
@@ -49,7 +63,7 @@ class SdkToolInvocation<T extends z.ZodType> extends BaseToolInvocation<
_updateOutput?: (output: string) => void,
): Promise<ToolResult> {
try {
const result = await this.action(this.params);
const result = await this.action(this.params, this.context);
const output =
typeof result === 'string' ? result : JSON.stringify(result, null, 2);
return {
@@ -57,26 +71,31 @@ class SdkToolInvocation<T extends z.ZodType> extends BaseToolInvocation<
returnDisplay: output,
};
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
return {
llmContent: `Error: ${errorMessage}`,
returnDisplay: `Error: ${errorMessage}`,
error: {
message: errorMessage,
},
};
if (this.sendErrorsToModel || error instanceof ModelVisibleError) {
const errorMessage =
error instanceof Error ? error.message : String(error);
return {
llmContent: `Error: ${errorMessage}`,
returnDisplay: `Error: ${errorMessage}`,
error: {
message: errorMessage,
},
};
}
throw error;
}
}
}
export class SdkTool<T extends z.ZodType> extends BaseDeclarativeTool<
export class SdkTool<T extends z.ZodTypeAny> extends BaseDeclarativeTool<
z.infer<T>,
ToolResult
> {
constructor(
private readonly definition: Tool<T>,
messageBus: MessageBus,
_agent?: unknown,
private readonly context?: SessionContext,
) {
super(
definition.name,
@@ -88,6 +107,26 @@ export class SdkTool<T extends z.ZodType> extends BaseDeclarativeTool<
);
}
bindContext(context: SessionContext): SdkTool<T> {
return new SdkTool(this.definition, this.messageBus, undefined, context);
}
createInvocationWithContext(
params: z.infer<T>,
messageBus: MessageBus,
context: SessionContext | undefined,
toolName?: string,
): ToolInvocation<z.infer<T>, ToolResult> {
return new SdkToolInvocation(
params,
messageBus,
this.definition.action,
context || this.context,
toolName || this.name,
this.definition.sendErrorsToModel,
);
}
protected createInvocation(
params: z.infer<T>,
messageBus: MessageBus,
@@ -97,14 +136,16 @@ export class SdkTool<T extends z.ZodType> extends BaseDeclarativeTool<
params,
messageBus,
this.definition.action,
this.context,
toolName || this.name,
this.definition.sendErrorsToModel,
);
}
}
export function tool<T extends z.ZodType>(
export function tool<T extends z.ZodTypeAny>(
definition: ToolDefinition<T>,
action: (params: z.infer<T>) => Promise<unknown>,
action: (params: z.infer<T>, context?: SessionContext) => Promise<unknown>,
): Tool<T> {
return {
...definition,
+41
View File
@@ -0,0 +1,41 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { Content } from '@google/gemini-cli-core';
import type { GeminiCliAgent } from './agent.js';
export interface AgentFilesystem {
readFile(path: string): Promise<string | null>;
writeFile(path: string, content: string): Promise<void>;
}
export interface AgentShellOptions {
env?: Record<string, string>;
timeoutSeconds?: number;
cwd?: string;
}
export interface AgentShellResult {
exitCode: number | null;
output: string;
stdout: string;
stderr: string;
error?: Error;
}
export interface AgentShell {
exec(cmd: string, options?: AgentShellOptions): Promise<AgentShellResult>;
}
export interface SessionContext {
sessionId: string;
transcript: Content[];
cwd: string;
timestamp: string;
fs: AgentFilesystem;
shell: AgentShell;
agent: GeminiCliAgent;
}