From fd3d28bb7c9119a15cfba1c980b115a3067b6a78 Mon Sep 17 00:00:00 2001 From: Michael Bleigh Date: Fri, 20 Mar 2026 16:49:24 -0700 Subject: [PATCH] feat(core): add recursive prompter module with dynamic sections --- packages/core/src/prompter/example.ts | 74 +++++++++ packages/core/src/prompter/prompter.test.ts | 162 ++++++++++++++++++++ packages/core/src/prompter/prompter.ts | 139 +++++++++++++++++ packages/core/src/prompter/types.ts | 31 ++++ 4 files changed, 406 insertions(+) create mode 100644 packages/core/src/prompter/example.ts create mode 100644 packages/core/src/prompter/prompter.test.ts create mode 100644 packages/core/src/prompter/prompter.ts create mode 100644 packages/core/src/prompter/types.ts diff --git a/packages/core/src/prompter/example.ts b/packages/core/src/prompter/example.ts new file mode 100644 index 0000000000..e38e8b5285 --- /dev/null +++ b/packages/core/src/prompter/example.ts @@ -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' }], + }), +); diff --git a/packages/core/src/prompter/prompter.test.ts b/packages/core/src/prompter/prompter.test.ts new file mode 100644 index 0000000000..0d099f44ba --- /dev/null +++ b/packages/core/src/prompter/prompter.test.ts @@ -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('\nHello\n'); + }); + + 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', + ); + }); +}); diff --git a/packages/core/src/prompter/prompter.ts b/packages/core/src/prompter/prompter.ts new file mode 100644 index 0000000000..3b336d2b71 --- /dev/null +++ b/packages/core/src/prompter/prompter.ts @@ -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 { + if (!attrs) return ''; + return Object.entries(attrs) + .map(([key, value]) => ` ${key}="${value}"`) + .join(''); +} + +export class Prompt { + private contents: Array>; + + constructor(...contents: Array>) { + this.contents = contents; + } + + add(content: PromptContent): void { + this.contents.push(content); + } + + contribute(contributions: Record>): void { + const traverse = (node: PromptContent) => { + if (Array.isArray(node)) { + node.forEach(traverse); + } else if (typeof node === 'object' && node !== null) { + // In this branch, node is a PromptSection + 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 { + 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( + context: C, + content: PromptContent, + options?: { depth?: number }, + ): Promise { + // 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( + context: C, + section: PromptSection, + options?: { depth?: number }, + ): Promise { + 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`; + } else { + result += innerContent; + } + + return result.trim(); + } +} + +export function prompt( + content: PromptContent, +): PromptContent { + return content; +} diff --git a/packages/core/src/prompter/types.ts b/packages/core/src/prompter/types.ts new file mode 100644 index 0000000000..84a6f01c48 --- /dev/null +++ b/packages/core/src/prompter/types.ts @@ -0,0 +1,31 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +export type ContextResolver = O | ((ctx: C) => O | Promise); + +export type PromptSection = { + /** 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; + + /** Condition that must evaluate to true for the section to be rendered. */ + condition?: (ctx: C) => boolean | Promise; + + // Notice we use the generic here so the children know about the context + content: PromptContent; +}; + +// The core recursive type. +// It wraps your 3 base node shapes (string, section, or array) in the resolver. +export type PromptContent = ContextResolver< + C, + string | PromptSection | Array> +>;