From 9699ec1548074d6bd0588dc3d589c58b89ad2142 Mon Sep 17 00:00:00 2001 From: Tyler POMPU Date: Mon, 11 May 2026 20:47:59 -0700 Subject: [PATCH 1/2] fix template expression parsing compatibility --- packages/poml/components/instructions.tsx | 24 +++ packages/poml/file.tsx | 236 +++++++++++++++++++++- packages/poml/tests/file.test.tsx | 51 +++++ 3 files changed, 302 insertions(+), 9 deletions(-) diff --git a/packages/poml/components/instructions.tsx b/packages/poml/components/instructions.tsx index c13635f9..2b902d40 100644 --- a/packages/poml/components/instructions.tsx +++ b/packages/poml/components/instructions.tsx @@ -80,6 +80,30 @@ export const Task = component('Task')((props: React.PropsWithChildrenUse tools only when fresh data or external actions are required. + * ``` + */ +export const ToolPolicy = component('ToolPolicy', { aliases: ['toolPolicy', 'tool-policy'] })(( + props: React.PropsWithChildren, +) => { + const { children, caption = 'Tool Policy', captionSerialized = 'toolPolicy', ...others } = props; + return ( + + {children} + + ); +}); + /** * Output format deals with the format in which the model should provide the output. * It can be a specific format such as JSON, XML, or CSV, or a general format such as a story, diff --git a/packages/poml/file.tsx b/packages/poml/file.tsx index dea56909..f15ee75d 100644 --- a/packages/poml/file.tsx +++ b/packages/poml/file.tsx @@ -133,6 +133,7 @@ export class PomlFile { } private readXml(text: string) { + text = normalizePomlXmlInput(text); const { cst, tokenVector, lexErrors, parseErrors } = parseXML(text); const errors: ReadError[] = []; @@ -218,7 +219,7 @@ export class PomlFile { return undefined; } - const text = xmlElementText(element[0]); + const text = this.unescapeText(xmlElementText(element[0])); try { return JSON.parse(text); } catch (e) { @@ -330,7 +331,7 @@ export class PomlFile { elementName === 'tool' ) { const parserAttr = xmlAttribute(element, 'parser'); - const text = xmlElementText(element).trim(); + const text = this.unescapeText(xmlElementText(element)).trim(); // Check if it's an expression (either explicit parser="eval" or auto-detected) if (parserAttr?.value === 'eval' || (!parserAttr && !text.trim().startsWith('{'))) { @@ -490,6 +491,7 @@ export class PomlFile { context, this.xmlAttributeValueRange(ifCondition), true, + true, ); if (condition) { return true; @@ -498,6 +500,35 @@ export class PomlFile { } }; + private handleIfElement( + element: XMLElement, + globalContext: { [key: string]: any }, + localContext: { [key: string]: any }, + ): React.ReactElement | null | undefined { + if (element.name?.toLowerCase() !== 'if') { + return undefined; + } + + const conditionAttr = xmlAttribute(element, 'condition') ?? xmlAttribute(element, 'test'); + if (!conditionAttr?.value) { + this.reportError('condition attribute is expected for .', this.xmlElementRange(element)); + return null; + } + + const condition = this.evaluateExpression( + conditionAttr.value, + { ...globalContext, ...localContext }, + this.xmlAttributeValueRange(conditionAttr), + true, + true, + ); + if (!condition) { + return null; + } + + return <>{this.processXmlContents(element, globalContext, localContext)}; + } + private handleLet = ( element: XMLElement, contextIn: { [key: string]: any }, @@ -566,7 +597,7 @@ export class PomlFile { // Case 3: { JSON } // or { JSON } if (element.textContents.length > 0) { - const text = xmlElementText(element); + const text = this.unescapeText(xmlElementText(element)); let content: any; try { content = parseText(text, type as AnyValue | undefined); @@ -684,7 +715,7 @@ export class PomlFile { | 'json' | 'eval') : undefined; - const text = xmlElementText(element).trim(); + const text = this.unescapeText(xmlElementText(element)).trim(); // Get the range for the text content (if available) const textRange = @@ -921,6 +952,11 @@ export class PomlFile { private unescapeText = (text: string): string => { return text + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/&/g, '&') .replace(/#lt;/g, '<') .replace(/#gt;/g, '>') .replace(/#amp;/g, '&') @@ -989,14 +1025,28 @@ export class PomlFile { context: { [key: string]: any }, range?: Range, stripCurlyBrackets: boolean = false, + missingIdentifierAsUndefined: boolean = false, ) { try { + expression = this.unescapeText(expression); if (stripCurlyBrackets) { - const curlyMatch = expression.match(/^\s*{{\s*(.+?)\s*}}\s*$/m); + const curlyMatch = expression.match(/^\s*{{\s*([\s\S]+?)\s*}}\s*$/); if (curlyMatch) { expression = curlyMatch[1]; } } + const bareIdentifier = expression.trim(); + if ( + /^[A-Za-z_$][\w$]*$/.test(bareIdentifier) && + !['true', 'false', 'null', 'undefined', 'NaN', 'Infinity'].includes(bareIdentifier) && + !(bareIdentifier in context) && + !(bareIdentifier in globalThis) + ) { + if (missingIdentifierAsUndefined) { + return undefined; + } + throw new ReferenceError(`${bareIdentifier} is not defined`); + } const result = evalWithVariables(expression, context || {}); if (range) { this.recordEvaluation(range, result); @@ -1059,6 +1109,13 @@ export class PomlFile { if (!this.handleIfCondition(element, context)) { continue; } + const ifElement = this.handleIfElement(element, globalContext, currentLocal); + if (ifElement !== undefined) { + if (ifElement) { + resultElements.push(ifElement); + } + continue; + } // Common logic for handling meta elements and new schema/tool elements if (isMeta && this.handleMeta(element, context)) { // If it's a meta element, we don't render anything. @@ -1175,6 +1232,45 @@ export class PomlFile { } } + private processXmlContents( + element: XMLElement, + globalContext: { [key: string]: any }, + localContext: { [key: string]: any }, + ): any[] { + const tagName = element.name; + const contents = xmlElementContents(element).filter((el) => { + if ( + tagName === 'poml' && + el.type === 'XMLElement' && + ['context', 'stylesheet'].includes((el as XMLElement).name?.toLowerCase() ?? '') + ) { + return false; + } else { + return true; + } + }); + + const avoidObject = (el: any) => { + if (typeof el === 'object' && el !== null && !React.isValidElement(el)) { + return JSON.stringify(el); + } + return el; + }; + + return contents.reduce((acc, el) => { + if (el.type === 'XMLTextContent') { + acc.push( + ...this.handleText(el.text ?? '', { ...globalContext, ...localContext }, this.xmlElementRange(el)).map( + avoidObject, + ), + ); + } else if (el.type === 'XMLElement') { + acc.push(this.parseXmlElement(el, globalContext, localContext)); + } + return acc; + }, [] as any[]); + } + private recoverPosition(position: number): number { return position + this.documentRange.start; } @@ -1451,11 +1547,133 @@ export class PomlFile { /** * XML utility functions. */ +const normalizePomlXmlInput = (text: string): string => { + let normalized = ''; + let i = 0; + let inTag = false; + let quote: '"' | "'" | undefined; + + const startsEntity = (offset: number): boolean => + /^&(?:lt|gt|amp|quot|apos|#[0-9]+|#x[0-9a-fA-F]+);/.test(text.slice(offset)); + const startsMarkup = (offset: number): boolean => { + const next = text[offset + 1]; + if (next === undefined) { + return false; + } + if (next === '!' || next === '?') { + return true; + } + if (next === '/') { + return true; + } + return /[A-Za-z_:]/.test(next); + }; + + const appendEscapedTextChar = (char: string, offset: number): number => { + if (char === '<') { + normalized += '#lt;'; + return offset + 1; + } + if (char === '&' && !startsEntity(offset)) { + normalized += '#amp;'; + return offset + 1; + } + normalized += char; + return offset + 1; + }; + + while (i < text.length) { + const char = text[i]; + + if (!inTag) { + if (text.startsWith('', i + 4); + if (end === -1) { + normalized += text.slice(i); + break; + } + normalized += text.slice(i, end + 3); + i = end + 3; + continue; + } + if (text.startsWith('', i + 9); + if (end === -1) { + normalized += text.slice(i); + break; + } + normalized += text.slice(i, end + 3); + i = end + 3; + continue; + } + if (text.startsWith('', i + 2); + if (end === -1) { + normalized += text.slice(i); + break; + } + normalized += text.slice(i, end + 2); + i = end + 2; + continue; + } + if (char === '<' && startsMarkup(i)) { + inTag = true; + normalized += char; + i++; + continue; + } + i = appendEscapedTextChar(char, i); + continue; + } + + if (quote) { + if (char === quote) { + quote = undefined; + normalized += char; + i++; + continue; + } + i = appendEscapedTextChar(char, i); + continue; + } + + if (char === '"' || char === "'") { + quote = char; + normalized += char; + i++; + continue; + } + + if (char === '>') { + inTag = false; + } + normalized += char; + i++; + } + + return normalized; +}; + const evalWithVariables = (text: string, context: { [key: string]: any }): any => { - const variableNames = Object.keys(context); - const variableValues = Object.values(context); - const fn = new Function(...variableNames, `return ${text}`); - return fn(...variableValues); + const fn = new Function( + 'context', + ` +const scope = new Proxy(context, { + has(target, key) { + return key in target || !(key in globalThis); + }, + get(target, key) { + if (key === Symbol.unscopables) { + return undefined; + } + return target[key]; + } +}); +with (scope) { + return ${text}; +}`, + ); + return fn(context); }; const hyphenToCamelCase = (text: string): string => { diff --git a/packages/poml/tests/file.test.tsx b/packages/poml/tests/file.test.tsx index dd381b69..997436e0 100644 --- a/packages/poml/tests/file.test.tsx +++ b/packages/poml/tests/file.test.tsx @@ -200,6 +200,43 @@ describe('templateEngine', () => { expect(ErrorCollection.empty()).toBe(true); }); + test('ifConditionAllowsXmlSensitiveOperators', async () => { + const text = '

cheerful

'; + expect(write(await read(text, undefined, { mood_points: 6 }))).toBe('cheerful'); + expect(ErrorCollection.empty()).toBe(true); + }); + + test('ifConditionDecodesXmlEntitiesBeforeEvaluation', async () => { + const text = '

cheerful

'; + expect(write(await read(text, undefined, { mood_points: 6 }))).toBe('cheerful'); + expect(ErrorCollection.empty()).toBe(true); + }); + + test('ifElementCondition', async () => { + const text = '

visiblehidden

'; + expect(write(await read(text, undefined, { enabled: true, disabled: false }))).toBe('visible'); + expect(ErrorCollection.empty()).toBe(true); + }); + + test('ifConditionTreatsMissingBareIdentifierAsFalse', async () => { + const text = '

hidden

visible

'; + expect(write(await read(text))).toBe('visible'); + expect(ErrorCollection.empty()).toBe(true); + }); + + test('missingVariablesCanFallbackWithLogicalOr', async () => { + const text = '

{{ missing_name || "friend" }}

'; + expect(await poml(text)).toBe('friend'); + expect(ErrorCollection.empty()).toBe(true); + }); + + test('toolPolicyAlias', async () => { + const rendered = await poml('Ask before using destructive tools.'); + expect(rendered).toContain('Tool Policy'); + expect(rendered).toContain('Ask before using destructive tools.'); + expect(ErrorCollection.empty()).toBe(true); + }); + test('let', async () => { const text = '

{{i}}

'; expect(await poml(text)).toBe('1'); @@ -236,6 +273,12 @@ describe('templateEngine', () => { expect(write(await read(text))).toBe('hello world'); }); + test('letContentAllowsXmlSensitiveText', async () => { + const text = '{ "heart": "<3", "operator": "a && b" }

{{heart}} {{operator}}

'; + expect(write(await read(text))).toBe('<3 a && b'); + expect(ErrorCollection.empty()).toBe(true); + }); + test('letObject', async () => { const text = '{ "object": { "complex": true } }

{{object}}

'; expect(write(await read(text))).toBe('{"complex":true}'); @@ -277,6 +320,14 @@ describe('templateEngine', () => { expect(ErrorCollection.empty()).toBe(true); }); + test('letValueMultilineCurlyExpression', async () => { + const text = `

{{mode}}

`; + expect(write(await read(text, undefined, { points: 6 }))).toBe('cheerful'); + expect(ErrorCollection.empty()).toBe(true); + }); + test('letValueBooleanTrue', async () => { const text = '

Visible

'; expect(await poml(text)).toBe('Visible'); From 39593a76aad56b489d932fc414a4971a21960378 Mon Sep 17 00:00:00 2001 From: Tyler POMPU Date: Mon, 11 May 2026 22:37:22 -0700 Subject: [PATCH 2/2] Address Copilot review comments --- packages/poml/file.tsx | 49 +++++++------------------------ packages/poml/tests/file.test.tsx | 29 ++++++++++++++++++ 2 files changed, 40 insertions(+), 38 deletions(-) diff --git a/packages/poml/file.tsx b/packages/poml/file.tsx index f15ee75d..e3806b0e 100644 --- a/packages/poml/file.tsx +++ b/packages/poml/file.tsx @@ -957,6 +957,8 @@ export class PomlFile { .replace(/"/g, '"') .replace(/'/g, "'") .replace(/&/g, '&') + .replace(new RegExp(XML_TEXT_LT_SENTINEL, 'g'), '<') + .replace(new RegExp(XML_TEXT_AMP_SENTINEL, 'g'), '&') .replace(/#lt;/g, '<') .replace(/#gt;/g, '>') .replace(/#amp;/g, '&') @@ -1112,7 +1114,9 @@ export class PomlFile { const ifElement = this.handleIfElement(element, globalContext, currentLocal); if (ifElement !== undefined) { if (ifElement) { - resultElements.push(ifElement); + resultElements.push( + forLoopedContext.length > 1 ? {ifElement} : ifElement, + ); } continue; } @@ -1179,41 +1183,7 @@ export class PomlFile { attrib.key = `key-${i}`; } - const contents = xmlElementContents(element).filter((el) => { - // Filter out stylesheet and context element in the root poml element - if ( - tagName === 'poml' && - el.type === 'XMLElement' && - ['context', 'stylesheet'].includes((el as XMLElement).name?.toLowerCase() ?? '') - ) { - return false; - } else { - return true; - } - }); - - const avoidObject = (el: any) => { - if (typeof el === 'object' && el !== null && !React.isValidElement(el)) { - return JSON.stringify(el); - } - return el; - }; - - const processedContents = contents.reduce((acc, el, i) => { - if (el.type === 'XMLTextContent') { - // const isFirst = i === 0, - // isLast = i === contents.length - 1; - // const text = this.config.trim ? trimText(el.text || '', isFirst, isLast) : el.text || ''; - acc.push( - ...this.handleText(el.text ?? '', { ...globalContext, ...currentLocal }, this.xmlElementRange(el)).map( - avoidObject, - ), - ); - } else if (el.type === 'XMLElement') { - acc.push(this.parseXmlElement(el, globalContext, currentLocal)); - } - return acc; - }, [] as any[]); + const processedContents = this.processXmlContents(element, globalContext, currentLocal); elementToAdd = React.createElement(component.render.bind(component), attrib, ...processedContents); } @@ -1547,6 +1517,9 @@ export class PomlFile { /** * XML utility functions. */ +const XML_TEXT_LT_SENTINEL = '\uE000'; +const XML_TEXT_AMP_SENTINEL = '\uE001'; + const normalizePomlXmlInput = (text: string): string => { let normalized = ''; let i = 0; @@ -1571,11 +1544,11 @@ const normalizePomlXmlInput = (text: string): string => { const appendEscapedTextChar = (char: string, offset: number): number => { if (char === '<') { - normalized += '#lt;'; + normalized += XML_TEXT_LT_SENTINEL; return offset + 1; } if (char === '&' && !startsEntity(offset)) { - normalized += '#amp;'; + normalized += XML_TEXT_AMP_SENTINEL; return offset + 1; } normalized += char; diff --git a/packages/poml/tests/file.test.tsx b/packages/poml/tests/file.test.tsx index 997436e0..b687c47a 100644 --- a/packages/poml/tests/file.test.tsx +++ b/packages/poml/tests/file.test.tsx @@ -218,6 +218,22 @@ describe('templateEngine', () => { expect(ErrorCollection.empty()).toBe(true); }); + test('ifElementWorksInsideForLoop', async () => { + const text = '

{{ item.name }}

'; + expect( + write( + await read(text, undefined, { + items: [ + { name: 'first', show: true }, + { name: 'second', show: false }, + { name: 'third', show: true }, + ], + }), + ), + ).toBe('firstthird'); + expect(ErrorCollection.empty()).toBe(true); + }); + test('ifConditionTreatsMissingBareIdentifierAsFalse', async () => { const text = '

hidden

visible

'; expect(write(await read(text))).toBe('visible'); @@ -450,6 +466,19 @@ describe('expressionEvaluation', () => { 3, ]); }); + + test('keeps expression ranges stable after xml-sensitive normalization', () => { + ErrorCollection.clear(); + const text = '

{{ value < 3 && value > 1 }}

'; + const file = new PomlFile(text); + file.react({ value: 2 }); + const tokens = file.getExpressionTokens(); + const expressionStart = text.indexOf('{{'); + const expressionEnd = text.indexOf('}}') + 1; + expect(tokens.length).toBe(1); + expect(tokens[0].range).toStrictEqual({ start: expressionStart, end: expressionEnd }); + expect(file.getExpressionEvaluations(tokens[0].range)).toStrictEqual([true]); + }); }); describe('include', () => {