feat(sdk): Implement dynamic system instructions (#18863)

Co-authored-by: N. Taylor Mullen <ntaylormullen@google.com>
This commit is contained in:
Michael Bleigh
2026-02-13 12:48:35 -08:00
committed by GitHub
parent f460ab841d
commit f76e24c00f
6 changed files with 258 additions and 7 deletions
+59 -5
View File
@@ -1,9 +1,15 @@
# `Gemini CLI SDK` # `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` # `Examples`
## `Simple Example` ## `Simple Example`
> **Status:** Implemented. `GeminiCliAgent` supports `cwd` and `sendStream`.
Equivalent to `gemini -p "what does this project do?"`. Loads all workspace and Equivalent to `gemini -p "what does this project do?"`. Loads all workspace and
user settings. user settings.
@@ -27,6 +33,9 @@ Validation:
## `System Instructions` ## `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 System instructions can be provided by a static string OR dynamically via a
function: function:
@@ -47,6 +56,9 @@ Validation:
## `Custom Tools` ## `Custom Tools`
> **Status:** Implemented. `tool()` helper and `GeminiCliAgent` support custom
> tool definitions and execution.
```ts ```ts
import { GeminiCliAgent, tool, z } from "@google/gemini-cli-sdk"; import { GeminiCliAgent, tool, z } from "@google/gemini-cli-sdk";
@@ -74,6 +86,8 @@ Validation:
## `Custom Hooks` ## `Custom Hooks`
> **Status:** Not Implemented.
SDK users can provide programmatic custom hooks SDK users can provide programmatic custom hooks
```ts ```ts
@@ -127,6 +141,8 @@ Validation (these are probably hardest to validate):
## `Custom Skills` ## `Custom Skills`
> **Status:** Not Implemented.
Custom skills can be referenced by individual directories or by "skill roots" Custom skills can be referenced by individual directories or by "skill roots"
(directories containing many skills). (directories containing many skills).
@@ -157,6 +173,8 @@ const mySkill = skill({
## `Subagents` ## `Subagents`
> **Status:** Not Implemented.
```ts ```ts
import { GeminiCliAgent, subagent } from "@google/gemini-cli"; import { GeminiCliAgent, subagent } from "@google/gemini-cli";
@@ -181,6 +199,8 @@ const agent = new GeminiCliAgent({
## `Extensions` ## `Extensions`
> **Status:** Not Implemented.
Potentially the most important feature of the Gemini CLI SDK is support for Potentially the most important feature of the Gemini CLI SDK is support for
extensions, which modularly encapsulate all of the primitives listed above: extensions, which modularly encapsulate all of the primitives listed above:
@@ -201,6 +221,8 @@ INSTRUCTIONS",
## `ACP Mode` ## `ACP Mode`
> **Status:** Not Implemented.
The SDK will include a wrapper utility to interact with the agent via ACP The SDK will include a wrapper utility to interact with the agent via ACP
instead of the SDK's natural API. instead of the SDK's natural API.
@@ -219,12 +241,17 @@ client.send({...clientMessage}); // e.g. a "session/prompt" message
## `Approvals / Policies` ## `Approvals / Policies`
> **Status:** Not Implemented.
TODO TODO
# `Implementation Guidance` # `Implementation Guidance`
## `Session Context` ## `Session Context`
> **Status:** Implemented. `SessionContext` interface exists and is passed to
> tools.
Whenever executing a tool, hook, command, or skill, a SessionContext object Whenever executing a tool, hook, command, or skill, a SessionContext object
should be passed as an additional argument after the arguments/payload. The should be passed as an additional argument after the arguments/payload. The
interface should look something like: interface should look something like:
@@ -245,18 +272,27 @@ export interface SessionContext {
} }
export interface AgentFilesystem { export interface AgentFilesystem {
readFile(path: string): Promise<string | null> readFile(path: string): Promise<string | null>;
writeFile(path: string, content: string): Promise<void> writeFile(path: string, content: string): Promise<void>;
// consider others including delete, globbing, etc but read/write are bare minimum } // consider others including delete, globbing, etc but read/write are bare minimum
}
export interface AgentShell { export interface AgentShell {
// simple promise-based execution that blocks until complete // 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; start(cmd: string, options?: AgentShellOptions): AgentShellProcess;
} }
export interface AgentShellOptions { export interface AgentShellOptions {
env?: Record<string,string>; env?: Record<string, string>;
timeoutSeconds?: number; timeoutSeconds?: number;
} }
@@ -277,3 +313,21 @@ export interface AgentShellProcess {
the same session id? the same session id?
- Presumably the transcript is kept updated in memory and also persisted to disk - Presumably the transcript is kept updated in memory and also persisted to disk
by default? by default?
# `Next Steps`
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
View File
@@ -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');
});
});
+36 -2
View File
@@ -24,8 +24,12 @@ import { SdkAgentFilesystem } from './fs.js';
import { SdkAgentShell } from './shell.js'; import { SdkAgentShell } from './shell.js';
import type { SessionContext } from './types.js'; import type { SessionContext } from './types.js';
export type SystemInstructions =
| string
| ((context: SessionContext) => string | Promise<string>);
export interface GeminiCliAgentOptions { export interface GeminiCliAgentOptions {
instructions: string; instructions: SystemInstructions;
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
tools?: Array<Tool<any>>; tools?: Array<Tool<any>>;
model?: string; model?: string;
@@ -39,18 +43,24 @@ export class GeminiCliAgent {
private config: Config; private config: Config;
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
private tools: Array<Tool<any>>; private tools: Array<Tool<any>>;
private readonly instructions: SystemInstructions;
private instructionsLoaded = false;
constructor(options: GeminiCliAgentOptions) { constructor(options: GeminiCliAgentOptions) {
this.instructions = options.instructions;
const cwd = options.cwd || process.cwd(); const cwd = options.cwd || process.cwd();
this.tools = options.tools || []; this.tools = options.tools || [];
const initialMemory =
typeof this.instructions === 'string' ? this.instructions : '';
const configParams: ConfigParameters = { const configParams: ConfigParameters = {
sessionId: `sdk-${Date.now()}`, sessionId: `sdk-${Date.now()}`,
targetDir: cwd, targetDir: cwd,
cwd, cwd,
debugMode: options.debug ?? false, debugMode: options.debug ?? false,
model: options.model || PREVIEW_GEMINI_MODEL_AUTO, model: options.model || PREVIEW_GEMINI_MODEL_AUTO,
userMemory: options.instructions, userMemory: initialMemory,
// Minimal config // Minimal config
enableHooks: false, enableHooks: false,
mcpEnabled: false, mcpEnabled: false,
@@ -94,6 +104,30 @@ export class GeminiCliAgent {
{ text: prompt }, { 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) { while (true) {
// sendMessageStream returns AsyncGenerator<ServerGeminiStreamEvent, Turn> // sendMessageStream returns AsyncGenerator<ServerGeminiStreamEvent, Turn>
const stream = client.sendMessageStream(request, abortSignal, sessionId); const stream = client.sendMessageStream(request, abortSignal, sessionId);
@@ -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}]}}]}
@@ -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}]}}]}
@@ -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}]}}]}