diff --git a/packages/core/src/prompter/types.ts b/packages/core/src/prompter/types.ts deleted file mode 100644 index 15521a05ae..0000000000 --- a/packages/core/src/prompter/types.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * @license - * Copyright 2026 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -export type ContextResolver = O | ((ctx: C) => O | Promise); - -export type PromptSlot = { slot: string; content?: never }; - -export type PromptSection = { - /** 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; - 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 - | number - | boolean - | PromptSection - | PromptSlot - | Array> ->; diff --git a/packages/core/src/prompter/prompter.test.ts b/packages/core/src/prompts/render-prompt.test.ts similarity index 86% rename from packages/core/src/prompter/prompter.test.ts rename to packages/core/src/prompts/render-prompt.test.ts index bb49204d1f..d3b74cbcf2 100644 --- a/packages/core/src/prompter/prompter.test.ts +++ b/packages/core/src/prompts/render-prompt.test.ts @@ -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', () => { diff --git a/packages/core/src/prompter/prompter.ts b/packages/core/src/prompts/render-prompt.ts similarity index 77% rename from packages/core/src/prompter/prompter.ts rename to packages/core/src/prompts/render-prompt.ts index b101a62ad1..67e083b56a 100644 --- a/packages/core/src/prompter/prompter.ts +++ b/packages/core/src/prompts/render-prompt.ts @@ -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 = O | ((ctx: C) => O | Promise); + +export type PromptSlot = { slot: string; content?: never }; + +export type PromptSection = { + /** 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' | 'list' | ((parts: string[]) => string); + + /** Condition that must evaluate to true for the section to be rendered. */ + condition?: (ctx: C) => boolean | Promise; + 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 + | number + | boolean + | null + | undefined + | PromptSection + | PromptSlot + | Array> +>; type BaseContent = string | BaseSection | PromptSlot | BaseContent[]; type BaseSection = { heading?: string; tag?: string; attrs?: Record; - format?: 'inline' | 'block'; + format?: 'inline' | 'block' | 'list' | ((parts: string[]) => string); content: BaseContent; }; @@ -68,6 +100,7 @@ export async function renderPrompt({ const resolveToBasic = async ( c: PromptContent, ): Promise => { + 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({ 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];