feat(core): enhance and consolidate prompter library into render-prompt.ts

This commit is contained in:
Michael Bleigh
2026-03-21 00:29:36 -07:00
parent 59411cc12b
commit ee3c3e0011
3 changed files with 84 additions and 44 deletions
-36
View File
@@ -1,36 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
export type ContextResolver<C, O> = O | ((ctx: C) => O | Promise<O>);
export type PromptSlot = { slot: string; content?: never };
export type PromptSection<C> = {
/** 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>;
/** 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<boolean>;
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
| number
| boolean
| PromptSection<C>
| PromptSlot
| Array<PromptContent<C>>
>;
@@ -5,8 +5,8 @@
*/
import { describe, expect, it } from 'vitest';
import { renderPrompt, p } from './prompter.js';
import type { PromptContent } from './types.js';
import { renderPrompt, p } from './render-prompt.js';
import type { PromptContent } from './render-prompt.js';
type TestContext = { name?: string; shouldRender?: boolean };
@@ -215,6 +215,42 @@ const tests: TestCase[] = [
context: {},
expect: 'Prefix: FirstSecond',
},
{
desc: 'conditionally omits items when null or undefined are present',
content: [
'Item 1',
null,
'Item 2',
undefined,
{
heading: 'Optional Section',
content: null,
},
'Item 3',
],
context: {},
expect: 'Item 1\n\nItem 2\n\nItem 3',
},
{
desc: 'renders lists with dashes',
content: {
heading: 'My List',
format: 'list',
content: ['Apple', 'Banana', null, 'Cherry'],
},
context: {},
expect: '# My List\n\n- Apple\n- Banana\n- Cherry',
},
{
desc: 'supports custom format functions',
content: {
heading: 'Custom Format',
format: (parts) => parts.join(' | '),
content: ['A', 'B', undefined, 'C'],
},
context: {},
expect: '# Custom Format\n\nA | B | C',
},
];
describe('renderPrompt', () => {
@@ -4,15 +4,47 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { SystemPromptOptions } from 'src/prompts/snippets.js';
import type { PromptContent, PromptSlot } from './types.js';
import type { SystemPromptOptions } from './snippets.js';
export type ContextResolver<C, O> = O | ((ctx: C) => O | Promise<O>);
export type PromptSlot = { slot: string; content?: never };
export type PromptSection<C> = {
/** 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>;
/** Formatting of the content inside this section. Defaults to 'block'. */
format?: 'inline' | 'block' | 'list' | ((parts: string[]) => string);
/** Condition that must evaluate to true for the section to be rendered. */
condition?: (ctx: C) => boolean | Promise<boolean>;
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
| number
| boolean
| null
| undefined
| PromptSection<C>
| PromptSlot
| Array<PromptContent<C>>
>;
type BaseContent = string | BaseSection | PromptSlot | BaseContent[];
type BaseSection = {
heading?: string;
tag?: string;
attrs?: Record<string, string>;
format?: 'inline' | 'block';
format?: 'inline' | 'block' | 'list' | ((parts: string[]) => string);
content: BaseContent;
};
@@ -68,6 +100,7 @@ export async function renderPrompt<C = SystemPromptOptions>({
const resolveToBasic = async (
c: PromptContent<C>,
): Promise<BaseContent | null> => {
if (c === undefined || c === null) return null;
if (typeof c === 'function') {
const resolved = await c(context);
return resolveToBasic(resolved);
@@ -133,14 +166,21 @@ export async function renderPrompt<C = SystemPromptOptions>({
const formatBasic = (
c: BaseContent | null,
depth: number,
format: 'inline' | 'block',
format: 'inline' | 'block' | 'list' | ((parts: string[]) => string),
): string => {
if (c === null) return '';
if (typeof c === 'string') return c;
if (Array.isArray(c)) {
return c
const parts = c
.map((item) => formatBasic(item, depth, format))
.join(format === 'inline' ? '' : '\n\n');
.filter((p) => p !== '');
if (typeof format === 'function') {
return format(parts);
}
if (format === 'list') {
return parts.map((p) => '- ' + p).join('\n');
}
return parts.join(format === 'inline' ? '' : '\n\n');
}
if ('slot' in c) {
const slotContributions = resolvedContributions[c.slot];