Skip to content

[api][java] Substitute prompt placeholders in a single pass#908

Open
Zhuoxi2000 wants to merge 1 commit into
apache:mainfrom
Zhuoxi2000:fix-prompt-single-pass-substitution
Open

[api][java] Substitute prompt placeholders in a single pass#908
Zhuoxi2000 wants to merge 1 commit into
apache:mainfrom
Zhuoxi2000:fix-prompt-single-pass-substitution

Conversation

@Zhuoxi2000

Copy link
Copy Markdown

Linked issue: #907

Purpose of change

LocalPrompt.format previously applied String.replace once per variable. This meant that substituted values could be interpreted again as placeholders, making the result dependent on map iteration order and allowing a value such as {secret} to expand into another variable's value.

This change scans the original template once and replaces only placeholders found in that template. Unknown placeholders are left untouched, and substituted values are inserted literally rather than processed again.

This also restores parity with Python's SafeFormatter, which already performs substitution in a single pass.

Tests

Added regression tests in both Java and Python covering:

  • substituted values are not re-expanded;
  • values cannot inject another variable;
  • the same behavior through both the string and message formatting paths.

The new Java tests fail on main and pass with this change.

Validation:

  • mvn -pl api test: 276 run, 0 failures, 9 skipped
  • Python api/tests/test_prompt.py: 10 passed
  • mvn -pl api spotless:check: clean
  • ruff check: clean
  • ruff format --check: clean

API

No public API signatures are changed. This only fixes placeholder substitution behavior for values that themselves contain {...} tokens matching another provided variable. Normal template formatting is unaffected.

Documentation

  • doc-needed
  • doc-not-needed
  • doc-included

@github-actions github-actions Bot added doc-label-missing The Bot applies this label either because none or multiple labels were provided. fixVersion/0.4.0 priority/major Default priority of the PR or issue. labels Jul 15, 2026
LocalPrompt.format iterated over the variables map and applied
String.replace once per entry. Because each replacement scanned the
whole partially-substituted string, text introduced by one variable's
value could be re-interpreted as another placeholder, and the result
depended on the map's iteration order. A caller-supplied value
containing e.g. "{secret}" could be expanded into another variable's
value.

Scan the template once and replace each {key} from the original text
only, leaving unknown placeholders untouched. This removes the order
dependence and the value-injection path, and matches the Python
SafeFormatter, which already substitutes in a single pass.

Add regression tests in both languages covering re-expansion and the
value-injection case, through both formatString/format_string and
formatMessages/format_messages (which share the same substitution
path).

Closes apache#907
@Zhuoxi2000 Zhuoxi2000 force-pushed the fix-prompt-single-pass-substitution branch from 3f71645 to 751dc86 Compare July 15, 2026 04:50
@Zhuoxi2000

Copy link
Copy Markdown
Author

the only failing check is it-python [flink-1.20], which looks like a flake unrelated to this change.

could a committer rerun the failed job when convenient? Happy to investigate if it reproduces. Thanks!

// Unknown placeholder: leave it as-is.
replacement = matcher.group(0);
}
matcher.appendReplacement(result, Matcher.quoteReplacement(replacement));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One small optional thought: The switch from String.replace to appendReplacement quietly changes how a value's own characters are treated: String.replace inserted values literally, whereas appendReplacement reads $ as a group reference and \ as an escape — so quoteReplacement(...) here is doing real load-bearing work (a value like "$5.00" would otherwise throw). The new tests all use $-free values, so nothing currently locks that behavior in. Would it be worth one assertion with a value containing a $ (say {price} -> "$5.00 (was $9)") so a future refactor can't drop the quoteReplacement call unnoticed? Something like this, if useful:

@Test
@DisplayName("Values containing $ and \\ are inserted literally")
void testValueWithRegexReplacementChars() {
    Prompt prompt = Prompt.fromText("Price: {price}");
    Map<String, String> vars = new HashMap<>();
    vars.put("price", "$5.00 (was $9) \\ end");
    assertEquals("Price: $5.00 (was $9) \\ end", prompt.formatString(vars));
}

Python's re.sub with a function replacement has no $/\ sensitivity, so a mirror Python test is optional — this one is really a Java-only guard.

@wenjin272 wenjin272 added doc-not-needed Your PR changes do not impact docs fixVersion/0.3.1 The feature or bug should be implemented/fixed in the 0.3.1 version. and removed doc-label-missing The Bot applies this label either because none or multiple labels were provided. labels Jul 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

doc-not-needed Your PR changes do not impact docs fixVersion/0.3.1 The feature or bug should be implemented/fixed in the 0.3.1 version. fixVersion/0.4.0 priority/major Default priority of the PR or issue.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants