feat(core): add recursive prompter module with dynamic sections

This commit is contained in:
Michael Bleigh
2026-03-20 16:49:24 -07:00
parent 62cb14fa52
commit fd3d28bb7c
4 changed files with 406 additions and 0 deletions
+74
View File
@@ -0,0 +1,74 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Prompt, prompt } from './prompter.js';
// prompts can be functions that depend on context
export const identity = prompt(
(ctx) =>
`You are Gemini CLI, an ${ctx.preamble?.interactive ? 'interactive' : 'autonomous'} CLI agent.`,
);
// or they can be objects representing an XML or Markdown section
export const securityMandates = prompt({
tag: 'security',
attrs: { importance: 'MAXIMUM' },
content: 'Never log or commit secrets. Protect .env and .git folders.',
});
// they can compose together, with individual content parts resolving based
// on context and not rendering if conditions aren't met
export const subagents = prompt({
id: 'subagents',
heading: 'Available Subagents',
condition: (ctx) => (ctx.subAgents?.length ?? 0) > 0,
content: [
'Sub-agents are specialized expert agents. Each sub-agent is available as a tool of the same name. You MUST delegate tasks to the sub-agent with the most relevant expertise.',
{
tag: 'available_subagents',
content: (ctx) =>
ctx.subAgents?.map((s) => `- ${s.name}: ${s.description}`).join('\n') ||
'',
},
],
});
export const emptySection = prompt({
heading: 'This Should Not Render',
content: '', // Empty content causes section to be skipped
});
const myPrompt = new Prompt(
identity,
{
id: 'general_guidance',
heading: 'General Guidance',
content: [
'The following sections tell you how to behave. ALWAYS FOLLOW.',
securityMandates,
],
},
{ heading: 'Stuff You Can Use', content: subagents },
emptySection,
);
// We can add new prompt elements dynamically
myPrompt.add('Here is an added note.');
// We can dynamically contribute to sections by ID, allowing for
// post-facto composability when adding new features
myPrompt.contribute({
general_guidance: 'Be nice to humans.',
subagents: 'Consider using the web_search subagent for recent info.',
});
//eslint-disable-next-line no-console
console.log(
await myPrompt.render({
preamble: { interactive: true },
subAgents: [{ name: 'foo', description: 'does foo stuff' }],
}),
);
+162
View File
@@ -0,0 +1,162 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it } from 'vitest';
import { Prompt } from './prompter.js';
describe('Prompt', () => {
it('renders a simple string', async () => {
const prompt = new Prompt('Hello world');
expect(await prompt.render({})).toBe('Hello world');
});
it('renders a function content resolving dynamically', async () => {
const prompt = new Prompt<{ name: string }>((ctx) => `Hello ${ctx.name}`);
expect(await prompt.render({ name: 'Alice' })).toBe('Hello Alice');
});
it('renders an array of contents', async () => {
const prompt = new Prompt(['Part 1', 'Part 2']);
expect(await prompt.render({})).toBe('Part 1\n\nPart 2');
});
it('renders a section with heading', async () => {
const prompt = new Prompt({
heading: 'My Section',
content: 'This is the body',
});
expect(await prompt.render({})).toBe('# My Section\n\nThis is the body');
});
it('renders a section with tag and attrs', async () => {
const prompt = new Prompt({
tag: 'foo',
attrs: { bar: 'baz' },
content: 'Hello',
});
expect(await prompt.render({})).toBe('<foo bar="baz">\nHello\n</foo>');
});
it('allows adding content via .add()', async () => {
const prompt = new Prompt('Original');
prompt.add('Added later');
expect(await prompt.render({})).toBe('Original\n\nAdded later');
});
it('conditionally omits rendering a section based on condition', async () => {
const prompt = new Prompt<{ shouldRender: boolean }>(
{
heading: 'Conditional',
condition: (ctx) => ctx.shouldRender,
content: 'This might not appear',
},
'Always appears',
);
expect(await prompt.render({ shouldRender: false })).toBe('Always appears');
expect(await prompt.render({ shouldRender: true })).toBe(
'# Conditional\n\nThis might not appear\n\nAlways appears',
);
});
it('conditionally omits rendering a section based on an async condition', async () => {
const prompt = new Prompt<{ shouldRender: boolean }>(
{
heading: 'Conditional',
condition: async (ctx) => ctx.shouldRender,
content: 'This might not appear',
},
'Always appears',
);
expect(await prompt.render({ shouldRender: false })).toBe('Always appears');
expect(await prompt.render({ shouldRender: true })).toBe(
'# Conditional\n\nThis might not appear\n\nAlways appears',
);
});
it('allows dynamic contributions via .contribute()', async () => {
const prompt = new Prompt(
{
id: 'target',
heading: 'Target Section',
content: ['Initial content'],
},
{
id: 'other',
content: 'Other content',
},
);
prompt.contribute({
target: 'Contributed content',
missing: 'Should be ignored',
});
const result = await prompt.render({});
expect(result).toBe(
'# Target Section\n\nInitial content\n\nContributed content\n\nOther content',
);
});
it('converts single content into an array when contributing', async () => {
const prompt = new Prompt({
id: 'target',
heading: 'Target Section',
content: 'Initial content', // String, not array
});
prompt.contribute({
target: 'Contributed content',
});
const result = await prompt.render({});
expect(result).toBe(
'# Target Section\n\nInitial content\n\nContributed content',
);
});
it('skips rendering headings and tags if the content is empty', async () => {
const prompt = new Prompt(
{
heading: 'Empty Section',
tag: 'empty',
content: '', // Empty string
},
{
heading: 'Empty Array Section',
content: [], // Empty array
},
{
heading: 'Function resolving to empty',
content: () => '',
},
'Visible content',
);
expect(await prompt.render({})).toBe('Visible content');
});
it('handles nested structures properly in contribute', async () => {
const prompt = new Prompt({
heading: 'Outer',
content: [
{
id: 'inner',
heading: 'Inner',
content: 'Inner content',
},
],
});
prompt.contribute({
inner: 'Injected into inner',
});
const result = await prompt.render({});
expect(result).toBe(
'# Outer\n\n## Inner\n\nInner content\n\nInjected into inner',
);
});
});
+139
View File
@@ -0,0 +1,139 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { SystemPromptOptions } from 'src/prompts/snippets.js';
import type { PromptContent, PromptSection } from './types.js';
// Helper to stringify XML attributes cleanly
function renderAttributes(attrs?: Record<string, string>): string {
if (!attrs) return '';
return Object.entries(attrs)
.map(([key, value]) => ` ${key}="${value}"`)
.join('');
}
export class Prompt<C = SystemPromptOptions> {
private contents: Array<PromptContent<C>>;
constructor(...contents: Array<PromptContent<C>>) {
this.contents = contents;
}
add(content: PromptContent<C>): void {
this.contents.push(content);
}
contribute(contributions: Record<string, PromptContent<C>>): void {
const traverse = (node: PromptContent<C>) => {
if (Array.isArray(node)) {
node.forEach(traverse);
} else if (typeof node === 'object' && node !== null) {
// In this branch, node is a PromptSection<C>
const section = node;
const contribution = contributions[section.id ?? ''];
if (section.id && contribution) {
const content = section.content;
if (Array.isArray(content)) {
content.push(contribution);
} else {
section.content = [content, contribution];
}
}
traverse(section.content);
}
};
this.contents.forEach(traverse);
}
async render(context: C, options?: { depth?: number }): Promise<string> {
const parts = await Promise.all(
this.contents.map((item) => Prompt.renderContent(context, item, options)),
);
return parts.filter((part) => part.length > 0).join('\n\n');
}
private static async renderContent<C>(
context: C,
content: PromptContent<C>,
options?: { depth?: number },
): Promise<string> {
// 1. if function: run function with context and process result
if (typeof content === 'function') {
const resolved = await content(context);
// keep passing options down so depth isn't lost
return Prompt.renderContent(context, resolved, options);
}
// 2. if string: simple string append
if (typeof content === 'string') {
return content;
}
// 3. if array: process and concatenate each item in the array
if (Array.isArray(content)) {
const parts = await Promise.all(
content.map((item) => Prompt.renderContent(context, item, options)),
);
// Filter out empty strings to prevent huge gaps, then separate with double newline
return parts.filter((part) => part.length > 0).join('\n\n');
}
// 4. if object: process as section
if (typeof content === 'object' && content !== null) {
if (content.condition) {
const shouldRender = await content.condition(context);
if (!shouldRender) {
return '';
}
}
return Prompt.renderSection(context, content, options);
}
return '';
}
private static async renderSection<C>(
context: C,
section: PromptSection<C>,
options?: { depth?: number },
): Promise<string> {
const depth = options?.depth ?? 1;
// Standard Markdown headings max out at h6 (######)
const headingLevel = Math.min(depth, 6);
// Pass context and increment depth for nested sections
// section.content is an array, which renderPrompt already knows how to handle
const innerContent = await Prompt.renderContent(context, section.content, {
depth: depth + 1,
});
if (!innerContent) {
return '';
}
let result = '';
if (section.heading) {
result += `${'#'.repeat(headingLevel)} ${section.heading}\n\n`;
}
if (section.tag) {
const attrs = renderAttributes(section.attrs);
result += `<${section.tag}${attrs}>\n${innerContent}\n</${section.tag}>`;
} else {
result += innerContent;
}
return result.trim();
}
}
export function prompt(
content: PromptContent<SystemPromptOptions>,
): PromptContent<SystemPromptOptions> {
return content;
}
+31
View File
@@ -0,0 +1,31 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
export type ContextResolver<C, O> = O | ((ctx: C) => O | Promise<O>);
export type PromptSection<C> = {
/** Unique identifier for the section if it can receive contributions. */
id?: string;
/** Add a Markdown heading of appropriate level to this section. */
heading?: string;
/** If supplied, wrap this section in an XML tag. */
tag?: string;
/** If supplied, add attributes to the XML section tag. */
attrs?: Record<string, string>;
/** Condition that must evaluate to true for the section to be rendered. */
condition?: (ctx: C) => boolean | Promise<boolean>;
// Notice we use the generic <C> here so the children know about the context
content: PromptContent<C>;
};
// The core recursive type.
// It wraps your 3 base node shapes (string, section, or array) in the resolver.
export type PromptContent<C> = ContextResolver<
C,
string | PromptSection<C> | Array<PromptContent<C>>
>;