From 59411cc12be7f44752837f34c7df944a4578cb47 Mon Sep 17 00:00:00 2001 From: Michael Bleigh Date: Fri, 20 Mar 2026 21:23:23 -0700 Subject: [PATCH] feat(core): enhance prompter with slots, format control, and multi-contribution support --- packages/core/src/prompter/example.ts | 74 ------ packages/core/src/prompter/prompter.test.ts | 279 ++++++++++++-------- packages/core/src/prompter/prompter.ts | 252 +++++++++++------- packages/core/src/prompter/types.ts | 15 +- 4 files changed, 339 insertions(+), 281 deletions(-) delete mode 100644 packages/core/src/prompter/example.ts diff --git a/packages/core/src/prompter/example.ts b/packages/core/src/prompter/example.ts deleted file mode 100644 index e38e8b5285..0000000000 --- a/packages/core/src/prompter/example.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** - * @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 index 0d099f44ba..bb49204d1f 100644 --- a/packages/core/src/prompter/prompter.test.ts +++ b/packages/core/src/prompter/prompter.test.ts @@ -5,158 +5,225 @@ */ import { describe, expect, it } from 'vitest'; -import { Prompt } from './prompter.js'; +import { renderPrompt, p } from './prompter.js'; +import type { PromptContent } from './types.js'; -describe('Prompt', () => { - it('renders a simple string', async () => { - const prompt = new Prompt('Hello world'); - expect(await prompt.render({})).toBe('Hello world'); - }); +type TestContext = { name?: string; shouldRender?: boolean }; - 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'); - }); +type TestCase = { + desc: string; + content: PromptContent | Array>; + context: TestContext; + contributions?: + | Record> + | Array>>; + expect: string; +}; - 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({ +const tests: TestCase[] = [ + { + desc: 'renders a simple string', + content: 'Hello world', + context: {}, + expect: 'Hello world', + }, + { + desc: 'renders a function content resolving dynamically', + content: (ctx) => `Hello ${ctx.name}`, + context: { name: 'Alice' }, + expect: 'Hello Alice', + }, + { + desc: 'renders an array of contents with block spacing', + content: [['Part 1', 'Part 2']], + context: {}, + expect: 'Part 1\n\nPart 2', + }, + { + desc: 'p tag renders inline spacing', + content: p`Part ${1} and Part ${2}`, + context: {}, + expect: 'Part 1 and Part 2', + }, + { + desc: 'renders a section with heading', + content: { 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({ + }, + context: {}, + expect: '# My Section\n\nThis is the body', + }, + { + desc: 'renders a section with tag and attrs', + content: { 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 }>( + }, + context: {}, + expect: '\nHello\n', + }, + { + desc: 'conditionally omits rendering a section based on condition', + content: [ { heading: 'Conditional', - condition: (ctx) => ctx.shouldRender, + condition: (ctx) => ctx.shouldRender ?? false, 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 }>( + ], + context: { shouldRender: false }, + expect: 'Always appears', + }, + { + desc: 'conditionally includes rendering a section based on condition', + content: [ { heading: 'Conditional', - condition: async (ctx) => ctx.shouldRender, + condition: (ctx) => ctx.shouldRender ?? false, 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( + ], + context: { shouldRender: true }, + expect: '# Conditional\n\nThis might not appear\n\nAlways appears', + }, + { + desc: 'conditionally omits rendering a section based on an async condition', + content: [ + { + heading: 'Conditional', + condition: async (ctx) => ctx.shouldRender ?? false, + content: 'This might not appear', + }, + 'Always appears', + ], + context: { shouldRender: false }, + expect: 'Always appears', + }, + { + desc: 'allows dynamic contributions via .contribute() to {slot}', + content: [ { - id: 'target', heading: 'Target Section', - content: ['Initial content'], + content: ['Initial content', { slot: 'target' }], }, { - id: 'other', + heading: 'Other Section', content: 'Other content', }, - ); - - prompt.contribute({ + ], + contributions: { 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', + }, + context: {}, + expect: + '# Target Section\n\nInitial content\n\nContributed content\n\n# Other Section\n\nOther content', + }, + { + desc: 'handles slot contribution even when missing', + content: { 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( + content: ['Initial content', { slot: 'target' }], + }, + context: {}, + expect: '# Target Section\n\nInitial content', + }, + { + desc: 'skips rendering headings and tags if the content is empty', + content: [ { heading: 'Empty Section', tag: 'empty', - content: '', // Empty string + content: '', }, { heading: 'Empty Array Section', - content: [], // Empty array + content: [], }, { 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({ + ], + context: {}, + expect: 'Visible content', + }, + { + desc: 'handles nested structures properly in contribute', + content: { heading: 'Outer', content: [ { - id: 'inner', heading: 'Inner', - content: 'Inner content', + content: ['Inner content', { slot: 'inner' }], }, ], - }); - - prompt.contribute({ + }, + contributions: { inner: 'Injected into inner', - }); + }, + context: {}, + expect: '# Outer\n\n## Inner\n\nInner content\n\nInjected into inner', + }, + { + desc: 'resolves recursive async functions correctly before filling slots', + content: { + heading: 'Async Section', + content: async () => [ + 'Content from async function', + { slot: 'async_slot' }, + ], + }, + contributions: { + async_slot: async () => 'Contributed async content', + }, + context: {}, + expect: + '# Async Section\n\nContent from async function\n\nContributed async content', + }, + { + desc: 'collapses 3+ newlines into 2', + content: ['First', '\n\n\n\n\n', 'Second'], + context: {}, + expect: 'First\n\nSecond', + }, + { + desc: 'does not collapse 3+ newlines inside markdown code fences', + content: ['First', '```\n\n\n\n\n```', 'Second'], + context: {}, + expect: 'First\n\n```\n\n\n\n\n```\n\nSecond', + }, + { + desc: 'appends multiple contributions to the same slot', + content: { + heading: 'Multi Section', + content: [{ slot: 'multi' }], + }, + contributions: [{ multi: 'First' }, { multi: 'Second' }], + context: {}, + expect: '# Multi Section\n\nFirst\n\nSecond', + }, + { + desc: 'appends multiple contributions to an inline slot', + content: p`Prefix: ${{ slot: 'inline_multi' }}`, + contributions: [{ inline_multi: 'First' }, { inline_multi: 'Second' }], + context: {}, + expect: 'Prefix: FirstSecond', + }, +]; - const result = await prompt.render({}); - expect(result).toBe( - '# Outer\n\n## Inner\n\nInner content\n\nInjected into inner', - ); +describe('renderPrompt', () => { + it.each(tests)('$desc', async (test) => { + const result = await renderPrompt({ + content: test.content, + contributions: test.contributions, + context: test.context, + }); + expect(result).toBe(test.expect); }); }); diff --git a/packages/core/src/prompter/prompter.ts b/packages/core/src/prompter/prompter.ts index 3b336d2b71..b101a62ad1 100644 --- a/packages/core/src/prompter/prompter.ts +++ b/packages/core/src/prompter/prompter.ts @@ -5,7 +5,16 @@ */ import type { SystemPromptOptions } from 'src/prompts/snippets.js'; -import type { PromptContent, PromptSection } from './types.js'; +import type { PromptContent, PromptSlot } from './types.js'; + +type BaseContent = string | BaseSection | PromptSlot | BaseContent[]; +type BaseSection = { + heading?: string; + tag?: string; + attrs?: Record; + format?: 'inline' | 'block'; + content: BaseContent; +}; // Helper to stringify XML attributes cleanly function renderAttributes(attrs?: Record): string { @@ -15,125 +24,176 @@ function renderAttributes(attrs?: Record): string { .join(''); } -export class Prompt { - private contents: Array>; +export function p( + strings: TemplateStringsArray, + ...values: Array> +): PromptContent { + const content = strings.reduce>>( + (acc, str, i) => [...acc, str, values[i] ?? ''], + [], + ); + return { format: 'inline', content }; +} - constructor(...contents: Array>) { - this.contents = contents; - } +export interface RenderPromptOptions { + content: PromptContent | Array>; + contributions?: + | Record> + | Array>>; + context: C; + options?: { depth?: number }; +} - add(content: PromptContent): void { - this.contents.push(content); - } +export async function renderPrompt({ + content, + contributions, + context, + options, +}: RenderPromptOptions): Promise { + const contents = Array.isArray(content) ? content : [content]; + const _contributions: Record>> = {}; - 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); + if (contributions) { + const batches = Array.isArray(contributions) + ? contributions + : [contributions]; + for (const batch of batches) { + for (const [slot, c] of Object.entries(batch)) { + _contributions[slot] = _contributions[slot] || []; + _contributions[slot].push(c); } - }; - - 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); + const resolveToBasic = async ( + c: PromptContent, + ): Promise => { + if (typeof c === 'function') { + const resolved = await c(context); + return resolveToBasic(resolved); } - - // 2. if string: simple string append - if (typeof content === 'string') { - return content; + if ( + typeof c === 'string' || + typeof c === 'number' || + typeof c === 'boolean' + ) { + return String(c); } - - // 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)), + if (Array.isArray(c)) { + const resolved = await Promise.all(c.map((item) => resolveToBasic(item))); + const filtered = resolved.filter( + (item): item is BaseContent => item !== null, ); - // Filter out empty strings to prevent huge gaps, then separate with double newline - return parts.filter((part) => part.length > 0).join('\n\n'); + if (filtered.length === 0) return null; + return filtered; } - - // 4. if object: process as section - if (typeof content === 'object' && content !== null) { - if (content.condition) { - const shouldRender = await content.condition(context); - if (!shouldRender) { - return ''; - } + if (typeof c === 'object' && c !== null) { + if ('slot' in c) { + return c; } - return Prompt.renderSection(context, content, options); - } - return ''; + const section = c; + if (section.condition) { + const shouldRender = await section.condition(context); + if (!shouldRender) return null; + } + const resolvedInner = await resolveToBasic(section.content); + if ( + resolvedInner === null || + resolvedInner === '' || + (Array.isArray(resolvedInner) && resolvedInner.length === 0) + ) { + return null; + } + return { + heading: section.heading, + tag: section.tag, + attrs: section.attrs, + format: section.format, + content: resolvedInner, + }; + } + return null; + }; + + const resolvedContents = await Promise.all( + contents.map((c) => resolveToBasic(c)), + ); + + const resolvedContributions: Record = {}; + for (const [slot, slotContributions] of Object.entries(_contributions)) { + const resolved = await Promise.all( + slotContributions.map((c) => resolveToBasic(c)), + ); + resolvedContributions[slot] = resolved.filter( + (c): c is BaseContent => c !== null, + ); } - 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 ''; + const formatBasic = ( + c: BaseContent | null, + depth: number, + format: 'inline' | 'block', + ): string => { + if (c === null) return ''; + if (typeof c === 'string') return c; + if (Array.isArray(c)) { + return c + .map((item) => formatBasic(item, depth, format)) + .join(format === 'inline' ? '' : '\n\n'); + } + if ('slot' in c) { + const slotContributions = resolvedContributions[c.slot]; + if (!slotContributions || slotContributions.length === 0) return ''; + return formatBasic(slotContributions, depth, format); } - let result = ''; + const section = c; + const sectionFormat = section.format || 'block'; + const innerContent = formatBasic( + section.content, + depth + 1, + sectionFormat, + ).trim(); + if (!innerContent) return ''; - if (section.heading) { - result += `${'#'.repeat(headingLevel)} ${section.heading}\n\n`; - } + let result = innerContent; if (section.tag) { const attrs = renderAttributes(section.attrs); - result += `<${section.tag}${attrs}>\n${innerContent}\n`; - } else { - result += innerContent; + result = `\n<${section.tag}${attrs}>\n${result}\n\n`; } - return result.trim(); - } + if (section.heading) { + const headingLevel = Math.min(depth, 6); + result = `\n\n${'#'.repeat(headingLevel)} ${section.heading}\n\n${result.trim()}`; + } + + return result; + }; + + const parts = resolvedContents + .map((c) => formatBasic(c, options?.depth ?? 1, 'block')) + .filter((p) => p !== null && p !== ''); + + const rawResult = parts.join('\n\n').trim(); + + // Normalize newlines: collapse 3+ consecutive newlines into exactly 2 + // but skip content inside markdown code fences (```) + const segments = rawResult.split(/(```[\s\S]*?```)/); + return segments + .map((segment, index) => { + // Even indices are outside code fences, odd indices are inside + if (index % 2 === 0) { + return segment.replace(/\n{3,}/g, '\n\n'); + } + return segment; + }) + .join(''); } export function prompt( - content: PromptContent, + ...content: Array> ): PromptContent { - return content; + return content.length === 1 ? content[0] : content; } diff --git a/packages/core/src/prompter/types.ts b/packages/core/src/prompter/types.ts index 84a6f01c48..15521a05ae 100644 --- a/packages/core/src/prompter/types.ts +++ b/packages/core/src/prompter/types.ts @@ -6,20 +6,20 @@ export type ContextResolver = O | ((ctx: C) => O | Promise); +export type PromptSlot = { slot: string; content?: never }; + 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; + /** Formatting of the content inside this section. Defaults to 'block'. */ + format?: 'inline' | 'block'; /** 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; }; @@ -27,5 +27,10 @@ export type PromptSection = { // It wraps your 3 base node shapes (string, section, or array) in the resolver. export type PromptContent = ContextResolver< C, - string | PromptSection | Array> + | string + | number + | boolean + | PromptSection + | PromptSlot + | Array> >;