diff --git a/packages/core/src/prompts/render-prompt.ts b/packages/core/src/prompts/render-prompt.ts index 67e083b56a..da9a7d0523 100644 --- a/packages/core/src/prompts/render-prompt.ts +++ b/packages/core/src/prompts/render-prompt.ts @@ -6,11 +6,17 @@ import type { SystemPromptOptions } from './snippets.js'; -export type ContextResolver = O | ((ctx: C) => O | Promise); +type MaybePromise = Sync extends true + ? T + : T | Promise; + +export type ContextResolver = + | O + | ((ctx: C) => MaybePromise); export type PromptSlot = { slot: string; content?: never }; -export type PromptSection = { +export type PromptSection = { /** Add a Markdown heading of appropriate level to this section. */ heading?: string; /** If supplied, wrap this section in an XML tag. */ @@ -18,33 +24,38 @@ export type PromptSection = { /** If supplied, add attributes to the XML section tag. */ attrs?: Record; /** Formatting of the content inside this section. Defaults to 'block'. */ - format?: 'inline' | 'block' | 'list' | ((parts: string[]) => string); + format?: + | 'inline' + | 'block' + | 'list' + | 'lines' + | ((parts: string[]) => string); /** Condition that must evaluate to true for the section to be rendered. */ - condition?: (ctx: C) => boolean | Promise; - content: PromptContent; + condition?: boolean | ((ctx: C) => MaybePromise); + content: PromptContent; }; // The core recursive type. // It wraps your 3 base node shapes (string, section, or array) in the resolver. -export type PromptContent = ContextResolver< +export type PromptContent = ContextResolver< C, | string | number | boolean | null | undefined - | PromptSection + | PromptSection | PromptSlot - | Array> + | Array>, + Sync >; -type BaseContent = string | BaseSection | PromptSlot | BaseContent[]; -type BaseSection = { - heading?: string; - tag?: string; - attrs?: Record; - format?: 'inline' | 'block' | 'list' | ((parts: string[]) => string); +type BaseContent = string | StaticSection | PromptSlot | BaseContent[]; +type StaticSection = Omit< + PromptSection, + 'condition' | 'content' +> & { content: BaseContent; }; @@ -56,34 +67,103 @@ function renderAttributes(attrs?: Record): string { .join(''); } -export function p( +export function p( strings: TemplateStringsArray, - ...values: Array> -): PromptContent { - const content = strings.reduce>>( + ...values: Array> +): PromptContent { + const content = strings.reduce>>( (acc, str, i) => [...acc, str, values[i] ?? ''], [], ); return { format: 'inline', content }; } -export interface RenderPromptOptions { - content: PromptContent | Array>; +export interface RenderPromptOptions { + content: PromptContent | Array>; contributions?: - | Record> - | Array>>; + | Record> + | Array>>; context: C; options?: { depth?: number }; } +function normalizeResult(rawResult: string): string { + // 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(''); +} + +const formatBasic = ( + c: BaseContent | null, + depth: number, + format: 'inline' | 'block' | 'list' | 'lines' | ((parts: string[]) => string), + resolvedContributions: Record, +): string => { + if (c === null) return ''; + if (typeof c === 'string') return c; + if (Array.isArray(c)) { + const parts = c + .map((item) => formatBasic(item, depth, format, resolvedContributions)) + .filter((p) => p !== ''); + if (typeof format === 'function') { + return format(parts); + } + if (format === 'list') { + return parts.map((p) => '- ' + p).join('\n'); + } + if (format === 'lines') { + return parts.join('\n'); + } + return parts.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, resolvedContributions); + } + + const section = c; + const sectionFormat = section.format || 'block'; + const innerContent = formatBasic( + section.content, + depth + 1, + sectionFormat, + resolvedContributions, + ).trim(); + if (!innerContent) return ''; + + let result = innerContent; + + if (section.tag) { + const attrs = renderAttributes(section.attrs); + result = `\n<${section.tag}${attrs}>\n${result}\n\n`; + } + + if (section.heading) { + const headingLevel = Math.min(depth, 6); + result = `\n\n${'#'.repeat(headingLevel)} ${section.heading}\n\n${result.trim()}`; + } + + return result; +}; + export async function renderPrompt({ content, contributions, context, options, -}: RenderPromptOptions): Promise { +}: RenderPromptOptions): Promise { const contents = Array.isArray(content) ? content : [content]; - const _contributions: Record>> = {}; + const _contributions: Record>> = {}; if (contributions) { const batches = Array.isArray(contributions) @@ -98,18 +178,17 @@ export async function renderPrompt({ } const resolveToBasic = async ( - c: PromptContent, + c: PromptContent, ): Promise => { if (c === undefined || c === null) return null; if (typeof c === 'function') { const resolved = await c(context); return resolveToBasic(resolved); } - if ( - typeof c === 'string' || - typeof c === 'number' || - typeof c === 'boolean' - ) { + if (typeof c === 'boolean') { + return null; + } + if (typeof c === 'string' || typeof c === 'number') { return String(c); } if (Array.isArray(c)) { @@ -126,8 +205,11 @@ export async function renderPrompt({ } const section = c; - if (section.condition) { - const shouldRender = await section.condition(context); + if (section.condition !== undefined) { + const shouldRender = + typeof section.condition === 'function' + ? await section.condition(context) + : section.condition; if (!shouldRender) return null; } const resolvedInner = await resolveToBasic(section.content); @@ -163,77 +245,127 @@ export async function renderPrompt({ ); } - const formatBasic = ( - c: BaseContent | null, - depth: number, - format: 'inline' | 'block' | 'list' | ((parts: string[]) => string), - ): string => { - if (c === null) return ''; - if (typeof c === 'string') return c; - if (Array.isArray(c)) { - const parts = c - .map((item) => formatBasic(item, depth, format)) - .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]; - if (!slotContributions || slotContributions.length === 0) return ''; - return formatBasic(slotContributions, depth, format); - } - - const section = c; - const sectionFormat = section.format || 'block'; - const innerContent = formatBasic( - section.content, - depth + 1, - sectionFormat, - ).trim(); - if (!innerContent) return ''; - - let result = innerContent; - - if (section.tag) { - const attrs = renderAttributes(section.attrs); - result = `\n<${section.tag}${attrs}>\n${result}\n\n`; - } - - 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')) + .map((c) => + formatBasic(c, options?.depth ?? 1, 'block', resolvedContributions), + ) .filter((p) => p !== null && p !== ''); const rawResult = parts.join('\n\n').trim(); + return normalizeResult(rawResult); +} - // 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'); +export function renderPromptSync({ + content, + contributions, + context, + options, +}: RenderPromptOptions): string { + const contents = Array.isArray(content) ? content : [content]; + const _contributions: Record>> = {}; + + 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); } - return segment; - }) - .join(''); + } + } + + const resolveToBasicSync = ( + c: PromptContent, + ): BaseContent | null => { + if (c === undefined || c === null) return null; + if (typeof c === 'function') { + const resolved = c(context); + // Extra safety check at runtime for JS users + if (resolved instanceof Promise) { + throw new Error( + 'renderPromptSync encountered a Promise from a resolver function.', + ); + } + return resolveToBasicSync(resolved); + } + if (typeof c === 'boolean') { + return null; + } + if (typeof c === 'string' || typeof c === 'number') { + return String(c); + } + if (Array.isArray(c)) { + const resolved = c.map((item) => resolveToBasicSync(item)); + const filtered = resolved.filter( + (item): item is BaseContent => item !== null, + ); + if (filtered.length === 0) return null; + return filtered; + } + if (typeof c === 'object' && c !== null) { + if ('slot' in c) { + return c; + } + + const section = c; + if (section.condition !== undefined) { + let shouldRender; + if (typeof section.condition === 'function') { + shouldRender = section.condition(context); + if ((shouldRender as unknown) instanceof Promise) { + throw new Error( + 'renderPromptSync encountered a Promise from a condition function.', + ); + } + } else { + shouldRender = section.condition; + } + if (!shouldRender) return null; + } + const resolvedInner = resolveToBasicSync(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 = contents.map((c) => resolveToBasicSync(c)); + + const resolvedContributions: Record = {}; + for (const [slot, slotContributions] of Object.entries(_contributions)) { + const resolved = slotContributions.map((c) => resolveToBasicSync(c)); + resolvedContributions[slot] = resolved.filter( + (c): c is BaseContent => c !== null, + ); + } + + const parts = resolvedContents + .map((c) => + formatBasic(c, options?.depth ?? 1, 'block', resolvedContributions), + ) + .filter((p) => p !== null && p !== ''); + + const rawResult = parts.join('\n\n').trim(); + return normalizeResult(rawResult); } -export function prompt( - ...content: Array> -): PromptContent { - return content.length === 1 ? content[0] : content; +export function prompt( + ...content: Array> +): PromptContent { + + return (content.length === 1 ? content[0] : content); }