fix(core): escape template variable names in the template engine
PR Checklist
- The commit message follows our guidelines
- Tests for the changes have been added
- Changesets have been added
- Related issue(s) linked — none; found while reading the code
What is the current behavior?
createSimpleTemplateEngine() interpolates the variable name straight into the RegExp source (packages/core/src/voltops/template-engine.ts:24):
const regex = new RegExp(`{{\s*${key}\s*}}`, "g");
So the name is interpreted as a pattern instead of text, and a prompt whose variables contain metacharacters silently renders wrong — or throws:
| variable name | what the pattern does |
|---|---|
user.name | . matches any character, so {{userXname}} is substituted too |
items[0] | [0] is a character class, so {{items0}} / {{items]}} match instead of {{items[0]}}, while the intended placeholder is left untouched |
a|b | alternation — it also replaces a bare {{b}} |
total) | new RegExp throws SyntaxError: Invalid regular expression: /{{\s*total)\s*}}/g: Unmatched ')', so promptManager.render() / resolvePrompt() fail for that prompt |
Keys like user.name, items[0] and total) are reachable from the public surface — variables is Record<string, any> in prompt-manager.ts:289 and local-prompts.ts:239, i.e. whatever the caller passes to render()/resolvePrompt().
What is the new behavior?
The variable name is escaped before it is interpolated (and the braces are matched literally, so the pattern no longer depends on { being inert outside a quantifier). Every name is now substituted as text, and no name can make the engine throw.
const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\]/g, "\$&");
// ...
const regex = new RegExp(`\{\{\s*${escapeRegExp(key)}\s*\}\}`, "g");
Substitution semantics for plain names are unchanged, and the existing String(value) replacement behavior (processed.replace(regex, () => replacement), so $& in a value is not re-expanded) is preserved.
Verification
pnpm install on the full clone of main at 249191c6, then from packages/core (Node v24.21.0, vitest 3.2.4, CI=true).
Before — the three new tests against unmodified source (the first run also shows the raw SyntaxError):
Test Files 1 failed (1)
Tests 3 failed | 14 passed (17)
FAIL src/voltops/template-engine.spec.ts > Template Engine > process method > should not throw on variable names that are invalid regex sources
AssertionError: expected [Function] to not throw an error but 'SyntaxError: Invalid regular expressi…' was thrown
"SyntaxError: Invalid regular expression: /{{\s*total)\s*}}/g: Unmatched ')'"
After:
Test Files 1 passed (1)
Tests 17 passed (17)
Whole @voltagent/core suite, before vs after this diff:
pristine main : Test Files 1 failed | 102 passed (103)
Tests 2 failed | 1266 passed | 2 skipped | 1 todo (1271)
with this PR : Test Files 1 failed | 102 passed (103)
Tests 2 failed | 1269 passed | 2 skipped | 1 todo (1274)
The same two tests fail in src/workspace/sandbox/local.spec.ts on pristine main on this Windows machine — the failing-test list is byte-identical between the two runs, so they are pre-existing and unrelated (the only delta is the 3 added tests).
Lint / types, with the commands the package defines:
$ npx biome check src/voltops/template-engine.ts src/voltops/template-engine.spec.ts
Checked 2 files in 4ms. No fixes applied.
$ npx vitest run --typecheck src/voltops/template-engine.spec.ts
Test Files 1 passed (1) / Tests 17 passed (17) / Type Errors no errors
$ npx tsc --noEmit # packages/core "typecheck" script
exit status 0
Notes for reviewers
- I checked the open PRs that touch
packages/core/src/voltops/: #1508 is themain→nextsync (it moves this file toward90909b67, the currentmainblob, and does not touch the pattern construction), and #1388 changesclient.ts/types.ts. Neither overlaps this hunk, and this PR touches no file of my open #1495. - An alternative would be to give the loop a single pass over a combined pattern, but escaping is the smaller change and keeps the per-key replacement semantics.
- Disclosure: this fix was produced with an AI-assisted coding workflow in Qoder (Qwen3.8-Flash); the reproduction, the three tests and every command output above were run locally by me on
mainat249191c6.
Summary by cubic
Fixes template variable names with regex metacharacters so they substitute literally instead of breaking substitution or throwing.
createSimpleTemplateEngine() interpolated variable names directly into the RegExp source, so a key like user.name matched any character, items[0] became a character class, a|b turned into an alternation, and total) threw a SyntaxError, failing render() and resolvePrompt(). Names are now escaped before interpolation and the braces are matched literally, so every name is treated as text. Substitution semantics for plain names are unchanged.
- Adds three regression tests covering metacharacter substitution, preventing unrelated placeholder matches, and invalid regex sources not throwing.
Summary by CodeRabbit
- Bug Fixes
- Template variables containing characters such as brackets, dots, or parentheses are now matched literally, preventing incorrect substitutions and errors.