diff --git a/packages/core/src/prompts/promptTemplating.test.ts b/packages/core/src/prompts/promptTemplating.test.ts new file mode 100644 index 00000000000..da482ff1036 --- /dev/null +++ b/packages/core/src/prompts/promptTemplating.test.ts @@ -0,0 +1,150 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { promptComponent, promptTemplate, renderSnippet, renderTemplate, enabledWhen, section, xmlSection, each, switchOn, type Snippet, type PromptTemplateBase } from './promptTemplating.js'; + +// Sample prompt components. +type MainPromptOptions = { isInteractive: boolean }; + +const identity = promptComponent('Your name is Gemini CLI and you are a coding agent'); + +const interactive = promptComponent( + enabledWhen('isInteractive', 'You are operating in an interactive session') +); + +const mainPrompt = promptComponent(identity, interactive); + +const variadicPrompt = promptComponent('A', 'B', 'C'); + +const nestedVariadicPrompt = promptComponent('A', ['B', 'C'], 'D'); + +const enabledWhenPrompt = promptComponent>( + 'Always', + enabledWhen('show', 'Sometimes') +); + +const xmlPrompt = xmlSection('rules', 'Be helpful'); + +const emptyXmlPrompt = xmlSection('rules', ''); + +const markdownPrompt = section('Rules', 'Be helpful'); + +const emptyMarkdownPrompt = section('Rules', ''); + +const customHeaderMarkdownPrompt = section('Rules', 'Be helpful', { headerLevel: 3 }); + +const eachPrompt = each<{ items: string[] }, string>('items', (item: string) => `Item: ${item}`); + +const eachCustomSeparatorPrompt = each<{ items: string[] }, string>('items', (item: string) => item, ', '); + +const switchOnPrompt = switchOn<{ mode: string | number }>('mode', { + fast: 'Speed is key', + safe: 'Safety first', + 1: 'Mode one', +}); + +// A high-level template. +interface SystemPromptOptions { name: string } +interface SystemPromptTemplate extends PromptTemplateBase { + identity: Snippet; + instructions: Snippet; +} + +const mainTemplate = promptTemplate({ + identity: (opt: SystemPromptOptions) => `Your name is ${opt.name}`, + instructions: 'Be helpful', +}); + +describe('promptTemplating', () => { + it('should take variadic arguments', () => { + expect(renderSnippet({}, variadicPrompt)).toBe('A,B,C'); + }); + + it('should handle nested arrays (variadic with arrays)', () => { + expect(renderSnippet({}, nestedVariadicPrompt)).toBe('A,B,C,D'); + }); + + it('should handle enabledWhen', () => { + expect(renderSnippet({ show: true }, enabledWhenPrompt)).toBe('Always,Sometimes'); + expect(renderSnippet({ show: false }, enabledWhenPrompt)).toBe('Always,'); + }); + + it('should handle xmlSection', () => { + expect(renderSnippet({}, xmlPrompt)).toBe('\nBe helpful\n'); + }); + + it('should return empty string for xmlSection if content is empty', () => { + expect(renderSnippet({}, emptyXmlPrompt)).toBe(''); + }); + + it('should handle markdown section with default header', () => { + expect(renderSnippet({}, markdownPrompt)).toBe('# Rules\n\nBe helpful'); + }); + + it('should return empty string for markdown section if content is empty', () => { + expect(renderSnippet({}, emptyMarkdownPrompt)).toBe(''); + }); + + it('should handle markdown section with custom header level', () => { + expect(renderSnippet({}, customHeaderMarkdownPrompt)).toBe('### Rules\n\nBe helpful'); + }); + + it('should handle each', () => { + const options = { items: ['A', 'B'] }; + expect(renderSnippet(options, eachPrompt)).toBe('Item: A\nItem: B'); + }); + + it('should handle switchOn', () => { + expect(renderSnippet({ mode: 'fast' }, switchOnPrompt)).toBe('Speed is key'); + expect(renderSnippet({ mode: 'safe' }, switchOnPrompt)).toBe('Safety first'); + expect(renderSnippet({ mode: 'unknown' }, switchOnPrompt)).toBe(''); + }); + + it('should handle switchOn with numeric values', () => { + expect(renderSnippet({ mode: 1 }, switchOnPrompt)).toBe('Mode one'); + }); + + it('should handle each with custom separator', () => { + const options = { items: ['A', 'B'] }; + expect(renderSnippet(options, eachCustomSeparatorPrompt)).toBe('A, B'); + }); + + it('should handle each with non-array value', () => { + const options = { items: 'not an array' as unknown as string[] }; + expect(renderSnippet(options, eachPrompt)).toBe(''); + }); + + it('should handle section with different header levels', () => { + const h2Section = section('Level 2', 'Content', { headerLevel: 2 }); + const h6Section = section('Level 6', 'Content', { headerLevel: 6 }); + expect(renderSnippet({}, h2Section)).toBe('## Level 2\n\nContent'); + expect(renderSnippet({}, h6Section)).toBe('###### Level 6\n\nContent'); + }); + + it('should handle toPrompt with basic types', () => { + expect(renderSnippet({}, 'just a string')).toBe('just a string'); + expect(renderSnippet({ name: 'test' }, (opt: { name: string }) => opt.name)).toBe('test'); + }); + + it('should handle toPrompt with empty array or object', () => { + expect(renderSnippet({}, [])).toBe(''); + expect(renderSnippet({}, {})).toBe(''); + }); + + it('should handle renderTemplate with a record of snippets (implements required members)', () => { + const options = { + name: 'Gemini CLI', + }; + expect(renderTemplate(options, mainTemplate)).toBe('Your name is Gemini CLI,Be helpful'); + }); + + it('should output correctly for mainPrompt', () => { + const text = renderSnippet({ isInteractive: true }, mainPrompt); + expect(text).toContain('Your name is Gemini CLI and you are a coding agent'); + expect(text).toContain('You are operating in an interactive session'); + }); +}); diff --git a/packages/core/src/prompts/promptTemplating.ts b/packages/core/src/prompts/promptTemplating.ts new file mode 100644 index 00000000000..9f9efdc4551 --- /dev/null +++ b/packages/core/src/prompts/promptTemplating.ts @@ -0,0 +1,262 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview + * This module provides a functional, type-safe DSL for constructing complex LLM prompts. + * It supports dynamic content generation, conditional snippets, structural formatting (XML, Markdown), + * and collection iteration, ensuring that prompt logic remains modular and maintainable. + * + * Key Features: + * - **Type Safety**: Prompts are tied to an options interface, ensuring all dynamic data is validated. + * - **Composition**: Small, reusable snippets can be combined. Templates (`promptTemplate`) ensure that like components have the same required elements, while components (`promptComponent`) are used for constructing the prompt. + * - **Conditional Logic**: Use `enabledWhen` and `switchOn` to prune or swap prompt sections dynamically. + * - **Structural Helpers**: `xmlSection` and `section` (Markdown) handle boilerplate formatting and empty-state pruning. + * - **Iteration**: `each` simplifies rendering lists of tools, rules, or historical context. + * + * @example + * interface SystemOptions { + * name: string; + * isInteractive: boolean; + * rules: string[]; + * mode: 'fast' | 'creative'; + * } + * + * interface SystemTemplate extends PromptTemplateBase { + * identity: Snippet; + * constraints: Snippet; + * capabilities: Snippet; + * } + * + * const systemPrompt = promptTemplate({ + * identity: (opt) => `Your name is ${opt.name}.`, + * constraints: xmlSection("rules", each("rules", (r) => `- ${r}`)), + * capabilities: promptComponent( + * switchOn("mode", { + * fast: "Optimize for speed and brevity.", + * creative: "Optimize for detail and exploration." + * }), + * enabledWhen("isInteractive", "You are in a live session; be brief.") + * ) + * }); + * + * const output = renderTemplate({ + * name: "Gemini CLI", + * isInteractive: true, + * rules: ["No spoilers", "Be helpful"], + * mode: "fast" + * }, systemPrompt); + */ + +/** + * A function that generates a string snippet based on the provided options. + * + * @example + * interface Options { name: string; } + * const snippet: DynamicSnippet = (options) => `Hello, ${options.name}!`; + */ +export type DynamicSnippet = (options: TOption) => string; + +/** + * A snippet can be a string, a dynamic snippet function, an array of snippets, + * or a full prompt template base object. + * + * @example + * interface Options { name: string; } + * const simple: Snippet = "Hello World"; + * const dynamic: Snippet = (opt) => opt.name; + * const combined: Snippet = ["Hello ", (opt) => opt.name]; + */ +export type Snippet = + | string + | DynamicSnippet + | Array> + | PromptTemplateBase; + +/** + * The base interface for a prompt template, where each key is a snippet. + */ +export interface PromptTemplateBase { + [key: string]: Snippet; +} + +/** + * Represents a complete prompt template defined by TTemplate. + */ +export type PromptTemplate< + TOption, + TTemplate extends PromptTemplateBase, +> = TTemplate; + +/** + * Defines a prompt template. + * + * @example + * interface Options { name: string; } + * interface Template extends PromptTemplateBase { greeting: Snippet; } + * const myTemplate = promptTemplate({ + * greeting: (opt) => `Hello ${opt.name}` + * }); + */ +export function promptTemplate< + TOption, + TTemplate extends PromptTemplateBase, +>(template: TTemplate): PromptTemplate { + return template; +} + +/** + * Combines multiple snippets into a single component. + * + * @example + * interface Options { second: string; } + * const component = promptComponent("First part. ", (opt) => opt.second); + */ +export function promptComponent( + ...snippets: Array> +): Snippet { + return snippets; +} + +/** + * Conditionally includes a snippet if a specific option is truthy. + * + * @example + * interface Options { verbose: boolean; } + * const conditional = enabledWhen("verbose", "Running in verbose mode."); + */ +export function enabledWhen( + optionName: keyof TOption, + snippet: Snippet, +): Snippet { + return (option: TOption) => + option[optionName] ? renderSnippet(option, snippet) : ''; +} + +/** + * Wraps a snippet in XML tags. + * + * @example + * interface Options { content: string; } + * const xml = xmlSection("details", (opt) => opt.content); + * // Output:
\nSome content\n
+ */ +export function xmlSection( + name: string, + snippet: Snippet, +): Snippet { + return (options: TOption) => { + const content = renderSnippet(options, snippet); + return content ? `<${name}>\n${content}\n` : ''; + }; +} + +/** + * Options for a markdown section. + */ +export interface SectionOptions { + /** The level of the markdown header (default is 1). */ + headerLevel?: number; +} + +/** + * Creates a markdown-style section with a header. + * + * @example + * interface Options { title: string; } + * const mdSection = section("Introduction", "This is the content.", { headerLevel: 2 }); + * // Output: ## Introduction\n\nThis is the content. + */ +export function section( + name: string, + snippet: Snippet, + sectionOptions?: SectionOptions, +): Snippet { + return (options: TOption) => { + const content = renderSnippet(options, snippet); + const level = sectionOptions?.headerLevel ?? 1; + const hashes = '#'.repeat(level); + return content ? `${hashes} ${name}\n\n${content}` : ''; + }; +} + +/** + * Iterates over an array in the options and renders a snippet for each item. + * + * @example + * interface Options { items: string[]; } + * const list = each("items", (item) => `- ${item}`); + */ +export function each( + optionName: keyof TOption, + snippet: Snippet, + separator = '\n', +): Snippet { + return (options: TOption) => { + const items = options[optionName]; + if (Array.isArray(items)) { + return items + .map((item: TItem) => renderSnippet(item, snippet)) + .join(separator); + } + return ''; + }; +} + +/** + * Renders a snippet based on the value of a specific option. + * + * @example + * interface Options { mode: 'a' | 'b'; } + * const router = switchOn("mode", { + * a: "Mode A selected", + * b: "Mode B selected" + * }); + */ +export function switchOn( + optionName: keyof TOption, + cases: Record>, +): Snippet { + return (options: TOption) => { + const value = String(options[optionName]); + const caseSnippet = cases[value]; + return caseSnippet ? renderSnippet(options, caseSnippet) : ''; + }; +} + +/** + * Renders an entire template into a single string. + */ +export function renderTemplate< + TOption, + TTemplate extends PromptTemplateBase, +>(options: TOption, implementation: TTemplate): string { + return Object.values(implementation) + .map((eachSnippet) => renderSnippet(options, eachSnippet)) + .join(); +} + +/** + * Renders a snippet into a string. + */ +export function renderSnippet( + options: TOption, + snippet: Snippet, +): string { + if (typeof snippet === 'string') { + return snippet; + } else if (Array.isArray(snippet)) { + return snippet + .map((eachSnippet) => renderSnippet(options, eachSnippet)) + .join(); + } else if (typeof snippet === 'function') { + return snippet(options); + } else { + return Object.values(snippet) + .map((eachSnippet) => renderSnippet(options, eachSnippet)) + .join(); + } +}