From d0ca05329390b75b714ac372afb412c824f611db Mon Sep 17 00:00:00 2001 From: Michael Bleigh Date: Tue, 10 Feb 2026 14:58:45 -0800 Subject: [PATCH 1/7] feat(sdk): initial package bootstrap for SDK --- eslint.config.js | 12 +++ package-lock.json | 21 +++++ packages/sdk/README.md | 17 ++++ packages/sdk/examples/simple.ts | 38 +++++++++ packages/sdk/index.ts | 7 ++ packages/sdk/package.json | 36 ++++++++ packages/sdk/src/agent.ts | 144 ++++++++++++++++++++++++++++++++ packages/sdk/src/tool.ts | 113 +++++++++++++++++++++++++ packages/sdk/tsconfig.json | 12 +++ packages/sdk/vitest.config.ts | 14 ++++ 10 files changed, 414 insertions(+) create mode 100644 packages/sdk/README.md create mode 100644 packages/sdk/examples/simple.ts create mode 100644 packages/sdk/index.ts create mode 100644 packages/sdk/package.json create mode 100644 packages/sdk/src/agent.ts create mode 100644 packages/sdk/src/tool.ts create mode 100644 packages/sdk/tsconfig.json create mode 100644 packages/sdk/vitest.config.ts diff --git a/eslint.config.js b/eslint.config.js index 7839ae78f67..48af3775f26 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -239,6 +239,18 @@ export default tseslint.config( ], }, }, + { + files: ['packages/sdk/src/**/*.{ts,tsx}'], + rules: { + 'no-restricted-imports': [ + 'error', + { + name: '@google/gemini-cli-sdk', + message: 'Please use relative imports within the @google/gemini-cli-sdk package.', + }, + ], + }, + }, { files: ['packages/*/src/**/*.test.{ts,tsx}'], plugins: { diff --git a/package-lock.json b/package-lock.json index e8bb6e6902b..3239740f481 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1389,6 +1389,10 @@ "resolved": "packages/core", "link": true }, + "node_modules/@google/gemini-cli-sdk": { + "resolved": "packages/sdk", + "link": true + }, "node_modules/@google/gemini-cli-test-utils": { "resolved": "packages/test-utils", "link": true @@ -17555,6 +17559,23 @@ "uuid": "dist-node/bin/uuid" } }, + "packages/sdk": { + "name": "@google/gemini-cli-sdk", + "version": "0.29.0-nightly.20260203.71f46f116", + "license": "Apache-2.0", + "dependencies": { + "@google/gemini-cli-core": "file:../core", + "zod": "^3.23.8", + "zod-to-json-schema": "^3.23.1" + }, + "devDependencies": { + "typescript": "^5.3.3", + "vitest": "^3.1.1" + }, + "engines": { + "node": ">=20" + } + }, "packages/test-utils": { "name": "@google/gemini-cli-test-utils", "version": "0.30.0-nightly.20260210.a2174751d", diff --git a/packages/sdk/README.md b/packages/sdk/README.md new file mode 100644 index 00000000000..38d027bfefb --- /dev/null +++ b/packages/sdk/README.md @@ -0,0 +1,17 @@ +# Gemini CLI SDK + +The public SDK for running Gemini CLI's core agent. + +## Installation + +```bash +npm install @google/gemini-cli-sdk +``` + +## Usage + +```typescript +import { helloSdk } from '@google/gemini-cli-sdk'; + +console.log(helloSdk()); +``` diff --git a/packages/sdk/examples/simple.ts b/packages/sdk/examples/simple.ts new file mode 100644 index 00000000000..6c2773b0c8a --- /dev/null +++ b/packages/sdk/examples/simple.ts @@ -0,0 +1,38 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { GeminiCliAgent, tool, z } from '../src/index.js'; + +async function main() { + const myTool = tool( + { + name: 'add', + description: 'Add two numbers.', + inputSchema: z.object({ + a: z.number().describe('the first number'), + b: z.number().describe('the second number'), + }), + }, + async ({ a, b }) => { + console.log(`Tool 'add' called with a=${a}, b=${b}`); + return { result: a + b }; + }, + ); + + const agent = new GeminiCliAgent({ + instructions: 'Make sure to always talk like a pirate.', + tools: [myTool], + }); + + console.log("Sending prompt: 'add 5 + 6'"); + for await (const chunk of agent.sendStream( + 'add 5 + 6 and tell me a story involving the result', + )) { + console.log(JSON.stringify(chunk, null, 2)); + } +} + +main().catch(console.error); diff --git a/packages/sdk/index.ts b/packages/sdk/index.ts new file mode 100644 index 00000000000..75f51214139 --- /dev/null +++ b/packages/sdk/index.ts @@ -0,0 +1,7 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './src/index.js'; diff --git a/packages/sdk/package.json b/packages/sdk/package.json new file mode 100644 index 00000000000..19c85ed58ac --- /dev/null +++ b/packages/sdk/package.json @@ -0,0 +1,36 @@ +{ + "name": "@google/gemini-cli-sdk", + "version": "0.29.0-nightly.20260203.71f46f116", + "description": "Gemini CLI SDK", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "git+https://github.com/google-gemini/gemini-cli.git" + }, + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "node ../../scripts/build_package.js", + "lint": "eslint . --ext .ts,.tsx", + "format": "prettier --write .", + "test": "vitest run", + "test:ci": "vitest run", + "typecheck": "tsc --noEmit" + }, + "files": [ + "dist" + ], + "dependencies": { + "@google/gemini-cli-core": "file:../core", + "zod": "^3.23.8", + "zod-to-json-schema": "^3.23.1" + }, + "devDependencies": { + "typescript": "^5.3.3", + "vitest": "^3.1.1" + }, + "engines": { + "node": ">=20" + } +} diff --git a/packages/sdk/src/agent.ts b/packages/sdk/src/agent.ts new file mode 100644 index 00000000000..398ec1bc673 --- /dev/null +++ b/packages/sdk/src/agent.ts @@ -0,0 +1,144 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + Config, + type ConfigParameters, + AuthType, + PREVIEW_GEMINI_MODEL_AUTO, + GeminiEventType, + type ToolCallRequestInfo, + type ServerGeminiStreamEvent, + type GeminiClient, +} from '@google/gemini-cli-core'; + +import { type Tool, SdkTool, type z } from './tool.js'; + +export interface GeminiCliAgentOptions { + instructions: string; + tools?: Array>; + model?: string; + cwd?: string; + debug?: boolean; +} + +export class GeminiCliAgent { + private config: Config; + private tools: Array>; + + constructor(options: GeminiCliAgentOptions) { + const cwd = options.cwd || process.cwd(); + this.tools = options.tools || []; + + const configParams: ConfigParameters = { + sessionId: `sdk-${Date.now()}`, + targetDir: cwd, + cwd, + debugMode: options.debug ?? false, + model: options.model || PREVIEW_GEMINI_MODEL_AUTO, + userMemory: options.instructions, + // Minimal config + enableHooks: false, + mcpEnabled: false, + extensionsEnabled: false, + }; + + this.config = new Config(configParams); + } + + async *sendStream(prompt: string): AsyncGenerator { + // Lazy initialization of auth and client + if (!this.config.getContentGenerator()) { + // Simple auth detection + let authType = AuthType.COMPUTE_ADC; + if (process.env['GEMINI_API_KEY']) { + authType = AuthType.USE_GEMINI; + } else if (process.env['GOOGLE_API_KEY']) { + authType = AuthType.USE_VERTEX_AI; + } + + await this.config.refreshAuth(authType); + await this.config.initialize(); + + // Register tools now that registry exists + const registry = this.config.getToolRegistry(); + const messageBus = this.config.getMessageBus(); + + for (const toolDef of this.tools) { + const sdkTool = new SdkTool(toolDef, messageBus); + registry.registerTool(sdkTool); + } + } + + const client = this.config.getGeminiClient(); + const registry = this.config.getToolRegistry(); + + let request: Parameters[0] = [ + { text: prompt }, + ]; + const signal = new AbortController().signal; // TODO: support signal + const sessionId = this.config.getSessionId(); + + while (true) { + // sendMessageStream returns AsyncGenerator + const stream = client.sendMessageStream(request, signal, sessionId); + + const toolCalls: ToolCallRequestInfo[] = []; + + for await (const event of stream) { + yield event; + if (event.type === GeminiEventType.ToolCallRequest) { + toolCalls.push(event.value); + } + } + + if (toolCalls.length === 0) { + break; + } + + const functionResponses: Array> = []; + for (const toolCall of toolCalls) { + const tool = registry.getTool(toolCall.name); + if (!tool) { + functionResponses.push({ + functionResponse: { + name: toolCall.name, + response: { error: `Tool ${toolCall.name} not found` }, + id: toolCall.callId, + }, + }); + continue; + } + + try { + // Cast toolCall.args to object to satisfy AnyDeclarativeTool.build + const invocation = tool.build(toolCall.args as object); + const result = await invocation.execute(signal); + + functionResponses.push({ + functionResponse: { + name: toolCall.name, + response: { result: result.llmContent }, + id: toolCall.callId, + }, + }); + } catch (e) { + functionResponses.push({ + functionResponse: { + name: toolCall.name, + response: { error: e instanceof Error ? e.message : String(e) }, + id: toolCall.callId, + }, + }); + } + } + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + request = functionResponses as unknown as Parameters< + GeminiClient['sendMessageStream'] + >[0]; + } + } +} diff --git a/packages/sdk/src/tool.ts b/packages/sdk/src/tool.ts new file mode 100644 index 00000000000..00cd3802dec --- /dev/null +++ b/packages/sdk/src/tool.ts @@ -0,0 +1,113 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { z } from 'zod'; +import { zodToJsonSchema } from 'zod-to-json-schema'; +import { + BaseDeclarativeTool, + BaseToolInvocation, + type ToolResult, + type ToolInvocation, + Kind, + type MessageBus, +} from '@google/gemini-cli-core'; + +export { z }; + +export interface ToolDefinition { + name: string; + description: string; + inputSchema: T; +} + +export interface Tool extends ToolDefinition { + action: (params: z.infer) => Promise; +} + +class SdkToolInvocation extends BaseToolInvocation< + z.infer, + ToolResult +> { + constructor( + params: z.infer, + messageBus: MessageBus, + private readonly action: (params: z.infer) => Promise, + toolName: string, + ) { + super(params, messageBus, toolName); + } + + getDescription(): string { + return `Executing ${this._toolName}...`; + } + + async execute( + _signal: AbortSignal, + _updateOutput?: (output: string) => void, + ): Promise { + try { + const result = await this.action(this.params); + const output = + typeof result === 'string' ? result : JSON.stringify(result, null, 2); + return { + llmContent: output, + returnDisplay: output, + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + return { + llmContent: `Error: ${errorMessage}`, + returnDisplay: `Error: ${errorMessage}`, + error: { + message: errorMessage, + }, + }; + } + } +} + +export class SdkTool extends BaseDeclarativeTool< + z.infer, + ToolResult +> { + constructor( + private readonly definition: Tool, + messageBus: MessageBus, + ) { + super( + definition.name, + definition.name, + definition.description, + Kind.Other, + zodToJsonSchema(definition.inputSchema), + messageBus, + ); + } + + protected createInvocation( + params: z.infer, + messageBus: MessageBus, + toolName?: string, + ): ToolInvocation, ToolResult> { + return new SdkToolInvocation( + params, + messageBus, + this.definition.action, + toolName || this.name, + ); + } +} + +export function tool( + definition: ToolDefinition, + action: (params: z.infer) => Promise, +): Tool { + return { + ...definition, + action, + }; +} diff --git a/packages/sdk/tsconfig.json b/packages/sdk/tsconfig.json new file mode 100644 index 00000000000..2cd4d6ea73e --- /dev/null +++ b/packages/sdk/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "composite": true, + "lib": ["DOM", "DOM.Iterable", "ES2023"], + "types": ["node", "vitest/globals"] + }, + "include": ["index.ts", "src/**/*.ts", "package.json"], + "exclude": ["node_modules", "dist"], + "references": [{ "path": "../core" }] +} diff --git a/packages/sdk/vitest.config.ts b/packages/sdk/vitest.config.ts new file mode 100644 index 00000000000..08cdff095c4 --- /dev/null +++ b/packages/sdk/vitest.config.ts @@ -0,0 +1,14 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + }, +}); From 5412b9d7f2acb3466cd939eca0e73b2c9633654f Mon Sep 17 00:00:00 2001 From: Michael Bleigh Date: Wed, 11 Feb 2026 17:00:31 -0800 Subject: [PATCH 2/7] fix(sdk): add missing src/index.ts export file --- packages/sdk/src/index.ts | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 packages/sdk/src/index.ts diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts new file mode 100644 index 00000000000..0ad940d7b2c --- /dev/null +++ b/packages/sdk/src/index.ts @@ -0,0 +1,8 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './agent.js'; +export * from './tool.js'; From 07ad7a09da6f74f713a9b57b05ca05305425a583 Mon Sep 17 00:00:00 2001 From: Michael Bleigh Date: Wed, 11 Feb 2026 17:10:34 -0800 Subject: [PATCH 3/7] fix(sdk): handle JSON string tool arguments and add TODO for AbortSignal --- packages/sdk/src/agent.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/sdk/src/agent.ts b/packages/sdk/src/agent.ts index 398ec1bc673..3f15dd709d5 100644 --- a/packages/sdk/src/agent.ts +++ b/packages/sdk/src/agent.ts @@ -79,7 +79,8 @@ export class GeminiCliAgent { let request: Parameters[0] = [ { text: prompt }, ]; - const signal = new AbortController().signal; // TODO: support signal + // TODO: support AbortSignal cancellation properly + const signal = new AbortController().signal; const sessionId = this.config.getSessionId(); while (true) { @@ -114,8 +115,13 @@ export class GeminiCliAgent { } try { + let args = toolCall.args; + if (typeof args === 'string') { + args = JSON.parse(args); + } + // Cast toolCall.args to object to satisfy AnyDeclarativeTool.build - const invocation = tool.build(toolCall.args as object); + const invocation = tool.build(args as object); const result = await invocation.execute(signal); functionResponses.push({ From 5045caddfaa35b945da0fae1bd41f50893e8c063 Mon Sep 17 00:00:00 2001 From: Michael Bleigh Date: Wed, 11 Feb 2026 17:29:18 -0800 Subject: [PATCH 4/7] fix(sdk): enforce tool execution confirmation in GeminiCliAgent and remove placeholder README --- packages/sdk/README.md | 17 ----------------- packages/sdk/src/agent.ts | 9 +++++++++ 2 files changed, 9 insertions(+), 17 deletions(-) delete mode 100644 packages/sdk/README.md diff --git a/packages/sdk/README.md b/packages/sdk/README.md deleted file mode 100644 index 38d027bfefb..00000000000 --- a/packages/sdk/README.md +++ /dev/null @@ -1,17 +0,0 @@ -# Gemini CLI SDK - -The public SDK for running Gemini CLI's core agent. - -## Installation - -```bash -npm install @google/gemini-cli-sdk -``` - -## Usage - -```typescript -import { helloSdk } from '@google/gemini-cli-sdk'; - -console.log(helloSdk()); -``` diff --git a/packages/sdk/src/agent.ts b/packages/sdk/src/agent.ts index 3f15dd709d5..eee421ebcca 100644 --- a/packages/sdk/src/agent.ts +++ b/packages/sdk/src/agent.ts @@ -122,6 +122,15 @@ export class GeminiCliAgent { // Cast toolCall.args to object to satisfy AnyDeclarativeTool.build const invocation = tool.build(args as object); + + // Check if the tool execution requires confirmation according to policy + const confirmation = await invocation.shouldConfirmExecute(signal); + if (confirmation) { + throw new Error( + `Tool execution for '${toolCall.name}' requires confirmation, which is not supported in this SDK version.`, + ); + } + const result = await invocation.execute(signal); functionResponses.push({ From 46b7f07dfaeb5a27e632e547b1dc6705d23f8984 Mon Sep 17 00:00:00 2001 From: Michael Bleigh Date: Tue, 10 Feb 2026 19:27:03 -0800 Subject: [PATCH 5/7] feat(sdk): implements SessionContext for SDK tool calls - adds tests to SDK tools - adds ModelVisibleError for SDK error handling - adds sdk test-data to .prettierignore --- .prettierignore | 1 + packages/sdk/SDK_DESIGN.md | 279 ++++++++++++++++++ packages/sdk/examples/session-context.ts | 73 +++++ packages/sdk/src/agent.ts | 39 ++- packages/sdk/src/fs.ts | 35 +++ packages/sdk/src/index.ts | 1 + packages/sdk/src/shell.ts | 43 +++ packages/sdk/src/tool.integration.test.ts | 147 +++++++++ packages/sdk/src/tool.test.ts | 143 +++++++++ packages/sdk/src/tool.ts | 63 +++- packages/sdk/src/types.ts | 40 +++ .../sdk/test-data/tool-catchall-error.json | 2 + .../sdk/test-data/tool-error-recovery.json | 2 + packages/sdk/test-data/tool-success.json | 2 + 14 files changed, 853 insertions(+), 17 deletions(-) create mode 100644 packages/sdk/SDK_DESIGN.md create mode 100644 packages/sdk/examples/session-context.ts create mode 100644 packages/sdk/src/fs.ts create mode 100644 packages/sdk/src/shell.ts create mode 100644 packages/sdk/src/tool.integration.test.ts create mode 100644 packages/sdk/src/tool.test.ts create mode 100644 packages/sdk/src/types.ts create mode 100644 packages/sdk/test-data/tool-catchall-error.json create mode 100644 packages/sdk/test-data/tool-error-recovery.json create mode 100644 packages/sdk/test-data/tool-success.json diff --git a/.prettierignore b/.prettierignore index e8f035ad741..9009498d8d2 100644 --- a/.prettierignore +++ b/.prettierignore @@ -21,3 +21,4 @@ junit.xml Thumbs.db .pytest_cache **/SKILL.md +packages/sdk/test-data/*.json diff --git a/packages/sdk/SDK_DESIGN.md b/packages/sdk/SDK_DESIGN.md new file mode 100644 index 00000000000..8daf6a4bb7e --- /dev/null +++ b/packages/sdk/SDK_DESIGN.md @@ -0,0 +1,279 @@ +# `Gemini CLI SDK` + +# `Examples` + +## `Simple Example` + +Equivalent to `gemini -p "what does this project do?"`. Loads all workspace and +user settings. + +```ts +import { GeminiCliAgent } from '@google/gemini-cli-sdk'; + +const simpleAgent = new GeminiCliAgent({ + cwd: '/path/to/some/dir', +}); + +for await (const chunk of simpleAgent.sendStream( + 'what does this project do?', +)) { + console.log(chunk); // equivalent to JSON streaming chunks (probably?) for now +} +``` + +Validation: + +- Model receives call containing "what does this project do?" text. + +## `System Instructions` + +System instructions can be provided by a static string OR dynamically via a +function: + +```ts +import { GeminiCliAgent } from "@google/gemini-cli-sdk"; + +const agent = new GeminiCliAgent({ + instructions: "This is a static string instruction"; // this is valid + instructions: (ctx) => `The current time is ${new Date().toISOString()} in session ${ctx.sessionId}.` +}); +``` + +Validation: + +- Static string instructions show up where GEMINI.md content normally would in + model call +- Dynamic instructions show up and contain dynamic content. + +## `Custom Tools` + +```ts +import { GeminiCliAgent, tool, z } from "@google/gemini-cli-sdk"; + +const addTool = tool({ + name: 'add', + description: 'add two numbers', + inputSchema: z.object({ + a: z.number().describe('first number to add'), + b: z.number().describe('second number to add'), + }), +}, (({a, b}) => ({result: a + b}),); + +const toolAgent = new GeminiCliAgent({ + tools: [addTool], +}); + +const result = await toolAgent.send("what is 23 + 79?"); +console.log(result.text); +``` + +Validation: + +- Model receives tool definition in prompt +- Model receives tool response after returning tool + +## `Custom Hooks` + +SDK users can provide programmatic custom hooks + +```ts +import { GeminiCliAgent, hook, z } from '@google/gemini-cli-sdk'; +import { reformat } from './reformat.js'; + +const myHook = hook( + { + event: 'AfterTool', + name: 'reformat', + matcher: 'write_file', + }, + (hook, ctx) => { + const filePath = hook.toolInput.path; + + // void return is a no-op + if (!filePath.endsWith('.ts')) return; + + // ctx.fs gives us a filesystem interface that obeys Gemini CLI permissions/sandbox + const reformatted = await reformat(await ctx.fs.read(filePath)); + await ctx.fs.write(filePath, reformatted); + + // hooks return a payload instructing the agent how to proceed + return { + hookSpecificOutput: { + additionalContext: `Reformatted file ${filePath}, read again before modifying further.`, + }, + }; + }, +); +``` + +SDK Hooks can also run as standalone scripts to implement userland "command" +style hooks: + +```ts +import { hook } from "@google/gemini-cli-sdk"; + +// define a hook as above +const myHook = hook({...}, (hook) => {...}); +// calling runAsCommand parses stdin, calls action, uses appropriate exit code +// with output, but you get nice strong typings to guide your impl +myHook.runAsCommand(); +``` + +Validation (these are probably hardest to validate): + +- Test each type of hook and check that model api receives injected content +- Check global halt scenarios +- Check specific return types for each type of hook + +## `Custom Skills` + +Custom skills can be referenced by individual directories or by "skill roots" +(directories containing many skills). + +```ts +import { GeminiCliAgent, skillDir, skillRoot } from '@google/gemini-cli-sdk'; + +const agent = new GeminiCliAgent({ + skills: [skillDir('/path/to/single/skill'), skillRoot('/path/to/skills/dir')], +}); +``` + +**NOTE:** I would like to support fully in-memory skills (including reference +files); however, it seems like that would currently require a pretty significant +refactor so we'll focus on filesystem skills for now. In an ideal future state, +we could do something like: + +```ts +import { GeminiCliAgent, skill } from '@google/gemini-cli-sdk'; + +const mySkill = skill({ + name: 'my-skill', + description: 'description of when my skill should be used', + content: 'This is the SKILL.md content', + // it can also be a function + content: (ctx) => `This is dynamic content.`, +}); +``` + +## `Subagents` + +```ts +import { GeminiCliAgent, subagent } from "@google/gemini-cli"; + +const mySubagent = subagent({ + name: "my-subagent", + description: "when the subagent should be used", + + // simple prompt agent with static string or dynamic string + instructions: "the instructions", + instructions (prompt, ctx) => `can also be dynamic with context`, + + // OR (in an ideal world)... + + // pass a full standalone agent + agent: new GeminiCliAgent(...); +}); + +const agent = new GeminiCliAgent({ + subagents: [mySubagent] +}); +``` + +## `Extensions` + +Potentially the most important feature of the Gemini CLI SDK is support for +extensions, which modularly encapsulate all of the primitives listed above: + +```ts +import { GeminiCliAgent, extension } from "@google/gemini-cli-sdk"; + +const myExtension = extension({ + name: "my-extension", + description: "...", + instructions: "THESE ARE CONCATENATED WITH OTHER AGENT +INSTRUCTIONS", + tools: [...], + skills: [...], + hooks: [...], + subagents: [...], +}); +``` + +## `ACP Mode` + +The SDK will include a wrapper utility to interact with the agent via ACP +instead of the SDK's natural API. + +```ts +import { GeminiCliAgent } from "@google/gemini-cli-sdk"; +import { GeminiCliAcpServer } from "@google/gemini-cli-sdk/acp"; + +const server = new GeminiCliAcpServer(new GeminiCliAgent({...})); +server.start(); // calling start runs a stdio ACP server + +const client = server.connect({ + onMessage: (message) => { /* updates etc received here */ }, +}); +client.send({...clientMessage}); // e.g. a "session/prompt" message +``` + +## `Approvals / Policies` + +TODO + +# `Implementation Guidance` + +## `Session Context` + +Whenever executing a tool, hook, command, or skill, a SessionContext object +should be passed as an additional argument after the arguments/payload. The +interface should look something like: + +```ts +export interface SessionContext { + // translations of existing common hook payload info + sessionId: string; + transcript: Message[]; + cwd: string; + timestamp: string; + + // helpers to access files and run shell commands while adhering to policies/validation + fs: AgentFilesystem; + shell: AgentShell; + // the agent itself is passed as context + agent: GeminiCliAgent; +} + +export interface AgentFilesystem { + readFile(path: string): Promise + writeFile(path: string, content: string): Promise + // consider others including delete, globbing, etc but read/write are bare minimum } + +export interface AgentShell { + // simple promise-based execution that blocks until complete + exec(cmd: string, options?: AgentShellOptions): Promise<{exitCode: number, output: string, stdout: string, stderr: string}> + start(cmd: string, options?: AgentShellOptions): AgentShellProcess; +} + +export interface AgentShellOptions { + env?: Record; + timeoutSeconds?: number; +} + +export interface AgentShellProcess { + // figure out how to have a streaming shell process here that supports stdin too + // investigate how Gemini CLI already does this +} +``` + +# `Notes` + +- To validate the SDK, it would be useful to have a robust way to mock the + underlying model API so that the tests could be closer to end-to-end but still + deterministic. +- Need to work in both Gemini-CLI-triggered approvals and optional + developer-initiated user prompts / HITL stuff. +- Need to think about how subagents inherit message context \- e.g. do they have + the same session id? +- Presumably the transcript is kept updated in memory and also persisted to disk + by default? diff --git a/packages/sdk/examples/session-context.ts b/packages/sdk/examples/session-context.ts new file mode 100644 index 00000000000..704353efe08 --- /dev/null +++ b/packages/sdk/examples/session-context.ts @@ -0,0 +1,73 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { GeminiCliAgent, tool, z } from '../src/index.js'; + +async function main() { + const getContextTool = tool( + { + name: 'get_context', + description: 'Get information about the current session context.', + inputSchema: z.object({}), + }, + async (_params, context) => { + if (!context) { + return { error: 'Context not available' }; + } + + console.log('Session Context Accessed:'); + console.log(`- Session ID: ${context.sessionId}`); + console.log(`- CWD: ${context.cwd}`); + console.log(`- Timestamp: ${context.timestamp}`); + + let fileContent = null; + try { + // Try to read a file (e.g., package.json in the CWD) + // Note: This relies on the agent running in a directory with package.json + fileContent = await context.fs.readFile('package.json'); + } catch (e) { + console.log(`- Could not read package.json: ${e}`); + } + + let shellOutput = null; + try { + // Try to run a simple shell command + const result = await context.shell.exec('echo "Hello from SDK Shell"'); + shellOutput = result.output.trim(); + } catch (e) { + console.log(`- Could not run shell command: ${e}`); + } + + return { + sessionId: context.sessionId, + cwd: context.cwd, + hasFsAccess: !!context.fs, + hasShellAccess: !!context.shell, + packageJsonExists: !!fileContent, + shellEcho: shellOutput, + }; + }, + ); + + const agent = new GeminiCliAgent({ + instructions: + 'You are a helpful assistant. Use the get_context tool to tell me about my environment.', + tools: [getContextTool], + // Set CWD to the package root so package.json exists + cwd: process.cwd(), + }); + + console.log("Sending prompt: 'What is my current session context?'"); + for await (const chunk of agent.sendStream( + 'What is my current session context?', + )) { + if (chunk.type === 'content') { + process.stdout.write(chunk.value || ''); + } + } +} + +main().catch(console.error); diff --git a/packages/sdk/src/agent.ts b/packages/sdk/src/agent.ts index eee421ebcca..f889dd4acaf 100644 --- a/packages/sdk/src/agent.ts +++ b/packages/sdk/src/agent.ts @@ -13,9 +13,13 @@ import { type ToolCallRequestInfo, type ServerGeminiStreamEvent, type GeminiClient, + type Content, } from '@google/gemini-cli-core'; import { type Tool, SdkTool, type z } from './tool.js'; +import { SdkAgentFilesystem } from './fs.js'; +import { SdkAgentShell } from './shell.js'; +import type { SessionContext } from './types.js'; export interface GeminiCliAgentOptions { instructions: string; @@ -44,6 +48,8 @@ export class GeminiCliAgent { enableHooks: false, mcpEnabled: false, extensionsEnabled: false, + recordResponses: options.recordResponses, + fakeResponses: options.fakeResponses, }; this.config = new Config(configParams); @@ -68,7 +74,7 @@ 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); } } @@ -83,6 +89,9 @@ export class GeminiCliAgent { const signal = new AbortController().signal; const sessionId = this.config.getSessionId(); + const fs = new SdkAgentFilesystem(this.config); + const shell = new SdkAgentShell(this.config); + while (true) { // sendMessageStream returns AsyncGenerator const stream = client.sendMessageStream(request, signal, sessionId); @@ -101,6 +110,17 @@ export class GeminiCliAgent { } const functionResponses: Array> = []; + const transcript: Content[] = client.getHistory(); + const context: SessionContext = { + sessionId, + transcript, + cwd: this.config.getWorkingDir(), + timestamp: new Date().toISOString(), + fs, + shell, + agent: this, + }; + for (const toolCall of toolCalls) { const tool = registry.getTool(toolCall.name); if (!tool) { @@ -121,7 +141,14 @@ export class GeminiCliAgent { } // Cast toolCall.args to object to satisfy AnyDeclarativeTool.build - const invocation = tool.build(args as object); + const invocation = + tool instanceof SdkTool + ? tool.createInvocationWithContext( + args as object, + this.config.getMessageBus(), + context + ) + : tool.build(args as object); // Check if the tool execution requires confirmation according to policy const confirmation = await invocation.shouldConfirmExecute(signal); @@ -130,7 +157,6 @@ export class GeminiCliAgent { `Tool execution for '${toolCall.name}' requires confirmation, which is not supported in this SDK version.`, ); } - const result = await invocation.execute(signal); functionResponses.push({ @@ -141,10 +167,15 @@ export class GeminiCliAgent { }, }); } catch (e) { + // eslint-disable-next-line no-console + console.error(`Tool execution error for ${toolCall.name}:`, e); functionResponses.push({ functionResponse: { name: toolCall.name, - response: { error: e instanceof Error ? e.message : String(e) }, + response: { + error: + 'Error: Tool execution failed. Please try again or use a different approach.', + }, id: toolCall.callId, }, }); diff --git a/packages/sdk/src/fs.ts b/packages/sdk/src/fs.ts new file mode 100644 index 00000000000..afdb92acff5 --- /dev/null +++ b/packages/sdk/src/fs.ts @@ -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 { + 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 { + const error = this.config.validatePathAccess(path, 'write'); + if (error) { + throw new Error(error); + } + await fs.writeFile(path, content, 'utf-8'); + } +} diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 0ad940d7b2c..36a4c7711d9 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -6,3 +6,4 @@ export * from './agent.js'; export * from './tool.js'; +export * from './types.js'; diff --git a/packages/sdk/src/shell.ts b/packages/sdk/src/shell.ts new file mode 100644 index 00000000000..07eddc0735d --- /dev/null +++ b/packages/sdk/src/shell.ts @@ -0,0 +1,43 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Config as CoreConfig } from '@google/gemini-cli-core'; +import { ShellExecutionService } 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 { + const cwd = options?.cwd || this.config.getWorkingDir(); + const abortController = new AbortController(); + + 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, + }; + } +} diff --git a/packages/sdk/src/tool.integration.test.ts b/packages/sdk/src/tool.integration.test.ts new file mode 100644 index 00000000000..4aa320a9287 --- /dev/null +++ b/packages/sdk/src/tool.integration.test.ts @@ -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'); + }); +}); diff --git a/packages/sdk/src/tool.test.ts b/packages/sdk/src/tool.test.ts new file mode 100644 index 00000000000..819177c3b94 --- /dev/null +++ b/packages/sdk/src/tool.test.ts @@ -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'); + }); +}); diff --git a/packages/sdk/src/tool.ts b/packages/sdk/src/tool.ts index 00cd3802dec..799d62108ac 100644 --- a/packages/sdk/src/tool.ts +++ b/packages/sdk/src/tool.ts @@ -14,17 +14,26 @@ import { Kind, type MessageBus, } from '@google/gemini-cli-core'; +import type { SessionContext } from './types.js'; export { z }; +export class ModelVisibleError extends Error { + constructor(message: string | Error) { + super(message instanceof Error ? message.message : message); + this.name = 'ModelVisibleError'; + } +} + export interface ToolDefinition { name: string; description: string; inputSchema: T; + sendErrorsToModel?: boolean; } export interface Tool extends ToolDefinition { - action: (params: z.infer) => Promise; + action: (params: z.infer, context?: SessionContext) => Promise; } class SdkToolInvocation extends BaseToolInvocation< @@ -34,8 +43,13 @@ class SdkToolInvocation extends BaseToolInvocation< constructor( params: z.infer, messageBus: MessageBus, - private readonly action: (params: z.infer) => Promise, + private readonly action: ( + params: z.infer, + context?: SessionContext, + ) => Promise, + private readonly context: SessionContext | undefined, toolName: string, + private readonly sendErrorsToModel: boolean = false, ) { super(params, messageBus, toolName); } @@ -49,7 +63,7 @@ class SdkToolInvocation extends BaseToolInvocation< _updateOutput?: (output: string) => void, ): Promise { 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,15 +71,18 @@ class SdkToolInvocation 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; } } } @@ -77,6 +94,8 @@ export class SdkTool extends BaseDeclarativeTool< constructor( private readonly definition: Tool, messageBus: MessageBus, + + _agent?: unknown, // To prevent unused variable error, but ideally we'd pass agent here if needed ) { super( definition.name, @@ -88,6 +107,22 @@ export class SdkTool extends BaseDeclarativeTool< ); } + createInvocationWithContext( + params: z.infer, + messageBus: MessageBus, + context: SessionContext | undefined, + toolName?: string, + ): ToolInvocation, ToolResult> { + return new SdkToolInvocation( + params, + messageBus, + this.definition.action, + context, + toolName || this.name, + this.definition.sendErrorsToModel, + ); + } + protected createInvocation( params: z.infer, messageBus: MessageBus, @@ -97,14 +132,16 @@ export class SdkTool extends BaseDeclarativeTool< params, messageBus, this.definition.action, + undefined, toolName || this.name, + this.definition.sendErrorsToModel, ); } } export function tool( definition: ToolDefinition, - action: (params: z.infer) => Promise, + action: (params: z.infer, context?: SessionContext) => Promise, ): Tool { return { ...definition, diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts new file mode 100644 index 00000000000..ed77e044001 --- /dev/null +++ b/packages/sdk/src/types.ts @@ -0,0 +1,40 @@ +/** + * @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; + writeFile(path: string, content: string): Promise; +} + +export interface AgentShellOptions { + env?: Record; + timeoutSeconds?: number; + cwd?: string; +} + +export interface AgentShellResult { + exitCode: number | null; + output: string; + stdout: string; + stderr: string; +} + +export interface AgentShell { + exec(cmd: string, options?: AgentShellOptions): Promise; +} + +export interface SessionContext { + sessionId: string; + transcript: Content[]; + cwd: string; + timestamp: string; + fs: AgentFilesystem; + shell: AgentShell; + agent: GeminiCliAgent; +} diff --git a/packages/sdk/test-data/tool-catchall-error.json b/packages/sdk/test-data/tool-catchall-error.json new file mode 100644 index 00000000000..43c3b44d8b3 --- /dev/null +++ b/packages/sdk/test-data/tool-catchall-error.json @@ -0,0 +1,2 @@ +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"checkSystemStatus","args":{}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7070,"candidatesTokenCount":3,"totalTokenCount":7073,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7070}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":3}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The system status check"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":9850,"totalTokenCount":9850,"promptTokensDetails":[{"modality":"TEXT","tokenCount":9850}]}},{"candidates":[{"content":{"parts":[{"text":" returned an error. It says `Error: Standard error caught`."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7082,"candidatesTokenCount":17,"totalTokenCount":7099,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7082}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":17}]}}]} diff --git a/packages/sdk/test-data/tool-error-recovery.json b/packages/sdk/test-data/tool-error-recovery.json new file mode 100644 index 00000000000..4e36d24aa7f --- /dev/null +++ b/packages/sdk/test-data/tool-error-recovery.json @@ -0,0 +1,2 @@ +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"failVisible","args":{"input":"fail"}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7073,"candidatesTokenCount":4,"totalTokenCount":7077,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7073}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":4}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":9867,"totalTokenCount":9867,"promptTokensDetails":[{"modality":"TEXT","tokenCount":9867}]}},{"candidates":[{"content":{"parts":[{"text":" tool failed visibly with the error message: \"Error: Tool failed visibly\"."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7085,"candidatesTokenCount":16,"totalTokenCount":7101,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7085}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":16}]}}]} diff --git a/packages/sdk/test-data/tool-success.json b/packages/sdk/test-data/tool-success.json new file mode 100644 index 00000000000..1b17993fe41 --- /dev/null +++ b/packages/sdk/test-data/tool-success.json @@ -0,0 +1,2 @@ +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"add","args":{"a":5,"b":3}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7045,"candidatesTokenCount":5,"totalTokenCount":7050,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7045}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":5}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"8"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":9849,"totalTokenCount":9849,"promptTokensDetails":[{"modality":"TEXT","tokenCount":9849}]}},{"candidates":[{"content":{"parts":[{"text":""}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7053,"candidatesTokenCount":1,"totalTokenCount":7054,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7053}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":1}]}}]} From bcd5f21108ab51287e2a119ef2ae2312cf669a50 Mon Sep 17 00:00:00 2001 From: Michael Bleigh Date: Wed, 11 Feb 2026 18:06:11 -0800 Subject: [PATCH 6/7] fix(sdk): use ShellTool for policy enforcement in SdkAgentShell --- packages/sdk/src/shell.ts | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/packages/sdk/src/shell.ts b/packages/sdk/src/shell.ts index 07eddc0735d..30b99795941 100644 --- a/packages/sdk/src/shell.ts +++ b/packages/sdk/src/shell.ts @@ -5,7 +5,7 @@ */ import type { Config as CoreConfig } from '@google/gemini-cli-core'; -import { ShellExecutionService } from '@google/gemini-cli-core'; +import { ShellExecutionService, ShellTool } from '@google/gemini-cli-core'; import type { AgentShell, AgentShellResult, @@ -22,6 +22,32 @@ export class SdkAgentShell implements AgentShell { 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, From 57441ed66e3561a16f80fd5665f403957be014b7 Mon Sep 17 00:00:00 2001 From: Michael Bleigh Date: Wed, 11 Feb 2026 18:15:28 -0800 Subject: [PATCH 7/7] fix(sdk): resolve build and lint errors in SDK --- packages/core/src/index.ts | 3 +++ packages/sdk/src/agent.ts | 12 ++++++++---- packages/sdk/src/tool.integration.test.ts | 2 +- packages/sdk/src/tool.ts | 10 +++++----- packages/sdk/src/types.ts | 1 + 5 files changed, 18 insertions(+), 10 deletions(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8232f735700..7487a1697fc 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -190,3 +190,6 @@ export * from './agents/types.js'; // Export stdio utils export * from './utils/stdio.js'; export * from './utils/terminal.js'; + +// Export types from @google/genai +export type { Content, Part, FunctionCall } from '@google/genai'; diff --git a/packages/sdk/src/agent.ts b/packages/sdk/src/agent.ts index f889dd4acaf..6862c846b55 100644 --- a/packages/sdk/src/agent.ts +++ b/packages/sdk/src/agent.ts @@ -16,22 +16,26 @@ import { type Content, } 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>; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + tools?: Array>; model?: string; cwd?: string; debug?: boolean; + recordResponses?: string; + fakeResponses?: string; } export class GeminiCliAgent { private config: Config; - private tools: Array>; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private tools: Array>; constructor(options: GeminiCliAgentOptions) { const cwd = options.cwd || process.cwd(); @@ -146,7 +150,7 @@ export class GeminiCliAgent { ? tool.createInvocationWithContext( args as object, this.config.getMessageBus(), - context + context, ) : tool.build(args as object); diff --git a/packages/sdk/src/tool.integration.test.ts b/packages/sdk/src/tool.integration.test.ts index 4aa320a9287..1ec9d73abd9 100644 --- a/packages/sdk/src/tool.integration.test.ts +++ b/packages/sdk/src/tool.integration.test.ts @@ -16,7 +16,7 @@ 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 RECORD_MODE = process.env['RECORD_NEW_RESPONSES'] === 'true'; const getGoldenPath = (name: string) => path.resolve(__dirname, '../test-data', `${name}.json`); diff --git a/packages/sdk/src/tool.ts b/packages/sdk/src/tool.ts index 799d62108ac..e6cb616ff54 100644 --- a/packages/sdk/src/tool.ts +++ b/packages/sdk/src/tool.ts @@ -25,18 +25,18 @@ export class ModelVisibleError extends Error { } } -export interface ToolDefinition { +export interface ToolDefinition { name: string; description: string; inputSchema: T; sendErrorsToModel?: boolean; } -export interface Tool extends ToolDefinition { +export interface Tool extends ToolDefinition { action: (params: z.infer, context?: SessionContext) => Promise; } -class SdkToolInvocation extends BaseToolInvocation< +class SdkToolInvocation extends BaseToolInvocation< z.infer, ToolResult > { @@ -87,7 +87,7 @@ class SdkToolInvocation extends BaseToolInvocation< } } -export class SdkTool extends BaseDeclarativeTool< +export class SdkTool extends BaseDeclarativeTool< z.infer, ToolResult > { @@ -139,7 +139,7 @@ export class SdkTool extends BaseDeclarativeTool< } } -export function tool( +export function tool( definition: ToolDefinition, action: (params: z.infer, context?: SessionContext) => Promise, ): Tool { diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index ed77e044001..d7e013d66c4 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -23,6 +23,7 @@ export interface AgentShellResult { output: string; stdout: string; stderr: string; + error?: Error; } export interface AgentShell {