Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 59 additions & 5 deletions packages/sdk/SDK_DESIGN.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
# `Gemini CLI SDK`

> **Implementation Status:** Core agent loop, tool execution, and session
> context are implemented. Advanced features like hooks, skills, subagents, and
> ACP are currently missing.

# `Examples`

## `Simple Example`

> **Status:** Implemented. `GeminiCliAgent` supports `cwd` and `sendStream`.

Equivalent to `gemini -p "what does this project do?"`. Loads all workspace and
user settings.

Expand All @@ -27,6 +33,9 @@ Validation:

## `System Instructions`

> **Status:** Implemented. Both static string instructions and dynamic functions
> (receiving `SessionContext`) are supported.

System instructions can be provided by a static string OR dynamically via a
function:

Expand All @@ -47,6 +56,9 @@ Validation:

## `Custom Tools`

> **Status:** Implemented. `tool()` helper and `GeminiCliAgent` support custom
> tool definitions and execution.

```ts
import { GeminiCliAgent, tool, z } from "@google/gemini-cli-sdk";

Expand Down Expand Up @@ -74,6 +86,8 @@ Validation:

## `Custom Hooks`

> **Status:** Not Implemented.

SDK users can provide programmatic custom hooks

```ts
Expand Down Expand Up @@ -127,6 +141,8 @@ Validation (these are probably hardest to validate):

## `Custom Skills`

> **Status:** Not Implemented.

Custom skills can be referenced by individual directories or by "skill roots"
(directories containing many skills).

Expand Down Expand Up @@ -157,6 +173,8 @@ const mySkill = skill({

## `Subagents`

> **Status:** Not Implemented.

```ts
import { GeminiCliAgent, subagent } from "@google/gemini-cli";

Expand All @@ -181,6 +199,8 @@ const agent = new GeminiCliAgent({

## `Extensions`

> **Status:** Not Implemented.

Potentially the most important feature of the Gemini CLI SDK is support for
extensions, which modularly encapsulate all of the primitives listed above:

Expand All @@ -201,6 +221,8 @@ INSTRUCTIONS",

## `ACP Mode`

> **Status:** Not Implemented.

The SDK will include a wrapper utility to interact with the agent via ACP
instead of the SDK's natural API.

Expand All @@ -219,12 +241,17 @@ client.send({...clientMessage}); // e.g. a "session/prompt" message

## `Approvals / Policies`

> **Status:** Not Implemented.

TODO

# `Implementation Guidance`

## `Session Context`

> **Status:** Implemented. `SessionContext` interface exists and is passed to
> tools.

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:
Expand All @@ -245,18 +272,27 @@ export interface SessionContext {
}

export interface AgentFilesystem {
readFile(path: string): Promise<string | null>
writeFile(path: string, content: string): Promise<void>
// consider others including delete, globbing, etc but read/write are bare minimum }
readFile(path: string): Promise<string | null>;
writeFile(path: string, content: string): Promise<void>;
// 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}>
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<string,string>;
env?: Record<string, string>;
timeoutSeconds?: number;
}

Expand All @@ -277,3 +313,21 @@ export interface AgentShellProcess {
the same session id?
- Presumably the transcript is kept updated in memory and also persisted to disk
by default?

# `Next Steps`
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

intended?


Based on the current implementation status, we can proceed with:

## Feature 2: Custom Skills Support

Implement support for loading and registering custom skills. This involves
adding a `skills` option to `GeminiCliAgentOptions` and implementing the logic
to read skill definitions from directories.

**Tasks:**

1. Add `skills` option to `GeminiCliAgentOptions`.
2. Implement `skillDir` and `skillRoot` helpers to load skills from the
filesystem.
3. Update `GeminiCliAgent` to register loaded skills with the internal tool
registry.
154 changes: 154 additions & 0 deletions packages/sdk/src/agent.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
/**
* @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 { 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 Integration', () => {
it('handles static instructions', async () => {
const goldenFile = getGoldenPath('agent-static-instructions');

const agent = new GeminiCliAgent({
instructions: 'You are a pirate. Respond in pirate speak.',
model: 'gemini-2.0-flash',
recordResponses: RECORD_MODE ? goldenFile : undefined,
fakeResponses: RECORD_MODE ? undefined : goldenFile,
});

const events = [];
const stream = agent.sendStream('Say hello.');

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 pirate speak
expect(responseText.toLowerCase()).toMatch(/ahoy|matey|arrr/);
}, 30000);

it('handles dynamic instructions', async () => {
const goldenFile = getGoldenPath('agent-dynamic-instructions');

let callCount = 0;
const agent = new GeminiCliAgent({
instructions: (_ctx) => {
callCount++;
return `You are a helpful assistant. The secret number is ${callCount}. Always mention the secret number when asked.`;
},
model: 'gemini-2.0-flash',
recordResponses: RECORD_MODE ? goldenFile : undefined,
fakeResponses: RECORD_MODE ? undefined : goldenFile,
});

// First turn
const stream1 = agent.sendStream('What is the secret number?');
const events1 = [];
for await (const event of stream1) {
events1.push(event);
}
const responseText1 = events1
.filter((e) => e.type === 'content')
.map((e) => (typeof e.value === 'string' ? e.value : ''))
.join('');

expect(responseText1).toContain('1');
expect(callCount).toBe(1);

// Second turn
const stream2 = agent.sendStream('What is the secret number now?');
const events2 = [];
for await (const event of stream2) {
events2.push(event);
}
const responseText2 = events2
.filter((e) => e.type === 'content')
.map((e) => (typeof e.value === 'string' ? e.value : ''))
.join('');

// Should still be 1 because instructions are only loaded once per session
expect(responseText2).toContain('1');
expect(callCount).toBe(1);
}, 30000);

it('handles async dynamic instructions', async () => {
const goldenFile = getGoldenPath('agent-async-instructions');

let callCount = 0;
const agent = new GeminiCliAgent({
instructions: async (_ctx) => {
await new Promise((resolve) => setTimeout(resolve, 10)); // Simulate async work
callCount++;
return `You are a helpful assistant. The secret number is ${callCount}. Always mention the secret number when asked.`;
},
model: 'gemini-2.0-flash',
recordResponses: RECORD_MODE ? goldenFile : undefined,
fakeResponses: RECORD_MODE ? undefined : goldenFile,
});

// First turn
const stream1 = agent.sendStream('What is the secret number?');
const events1 = [];
for await (const event of stream1) {
events1.push(event);
}
const responseText1 = events1
.filter((e) => e.type === 'content')
.map((e) => (typeof e.value === 'string' ? e.value : ''))
.join('');

expect(responseText1).toContain('1');
expect(callCount).toBe(1);

// Second turn
const stream2 = agent.sendStream('What is the secret number now?');
const events2 = [];
for await (const event of stream2) {
events2.push(event);
}
const responseText2 = events2
.filter((e) => e.type === 'content')
.map((e) => (typeof e.value === 'string' ? e.value : ''))
.join('');

// Should still be 1 because instructions are only loaded once per session
expect(responseText2).toContain('1');
expect(callCount).toBe(1);
}, 30000);

it('throws when dynamic instructions fail', async () => {
const agent = new GeminiCliAgent({
instructions: () => {
throw new Error('Dynamic instruction failure');
},
model: 'gemini-2.0-flash',
});

const stream = agent.sendStream('Say hello.');

await expect(async () => {
for await (const _event of stream) {
// Just consume the stream
}
}).rejects.toThrow('Dynamic instruction failure');
});
});
38 changes: 36 additions & 2 deletions packages/sdk/src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,12 @@ import { SdkAgentFilesystem } from './fs.js';
import { SdkAgentShell } from './shell.js';
import type { SessionContext } from './types.js';

export type SystemInstructions =
| string
| ((context: SessionContext) => string | Promise<string>);

export interface GeminiCliAgentOptions {
instructions: string;
instructions: SystemInstructions;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
tools?: Array<Tool<any>>;
model?: string;
Expand All @@ -39,18 +43,24 @@ export class GeminiCliAgent {
private config: Config;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private tools: Array<Tool<any>>;
private readonly instructions: SystemInstructions;
private instructionsLoaded = false;

constructor(options: GeminiCliAgentOptions) {
this.instructions = options.instructions;
const cwd = options.cwd || process.cwd();
this.tools = options.tools || [];

const initialMemory =
typeof this.instructions === 'string' ? this.instructions : '';

const configParams: ConfigParameters = {
sessionId: `sdk-${Date.now()}`,
targetDir: cwd,
cwd,
debugMode: options.debug ?? false,
model: options.model || PREVIEW_GEMINI_MODEL_AUTO,
userMemory: options.instructions,
userMemory: initialMemory,
// Minimal config
enableHooks: false,
mcpEnabled: false,
Expand Down Expand Up @@ -94,6 +104,30 @@ export class GeminiCliAgent {
{ text: prompt },
];

if (!this.instructionsLoaded && typeof this.instructions === 'function') {
const context: SessionContext = {
sessionId,
transcript: client.getHistory(),
cwd: this.config.getWorkingDir(),
timestamp: new Date().toISOString(),
fs,
shell,
agent: this,
};
try {
const newInstructions = await this.instructions(context);
this.config.setUserMemory(newInstructions);
client.updateSystemInstruction();
this.instructionsLoaded = true;
} catch (e) {
const error =
e instanceof Error
? e
: new Error(`Error resolving dynamic instructions: ${String(e)}`);
throw error;
}
}

while (true) {
// sendMessageStream returns AsyncGenerator<ServerGeminiStreamEvent, Turn>
const stream = client.sendMessageStream(request, abortSignal, sessionId);
Expand Down
4 changes: 4 additions & 0 deletions packages/sdk/test-data/agent-async-instructions.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":9831,"totalTokenCount":9831,"promptTokensDetails":[{"modality":"TEXT","tokenCount":9831}]}},{"candidates":[{"content":{"parts":[{"text":" 1.\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7098,"candidatesTokenCount":8,"totalTokenCount":7106,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7098}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":9848,"totalTokenCount":9848,"promptTokensDetails":[{"modality":"TEXT","tokenCount":9848}]}},{"candidates":[{"content":{"parts":[{"text":" 1.\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7113,"candidatesTokenCount":8,"totalTokenCount":7121,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7113}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":9853,"totalTokenCount":9853,"promptTokensDetails":[{"modality":"TEXT","tokenCount":9853}]}},{"candidates":[{"content":{"parts":[{"text":" 1.\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7120,"candidatesTokenCount":8,"totalTokenCount":7128,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7120}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":9870,"totalTokenCount":9870,"promptTokensDetails":[{"modality":"TEXT","tokenCount":9870}]}},{"candidates":[{"content":{"parts":[{"text":" 1.\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7135,"candidatesTokenCount":8,"totalTokenCount":7143,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7135}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]}
4 changes: 4 additions & 0 deletions packages/sdk/test-data/agent-dynamic-instructions.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":9831,"totalTokenCount":9831,"promptTokensDetails":[{"modality":"TEXT","tokenCount":9831}]}},{"candidates":[{"content":{"parts":[{"text":" 1.\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7098,"candidatesTokenCount":8,"totalTokenCount":7106,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7098}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":9848,"totalTokenCount":9848,"promptTokensDetails":[{"modality":"TEXT","tokenCount":9848}]}},{"candidates":[{"content":{"parts":[{"text":" 1.\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7113,"candidatesTokenCount":8,"totalTokenCount":7121,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7113}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":9853,"totalTokenCount":9853,"promptTokensDetails":[{"modality":"TEXT","tokenCount":9853}]}},{"candidates":[{"content":{"parts":[{"text":" 1.\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7120,"candidatesTokenCount":8,"totalTokenCount":7128,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7120}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":9870,"totalTokenCount":9870,"promptTokensDetails":[{"modality":"TEXT","tokenCount":9870}]}},{"candidates":[{"content":{"parts":[{"text":" 1.\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7135,"candidatesTokenCount":8,"totalTokenCount":7143,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7135}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]}
1 change: 1 addition & 0 deletions packages/sdk/test-data/agent-static-instructions.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Ah"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":9828,"totalTokenCount":9828,"promptTokensDetails":[{"modality":"TEXT","tokenCount":9828}]}},{"candidates":[{"content":{"parts":[{"text":"oy, matey! Ready to chart a course through the code?"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7095,"candidatesTokenCount":15,"totalTokenCount":7110,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7095}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":15}]}}]}
Loading