feat(core): experimental in-progress steering hints (1 of 3) (#19008)

This commit is contained in:
joshualitt
2026-02-17 14:59:33 -08:00
committed by GitHub
parent 5e2f5df62c
commit 55c628e967
20 changed files with 1381 additions and 60 deletions
@@ -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.');
});
});
+199
View File
@@ -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;
}
}