mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-23 03:24:42 -07:00
feat(core): experimental in-progress steering hints (1 of 3) (#19008)
This commit is contained in:
@@ -41,6 +41,7 @@ import type { SkillDefinition } from '../skills/skillLoader.js';
|
||||
import type { McpClientManager } from '../tools/mcp-client-manager.js';
|
||||
import { DEFAULT_MODEL_CONFIGS } from './defaultModelConfigs.js';
|
||||
import { DEFAULT_GEMINI_MODEL } from './models.js';
|
||||
import { Storage } from './storage.js';
|
||||
|
||||
vi.mock('fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('fs')>();
|
||||
@@ -279,16 +280,21 @@ describe('Server Config (config.ts)', () => {
|
||||
await expect(config.initialize()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should throw an error if initialized more than once', async () => {
|
||||
it('should deduplicate multiple calls to initialize', async () => {
|
||||
const config = new Config({
|
||||
...baseParams,
|
||||
checkpointing: false,
|
||||
});
|
||||
|
||||
await expect(config.initialize()).resolves.toBeUndefined();
|
||||
await expect(config.initialize()).rejects.toThrow(
|
||||
'Config was already initialized',
|
||||
);
|
||||
const storageSpy = vi.spyOn(Storage.prototype, 'initialize');
|
||||
|
||||
await Promise.all([
|
||||
config.initialize(),
|
||||
config.initialize(),
|
||||
config.initialize(),
|
||||
]);
|
||||
|
||||
expect(storageSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should await MCP initialization in non-interactive mode', async () => {
|
||||
|
||||
@@ -621,7 +621,8 @@ export class Config {
|
||||
private readonly enablePromptCompletion: boolean = false;
|
||||
private readonly truncateToolOutputThreshold: number;
|
||||
private compressionTruncationCounter = 0;
|
||||
private initialized: boolean = false;
|
||||
private initialized = false;
|
||||
private initPromise: Promise<void> | undefined;
|
||||
readonly storage: Storage;
|
||||
private readonly fileExclusions: FileExclusions;
|
||||
private readonly eventEmitter?: EventEmitter;
|
||||
@@ -674,7 +675,6 @@ export class Config {
|
||||
private remoteAdminSettings: AdminControlsSettings | undefined;
|
||||
private latestApiRequest: GenerateContentParameters | undefined;
|
||||
private lastModeSwitchTime: number = Date.now();
|
||||
|
||||
private approvedPlanPath: string | undefined;
|
||||
|
||||
constructor(params: ConfigParameters) {
|
||||
@@ -917,14 +917,20 @@ export class Config {
|
||||
}
|
||||
|
||||
/**
|
||||
* Must only be called once, throws if called again.
|
||||
* Dedups initialization requests using a shared promise that is only resolved
|
||||
* once.
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
if (this.initialized) {
|
||||
throw Error('Config was already initialized');
|
||||
if (this.initPromise) {
|
||||
return this.initPromise;
|
||||
}
|
||||
this.initialized = true;
|
||||
|
||||
this.initPromise = this._initialize();
|
||||
|
||||
return this.initPromise;
|
||||
}
|
||||
|
||||
private async _initialize(): Promise<void> {
|
||||
await this.storage.initialize();
|
||||
|
||||
// Add pending directories to workspace context
|
||||
@@ -1011,6 +1017,7 @@ export class Config {
|
||||
|
||||
await this.geminiClient.initialize();
|
||||
this.syncPlanModeTools();
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
getContentGenerator(): ContentGenerator {
|
||||
|
||||
@@ -127,6 +127,19 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
|
||||
},
|
||||
},
|
||||
},
|
||||
'fast-ack-helper': {
|
||||
extends: 'base',
|
||||
modelConfig: {
|
||||
model: 'gemini-2.5-flash-lite',
|
||||
generateContentConfig: {
|
||||
temperature: 0.2,
|
||||
maxOutputTokens: 120,
|
||||
thinkingConfig: {
|
||||
thinkingBudget: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'edit-corrector': {
|
||||
extends: 'base',
|
||||
modelConfig: {
|
||||
|
||||
@@ -28,6 +28,7 @@ export * from './commands/memory.js';
|
||||
export * from './commands/types.js';
|
||||
|
||||
// Export Core Logic
|
||||
export * from './core/baseLlmClient.js';
|
||||
export * from './core/client.js';
|
||||
export * from './core/contentGenerator.js';
|
||||
export * from './core/loggingContentGenerator.js';
|
||||
@@ -88,6 +89,7 @@ export * from './utils/formatters.js';
|
||||
export * from './utils/generateContentResponseUtilities.js';
|
||||
export * from './utils/filesearch/fileSearch.js';
|
||||
export * from './utils/errorParsing.js';
|
||||
export * from './utils/fastAckHelper.js';
|
||||
export * from './utils/workspaceContext.js';
|
||||
export * from './utils/environmentContext.js';
|
||||
export * from './utils/ignorePatterns.js';
|
||||
|
||||
@@ -133,6 +133,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"fast-ack-helper": {
|
||||
"model": "gemini-2.5-flash-lite",
|
||||
"generateContentConfig": {
|
||||
"temperature": 0.2,
|
||||
"topP": 1,
|
||||
"maxOutputTokens": 120,
|
||||
"thinkingConfig": {
|
||||
"thinkingBudget": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"edit-corrector": {
|
||||
"model": "gemini-2.5-flash-lite",
|
||||
"generateContentConfig": {
|
||||
|
||||
@@ -133,6 +133,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"fast-ack-helper": {
|
||||
"model": "gemini-2.5-flash-lite",
|
||||
"generateContentConfig": {
|
||||
"temperature": 0.2,
|
||||
"topP": 1,
|
||||
"maxOutputTokens": 120,
|
||||
"thinkingConfig": {
|
||||
"thinkingBudget": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"edit-corrector": {
|
||||
"model": "gemini-2.5-flash-lite",
|
||||
"generateContentConfig": {
|
||||
|
||||
@@ -15,4 +15,5 @@ export enum LlmRole {
|
||||
UTILITY_NEXT_SPEAKER = 'utility_next_speaker',
|
||||
UTILITY_EDIT_CORRECTOR = 'utility_edit_corrector',
|
||||
UTILITY_AUTOCOMPLETE = 'utility_autocomplete',
|
||||
UTILITY_FAST_ACK_HELPER = 'utility_fast_ack_helper',
|
||||
}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import type { BaseLlmClient } from '../core/baseLlmClient.js';
|
||||
import {
|
||||
DEFAULT_FAST_ACK_MODEL_CONFIG_KEY,
|
||||
generateFastAckText,
|
||||
truncateFastAckInput,
|
||||
generateSteeringAckMessage,
|
||||
} from './fastAckHelper.js';
|
||||
import { LlmRole } from 'src/telemetry/llmRole.js';
|
||||
|
||||
describe('truncateFastAckInput', () => {
|
||||
it('returns input as-is when below limit', () => {
|
||||
expect(truncateFastAckInput('hello', 10)).toBe('hello');
|
||||
});
|
||||
|
||||
it('truncates and appends suffix when above limit', () => {
|
||||
const input = 'abcdefghijklmnopqrstuvwxyz';
|
||||
const result = truncateFastAckInput(input, 20);
|
||||
// grapheme count is 20
|
||||
const segmenter = new Intl.Segmenter(undefined, {
|
||||
granularity: 'grapheme',
|
||||
});
|
||||
expect(Array.from(segmenter.segment(result)).length).toBe(20);
|
||||
expect(result).toContain('...[truncated]');
|
||||
});
|
||||
|
||||
it('is grapheme aware', () => {
|
||||
const input = '👨👩👧👦'.repeat(10); // 10 family emojis
|
||||
const result = truncateFastAckInput(input, 5);
|
||||
// family emoji is 1 grapheme
|
||||
expect(result).toBe('👨👩👧👦👨👩👧👦👨👩👧👦👨👩👧👦👨👩👧👦');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateFastAckText', () => {
|
||||
const abortSignal = new AbortController().signal;
|
||||
|
||||
it('uses the default fast-ack-helper model config and returns response text', async () => {
|
||||
const llmClient = {
|
||||
generateContent: vi.fn().mockResolvedValue({
|
||||
candidates: [
|
||||
{ content: { parts: [{ text: ' Got it. Skipping #2. ' }] } },
|
||||
],
|
||||
}),
|
||||
} as unknown as BaseLlmClient;
|
||||
|
||||
const result = await generateFastAckText(llmClient, {
|
||||
instruction: 'Write a short acknowledgement sentence.',
|
||||
input: 'skip #2',
|
||||
fallbackText: 'Got it.',
|
||||
abortSignal,
|
||||
promptId: 'test',
|
||||
});
|
||||
|
||||
expect(result).toBe('Got it. Skipping #2.');
|
||||
expect(llmClient.generateContent).toHaveBeenCalledWith({
|
||||
modelConfigKey: DEFAULT_FAST_ACK_MODEL_CONFIG_KEY,
|
||||
contents: expect.any(Array),
|
||||
abortSignal,
|
||||
promptId: 'test',
|
||||
maxAttempts: 1,
|
||||
role: LlmRole.UTILITY_FAST_ACK_HELPER,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns fallback text when response text is empty', async () => {
|
||||
const llmClient = {
|
||||
generateContent: vi.fn().mockResolvedValue({}),
|
||||
} as unknown as BaseLlmClient;
|
||||
|
||||
const result = await generateFastAckText(llmClient, {
|
||||
instruction: 'Return one sentence.',
|
||||
input: 'cancel task 2',
|
||||
fallbackText: 'Understood. Cancelling task 2.',
|
||||
abortSignal,
|
||||
promptId: 'test',
|
||||
});
|
||||
|
||||
expect(result).toBe('Understood. Cancelling task 2.');
|
||||
});
|
||||
|
||||
it('returns fallback text when generation throws', async () => {
|
||||
const llmClient = {
|
||||
generateContent: vi.fn().mockRejectedValue(new Error('boom')),
|
||||
} as unknown as BaseLlmClient;
|
||||
|
||||
const result = await generateFastAckText(llmClient, {
|
||||
instruction: 'Return one sentence.',
|
||||
input: 'cancel task 2',
|
||||
fallbackText: 'Understood.',
|
||||
abortSignal,
|
||||
promptId: 'test',
|
||||
});
|
||||
|
||||
expect(result).toBe('Understood.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateSteeringAckMessage', () => {
|
||||
it('returns a shortened acknowledgement using fast-ack-helper', async () => {
|
||||
const llmClient = {
|
||||
generateContent: vi.fn().mockResolvedValue({
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [{ text: 'Got it. I will focus on the tests now.' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
} as unknown as BaseLlmClient;
|
||||
|
||||
const result = await generateSteeringAckMessage(
|
||||
llmClient,
|
||||
'focus on tests',
|
||||
);
|
||||
expect(result).toBe('Got it. I will focus on the tests now.');
|
||||
});
|
||||
|
||||
it('returns a fallback message if the model fails', async () => {
|
||||
const llmClient = {
|
||||
generateContent: vi.fn().mockRejectedValue(new Error('timeout')),
|
||||
} as unknown as BaseLlmClient;
|
||||
|
||||
const result = await generateSteeringAckMessage(
|
||||
llmClient,
|
||||
'a very long hint that should be truncated in the fallback message if it was longer but it is not',
|
||||
);
|
||||
expect(result).toContain('Understood. a very long hint');
|
||||
});
|
||||
|
||||
it('returns a very simple fallback if hint is empty', async () => {
|
||||
const llmClient = {
|
||||
generateContent: vi.fn().mockRejectedValue(new Error('error')),
|
||||
} as unknown as BaseLlmClient;
|
||||
|
||||
const result = await generateSteeringAckMessage(llmClient, ' ');
|
||||
expect(result).toBe('Understood. Adjusting the plan.');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { LlmRole } from '../telemetry/llmRole.js';
|
||||
import type { BaseLlmClient } from '../core/baseLlmClient.js';
|
||||
import type { ModelConfigKey } from '../services/modelConfigService.js';
|
||||
import { debugLogger } from './debugLogger.js';
|
||||
import { getResponseText } from './partUtils.js';
|
||||
|
||||
export const DEFAULT_FAST_ACK_MODEL_CONFIG_KEY: ModelConfigKey = {
|
||||
model: 'fast-ack-helper',
|
||||
};
|
||||
|
||||
export const DEFAULT_MAX_INPUT_CHARS = 1200;
|
||||
export const DEFAULT_MAX_OUTPUT_CHARS = 180;
|
||||
const INPUT_TRUNCATION_SUFFIX = '\n...[truncated]';
|
||||
|
||||
/**
|
||||
* Normalizes whitespace in a string and trims it.
|
||||
*/
|
||||
export function normalizeSpace(text: string): string {
|
||||
return text.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Grapheme-aware slice.
|
||||
*/
|
||||
function safeSlice(text: string, start: number, end?: number): string {
|
||||
const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
|
||||
const segments = Array.from(segmenter.segment(text));
|
||||
return segments
|
||||
.slice(start, end)
|
||||
.map((s) => s.segment)
|
||||
.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Grapheme-aware length.
|
||||
*/
|
||||
function safeLength(text: string): number {
|
||||
const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
|
||||
let count = 0;
|
||||
for (const _ of segmenter.segment(text)) {
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
export const USER_STEERING_INSTRUCTION =
|
||||
'Internal instruction: Re-evaluate the active plan using this user steering update. ' +
|
||||
'Classify it as ADD_TASK, MODIFY_TASK, CANCEL_TASK, or EXTRA_CONTEXT. ' +
|
||||
'Apply minimal-diff changes only to affected tasks and keep unaffected tasks active. ' +
|
||||
'Do not cancel/skip tasks unless the user explicitly cancels them. ' +
|
||||
'Acknowledge the steering briefly and state the course correction.';
|
||||
|
||||
/**
|
||||
* Wraps user input in XML-like tags to mitigate prompt injection.
|
||||
*/
|
||||
function wrapInput(input: string): string {
|
||||
return `<user_input>\n${input}\n</user_input>`;
|
||||
}
|
||||
|
||||
export function buildUserSteeringHintPrompt(hintText: string): string {
|
||||
const cleanHint = normalizeSpace(hintText);
|
||||
return `User steering update:\n${wrapInput(cleanHint)}\n${USER_STEERING_INSTRUCTION}`;
|
||||
}
|
||||
|
||||
export function formatUserHintsForModel(hints: string[]): string | null {
|
||||
if (hints.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const hintText = hints.map((hint) => `- ${normalizeSpace(hint)}`).join('\n');
|
||||
return `User hints:\n${wrapInput(hintText)}\n\n${USER_STEERING_INSTRUCTION}`;
|
||||
}
|
||||
|
||||
const STEERING_ACK_INSTRUCTION =
|
||||
'Write one short, friendly sentence acknowledging a user steering update for an in-progress task. ' +
|
||||
'Be concrete when possible (e.g., mention skipped/cancelled item numbers). ' +
|
||||
'Do not apologize, do not mention internal policy, and do not add extra steps.';
|
||||
const STEERING_ACK_TIMEOUT_MS = 1200;
|
||||
const STEERING_ACK_MAX_INPUT_CHARS = 320;
|
||||
const STEERING_ACK_MAX_OUTPUT_CHARS = 90;
|
||||
|
||||
function buildSteeringFallbackMessage(hintText: string): string {
|
||||
const normalized = normalizeSpace(hintText);
|
||||
if (!normalized) {
|
||||
return 'Understood. Adjusting the plan.';
|
||||
}
|
||||
if (safeLength(normalized) <= 64) {
|
||||
return `Understood. ${normalized}`;
|
||||
}
|
||||
return `Understood. ${safeSlice(normalized, 0, 61)}...`;
|
||||
}
|
||||
|
||||
export async function generateSteeringAckMessage(
|
||||
llmClient: BaseLlmClient,
|
||||
hintText: string,
|
||||
): Promise<string> {
|
||||
const fallbackText = buildSteeringFallbackMessage(hintText);
|
||||
|
||||
const abortController = new AbortController();
|
||||
const timeout = setTimeout(
|
||||
() => abortController.abort(),
|
||||
STEERING_ACK_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
try {
|
||||
return await generateFastAckText(llmClient, {
|
||||
instruction: STEERING_ACK_INSTRUCTION,
|
||||
input: normalizeSpace(hintText),
|
||||
fallbackText,
|
||||
abortSignal: abortController.signal,
|
||||
maxInputChars: STEERING_ACK_MAX_INPUT_CHARS,
|
||||
maxOutputChars: STEERING_ACK_MAX_OUTPUT_CHARS,
|
||||
promptId: 'steering-ack',
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
export interface GenerateFastAckTextOptions {
|
||||
instruction: string;
|
||||
input: string;
|
||||
fallbackText: string;
|
||||
abortSignal: AbortSignal;
|
||||
promptId: string;
|
||||
modelConfigKey?: ModelConfigKey;
|
||||
maxInputChars?: number;
|
||||
maxOutputChars?: number;
|
||||
}
|
||||
|
||||
export function truncateFastAckInput(
|
||||
input: string,
|
||||
maxInputChars: number = DEFAULT_MAX_INPUT_CHARS,
|
||||
): string {
|
||||
const suffixLength = safeLength(INPUT_TRUNCATION_SUFFIX);
|
||||
if (maxInputChars <= suffixLength) {
|
||||
return safeSlice(input, 0, Math.max(maxInputChars, 0));
|
||||
}
|
||||
if (safeLength(input) <= maxInputChars) {
|
||||
return input;
|
||||
}
|
||||
const keepChars = maxInputChars - suffixLength;
|
||||
return safeSlice(input, 0, keepChars) + INPUT_TRUNCATION_SUFFIX;
|
||||
}
|
||||
|
||||
export async function generateFastAckText(
|
||||
llmClient: BaseLlmClient,
|
||||
options: GenerateFastAckTextOptions,
|
||||
): Promise<string> {
|
||||
const {
|
||||
instruction,
|
||||
input,
|
||||
fallbackText,
|
||||
abortSignal,
|
||||
promptId,
|
||||
modelConfigKey = DEFAULT_FAST_ACK_MODEL_CONFIG_KEY,
|
||||
maxInputChars = DEFAULT_MAX_INPUT_CHARS,
|
||||
maxOutputChars = DEFAULT_MAX_OUTPUT_CHARS,
|
||||
} = options;
|
||||
|
||||
const safeInstruction = instruction.trim();
|
||||
if (!safeInstruction) {
|
||||
return fallbackText;
|
||||
}
|
||||
|
||||
const safeInput = truncateFastAckInput(input.trim(), maxInputChars);
|
||||
const prompt = `${safeInstruction}\n\nUser input:\n${wrapInput(safeInput)}`;
|
||||
|
||||
try {
|
||||
const response = await llmClient.generateContent({
|
||||
modelConfigKey,
|
||||
contents: [{ role: 'user', parts: [{ text: prompt }] }],
|
||||
role: LlmRole.UTILITY_FAST_ACK_HELPER,
|
||||
abortSignal,
|
||||
promptId,
|
||||
maxAttempts: 1, // Fast path, don't retry much
|
||||
});
|
||||
|
||||
const responseText = normalizeSpace(getResponseText(response) || '');
|
||||
if (!responseText) {
|
||||
return fallbackText;
|
||||
}
|
||||
|
||||
if (maxOutputChars > 0 && safeLength(responseText) > maxOutputChars) {
|
||||
return safeSlice(responseText, 0, maxOutputChars).trimEnd();
|
||||
}
|
||||
return responseText;
|
||||
} catch (error) {
|
||||
debugLogger.debug(
|
||||
`[FastAckHelper] Generation failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
return fallbackText;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user