Skip to content

Commit 9928b62

Browse files
committed
feat: add an opt-in to leave escaped placeholders in instructions as written
`InstructionUtils.injectSessionState` resolves a placeholder right after `$` or `\` like any other and keeps the character in front: `Price: ${price}` becomes `Price: $9.99`, and `${expression}` fails the request when `expression` is not in the state. adk-python leaves such placeholders as written since 2.10.0 (google/adk-python@0caecd2). `LlmAgent.Builder.preserveEscapedPlaceholders(boolean)`, also available as `preserve_escaped_placeholders` in YAML agent configs, lets an agent opt into the same rule. The agent's flag applies to its instruction and the root agent's flag to the global instruction; `GlobalInstructionPlugin` takes it as a constructor argument. The default is unchanged.
1 parent 2d4a59e commit 9928b62

12 files changed

Lines changed: 687 additions & 10 deletions

File tree

‎core/src/main/java/com/google/adk/agents/Instruction.java‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,10 @@
2929
* {artifact.artifact_name}}. These are replaced with values from the agent's session state or
3030
* loaded artifacts, respectively. For example, an instruction like {@code "Translate the following
3131
* text to {language}: {user_query}"} would substitute {@code {language}} and {@code {user_query}}
32-
* with their corresponding values from the session state.
32+
* with their corresponding values from the session state. A placeholder right after {@code $} or
33+
* {@code \}, such as {@code ${language}}, is resolved in the same way unless the agent is built
34+
* with {@link LlmAgent.Builder#preserveEscapedPlaceholders(boolean)} set to {@code true}, in which
35+
* case it is left as written, backslash included.
3336
*
3437
* <p>Instructions can also be dynamically constructed using {@link Instruction.Provider}. This
3538
* allows for more complex logic where the instruction text is generated based on the current {@link

‎core/src/main/java/com/google/adk/agents/LlmAgent.java‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ public enum IncludeContents {
101101
private final ImmutableList<BaseToolset> toolsets;
102102
private final Optional<GenerateContentConfig> generateContentConfig;
103103
private final IncludeContents includeContents;
104+
private final boolean preserveEscapedPlaceholders;
104105

105106
private final boolean planning;
106107
private final Optional<Integer> maxSteps;
@@ -134,6 +135,8 @@ protected LlmAgent(Builder builder) {
134135
requireNonNullElse(builder.globalInstruction, new Instruction.Static(""));
135136
this.generateContentConfig = Optional.ofNullable(builder.generateContentConfig);
136137
this.includeContents = requireNonNullElse(builder.includeContents, IncludeContents.DEFAULT);
138+
this.preserveEscapedPlaceholders =
139+
requireNonNullElse(builder.preserveEscapedPlaceholders, false);
137140
this.planning = requireNonNullElse(builder.planning, false);
138141
this.maxSteps = Optional.ofNullable(builder.maxSteps);
139142
this.disallowTransferToParent = requireNonNullElse(builder.disallowTransferToParent, false);
@@ -181,6 +184,7 @@ public static class Builder extends BaseAgent.Builder<Builder> {
181184
private ImmutableList<Object> toolsUnion;
182185
private GenerateContentConfig generateContentConfig;
183186
private IncludeContents includeContents;
187+
private Boolean preserveEscapedPlaceholders;
184188
private Boolean planning;
185189
private Integer maxSteps;
186190
private Boolean disallowTransferToParent;
@@ -258,6 +262,20 @@ public Builder includeContents(IncludeContents includeContents) {
258262
return this;
259263
}
260264

265+
/**
266+
* Sets whether a placeholder right after {@code $} or {@code \} in the instruction is left as
267+
* written, backslash included: {@code ${key}}, {@code ${{key}}} and {@code \{key}} reach the
268+
* model unchanged. Defaults to {@code false}, which resolves such a placeholder and keeps the
269+
* {@code $} or {@code \} before it, so {@code ${price}} becomes {@code $9.99}. The global
270+
* instruction follows the root agent's setting. An {@link Instruction.Provider} is not
271+
* templated either way.
272+
*/
273+
@CanIgnoreReturnValue
274+
public Builder preserveEscapedPlaceholders(boolean preserveEscapedPlaceholders) {
275+
this.preserveEscapedPlaceholders = preserveEscapedPlaceholders;
276+
return this;
277+
}
278+
261279
@CanIgnoreReturnValue
262280
public Builder planning(boolean planning) {
263281
this.planning = planning;
@@ -902,6 +920,14 @@ public IncludeContents includeContents() {
902920
return includeContents;
903921
}
904922

923+
/**
924+
* Returns whether a placeholder right after {@code $} or {@code \} in the instruction is left as
925+
* written. See {@link Builder#preserveEscapedPlaceholders(boolean)}.
926+
*/
927+
public boolean preserveEscapedPlaceholders() {
928+
return preserveEscapedPlaceholders;
929+
}
930+
905931
public Single<List<BaseTool>> tools() {
906932
return canonicalTools().toList();
907933
}
@@ -1145,6 +1171,11 @@ public static LlmAgent fromConfig(LlmAgentConfig config, String configAbsPath)
11451171
builder.includeContents(config.includeContents());
11461172
}
11471173

1174+
// Set optional preserve_escaped_placeholders
1175+
if (config.preserveEscapedPlaceholders() != null) {
1176+
builder.preserveEscapedPlaceholders(config.preserveEscapedPlaceholders());
1177+
}
1178+
11481179
// Set optional generateContentConfig
11491180
if (config.generateContentConfig() != null) {
11501181
builder.generateContentConfig(config.generateContentConfig());

‎core/src/main/java/com/google/adk/agents/LlmAgentConfig.java‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ public class LlmAgentConfig extends BaseAgentConfig {
3434
private String outputKey;
3535
private List<ToolConfig> tools;
3636
private IncludeContents includeContents;
37+
private Boolean preserveEscapedPlaceholders;
3738
private GenerateContentConfig generateContentConfig;
3839

3940
// Callback configuration (names resolved via ComponentRegistry)
@@ -103,6 +104,14 @@ public void setIncludeContents(IncludeContents includeContents) {
103104
this.includeContents = includeContents;
104105
}
105106

107+
public Boolean preserveEscapedPlaceholders() {
108+
return preserveEscapedPlaceholders;
109+
}
110+
111+
public void setPreserveEscapedPlaceholders(Boolean preserveEscapedPlaceholders) {
112+
this.preserveEscapedPlaceholders = preserveEscapedPlaceholders;
113+
}
114+
106115
public GenerateContentConfig generateContentConfig() {
107116
return generateContentConfig;
108117
}

‎core/src/main/java/com/google/adk/flows/llmflows/Instructions.java‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,12 +44,19 @@ public Single<RequestProcessor.RequestProcessingResult> processRequest(
4444
if (agent.rootAgent() instanceof LlmAgent rootAgent) {
4545
builderSingle =
4646
appendInstruction(
47-
builderSingle, context, rootAgent.canonicalGlobalInstruction(readonlyContext));
47+
builderSingle,
48+
context,
49+
rootAgent.canonicalGlobalInstruction(readonlyContext),
50+
rootAgent.preserveEscapedPlaceholders());
4851
}
4952

5053
// Process agent-specific instruction
5154
builderSingle =
52-
appendInstruction(builderSingle, context, agent.canonicalInstruction(readonlyContext));
55+
appendInstruction(
56+
builderSingle,
57+
context,
58+
agent.canonicalInstruction(readonlyContext),
59+
agent.preserveEscapedPlaceholders());
5360

5461
return builderSingle.map(
5562
finalBuilder ->
@@ -60,7 +67,8 @@ public Single<RequestProcessor.RequestProcessingResult> processRequest(
6067
private Single<LlmRequest.Builder> appendInstruction(
6168
Single<LlmRequest.Builder> builderSingle,
6269
InvocationContext context,
63-
Single<Map.Entry<String, Boolean>> instructionEntrySingle) {
70+
Single<Map.Entry<String, Boolean>> instructionEntrySingle,
71+
boolean preserveEscapedPlaceholders) {
6472
return builderSingle.flatMap(
6573
builder ->
6674
instructionEntrySingle.flatMap(
@@ -73,7 +81,8 @@ private Single<LlmRequest.Builder> appendInstruction(
7381
if (bypassStateInjection) {
7482
return Single.just(builder.appendInstructions(ImmutableList.of(instruction)));
7583
}
76-
return InstructionUtils.injectSessionState(context, instruction)
84+
return InstructionUtils.injectSessionState(
85+
context, instruction, preserveEscapedPlaceholders)
7786
.map(
7887
resolvedInstr ->
7988
builder.appendInstructions(ImmutableList.of(resolvedInstr)));

‎core/src/main/java/com/google/adk/plugins/GlobalInstructionPlugin.java‎

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,19 +39,23 @@
3939
* <p>The plugin operates through the before_model_callback, allowing it to modify LLM requests
4040
* before they are sent to the model by prepending the global instruction to any existing system
4141
* instructions provided by the agent.
42+
*
43+
* <p>A string global instruction is templated like an agent instruction. A placeholder right after
44+
* {@code $} or {@code \} is resolved unless the plugin is created with {@code
45+
* preserveEscapedPlaceholders} set to {@code true}, whatever the setting of the current agent.
4246
*/
4347
public class GlobalInstructionPlugin extends BasePlugin {
4448

4549
private final Function<CallbackContext, Maybe<String>> instructionProvider;
4650

4751
private static Function<CallbackContext, Maybe<String>> createInstructionProvider(
48-
String globalInstruction) {
52+
String globalInstruction, boolean preserveEscapedPlaceholders) {
4953
return callbackContext -> {
5054
if (globalInstruction == null) {
5155
return Maybe.empty();
5256
}
5357
return InstructionUtils.injectSessionState(
54-
callbackContext.invocationContext(), globalInstruction)
58+
callbackContext.invocationContext(), globalInstruction, preserveEscapedPlaceholders)
5559
.toMaybe();
5660
};
5761
}
@@ -61,7 +65,21 @@ public GlobalInstructionPlugin(String globalInstruction) {
6165
}
6266

6367
public GlobalInstructionPlugin(String globalInstruction, String name) {
64-
this(createInstructionProvider(globalInstruction), name);
68+
this(globalInstruction, name, false);
69+
}
70+
71+
/**
72+
* Creates a plugin with a string global instruction.
73+
*
74+
* @param globalInstruction The global instruction, templated with values from the session state
75+
* or artifacts.
76+
* @param name The name of the plugin.
77+
* @param preserveEscapedPlaceholders Whether a placeholder right after {@code $} or {@code \} in
78+
* the global instruction is left as written, backslash included, instead of being resolved.
79+
*/
80+
public GlobalInstructionPlugin(
81+
String globalInstruction, String name, boolean preserveEscapedPlaceholders) {
82+
this(createInstructionProvider(globalInstruction, preserveEscapedPlaceholders), name);
6583
}
6684

6785
public GlobalInstructionPlugin(Function<CallbackContext, Maybe<String>> instructionProvider) {

‎core/src/main/java/com/google/adk/utils/InstructionUtils.java‎

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
package com.google.adk.utils;
1818

1919
import com.google.adk.agents.InvocationContext;
20+
import com.google.adk.agents.LlmAgent;
2021
import com.google.adk.sessions.Session;
2122
import com.google.adk.sessions.State;
2223
import com.google.common.collect.ImmutableSet;
@@ -35,6 +36,11 @@ public final class InstructionUtils {
3536
private static final Pattern INSTRUCTION_PLACEHOLDER_PATTERN =
3637
Pattern.compile("\\{+[^\\{\\}]*\\}+");
3738

39+
// Also skips braces preceded by '$', '{' or '\', so ${key}, ${{key}} and \{key} are left as
40+
// written. The '{' keeps the inner braces of ${{key}} and \{{key}} from matching on their own.
41+
private static final Pattern ESCAPE_PRESERVING_PLACEHOLDER_PATTERN =
42+
Pattern.compile("(?<![\\$\\{\\\\])\\{+[^\\{\\}]*\\}+");
43+
3844
private InstructionUtils() {}
3945

4046
/**
@@ -74,9 +80,20 @@ private InstructionUtils() {}
7480
* <li>If an optional placeholder cannot be resolved (e.g., variable not found, artifact
7581
* not found), it is replaced with an empty string.
7682
* </ul>
83+
* <li><b>Escaped Placeholders:</b> A placeholder right after {@code $} or {@code \}, such as
84+
* {@code ${key}}, {@code ${{key}}} or {@code \{key}}.
85+
* <ul>
86+
* <li>When {@code preserveEscapedPlaceholders} is {@code true}, it is left as written,
87+
* backslash included.
88+
* <li>When it is {@code false}, it is resolved like any other placeholder and the {@code
89+
* $} or {@code \} before it is kept, so {@code ${key}} with {@code key} set to {@code
90+
* 9.99} becomes {@code $9.99}.
91+
* <li>This method uses the {@link LlmAgent#preserveEscapedPlaceholders()} setting of the
92+
* context's agent when it is an {@link LlmAgent}, and {@code false} otherwise.
93+
* </ul>
7794
* </ul>
7895
*
79-
* <b>Example Usage:</b>
96+
* <p><b>Example Usage:</b>
8097
*
8198
* <pre>{@code
8299
* InvocationContext context = ...; // Assume this is initialized with session and artifact service
@@ -107,13 +124,36 @@ private InstructionUtils() {}
107124
* @throws IllegalArgumentException if a non-optional variable or artifact is not found.
108125
*/
109126
public static Single<String> injectSessionState(InvocationContext context, String template) {
127+
return injectSessionState(context, template, preservesEscapedPlaceholders(context));
128+
}
129+
130+
/**
131+
* Populates placeholders in an instruction template string with values from the session state or
132+
* loaded artifacts, as {@link #injectSessionState(InvocationContext, String)} does, with the
133+
* handling of escaped placeholders given explicitly instead of taken from the context's agent.
134+
*
135+
* @param context The invocation context providing access to session state and artifact services.
136+
* @param template The instruction template string containing placeholders to be populated.
137+
* @param preserveEscapedPlaceholders Whether a placeholder right after {@code $} or {@code \} is
138+
* left as written, backslash included, instead of being resolved.
139+
* @return A {@link Single} that will emit the populated instruction string upon successful
140+
* resolution of all non-optional placeholders.
141+
* @throws NullPointerException if the template or context is null.
142+
* @throws IllegalArgumentException if a non-optional variable or artifact is not found.
143+
*/
144+
public static Single<String> injectSessionState(
145+
InvocationContext context, String template, boolean preserveEscapedPlaceholders) {
110146
if (template == null) {
111147
return Single.error(new NullPointerException("template cannot be null"));
112148
}
113149
if (context == null) {
114150
return Single.error(new NullPointerException("context cannot be null"));
115151
}
116-
Matcher matcher = INSTRUCTION_PLACEHOLDER_PATTERN.matcher(template);
152+
Pattern placeholderPattern =
153+
preserveEscapedPlaceholders
154+
? ESCAPE_PRESERVING_PLACEHOLDER_PATTERN
155+
: INSTRUCTION_PLACEHOLDER_PATTERN;
156+
Matcher matcher = placeholderPattern.matcher(template);
117157
List<Single<String>> parts = new ArrayList<>();
118158
int lastEnd = 0;
119159

@@ -144,6 +184,12 @@ public static Single<String> injectSessionState(InvocationContext context, Strin
144184
});
145185
}
146186

187+
private static boolean preservesEscapedPlaceholders(InvocationContext context) {
188+
return context != null
189+
&& context.agent() instanceof LlmAgent llmAgent
190+
&& llmAgent.preserveEscapedPlaceholders();
191+
}
192+
147193
private static Single<String> resolveMatchAsync(InvocationContext context, MatchResult match) {
148194
String placeholder = match.group();
149195
String varNameFromPlaceholder =

‎core/src/test/java/com/google/adk/agents/ConfigAgentUtilsTest.java‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1561,4 +1561,39 @@ public void fromConfig_validYamlLoopAgent_createsLoopAgent()
15611561
assertThat(agent).isNotNull();
15621562
assertThat(agent).isInstanceOf(LoopAgent.class);
15631563
}
1564+
1565+
@Test
1566+
public void fromConfig_withPreserveEscapedPlaceholders_setsFlagOnAgent()
1567+
throws IOException, ConfigurationException {
1568+
File configFile = tempFolder.newFile("preserve_escaped_placeholders.yaml");
1569+
Files.writeString(
1570+
configFile.toPath(),
1571+
"""
1572+
name: preserving_agent
1573+
instruction: "Show ${expression} as written."
1574+
preserve_escaped_placeholders: true
1575+
""");
1576+
1577+
BaseAgent agent = ConfigAgentUtils.fromConfig(configFile.getAbsolutePath());
1578+
1579+
assertThat(agent).isInstanceOf(LlmAgent.class);
1580+
assertThat(((LlmAgent) agent).preserveEscapedPlaceholders()).isTrue();
1581+
}
1582+
1583+
@Test
1584+
public void fromConfig_withoutPreserveEscapedPlaceholders_defaultsToFalse()
1585+
throws IOException, ConfigurationException {
1586+
File configFile = tempFolder.newFile("default_escaped_placeholders.yaml");
1587+
Files.writeString(
1588+
configFile.toPath(),
1589+
"""
1590+
name: default_agent
1591+
instruction: Resolve {placeholders}.
1592+
""");
1593+
1594+
BaseAgent agent = ConfigAgentUtils.fromConfig(configFile.getAbsolutePath());
1595+
1596+
assertThat(agent).isInstanceOf(LlmAgent.class);
1597+
assertThat(((LlmAgent) agent).preserveEscapedPlaceholders()).isFalse();
1598+
}
15641599
}

‎core/src/test/java/com/google/adk/agents/LlmAgentTest.java‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -935,4 +935,53 @@ public void runAsync_resumeFromTransferToPeer_runsTransferredPeerAgent() {
935935
assertThat(simplifyEvents(events)).contains("agent_b: agent B response");
936936
assertThat(simplifyEvents(events)).doesNotContain("agent_a: agent A should not re-run");
937937
}
938+
939+
@Test
940+
public void testBuild_withoutPreserveEscapedPlaceholders_defaultsToFalse() {
941+
LlmAgent agent = createTestAgentBuilder(createTestLlm(LlmResponse.builder().build())).build();
942+
943+
assertThat(agent.preserveEscapedPlaceholders()).isFalse();
944+
}
945+
946+
@Test
947+
public void testBuild_withPreserveEscapedPlaceholders_setsFlag() {
948+
LlmAgent agent =
949+
createTestAgentBuilder(createTestLlm(LlmResponse.builder().build()))
950+
.preserveEscapedPlaceholders(true)
951+
.build();
952+
953+
assertThat(agent.preserveEscapedPlaceholders()).isTrue();
954+
}
955+
956+
@Test
957+
public void run_withPreserveEscapedPlaceholders_sendsEscapedPlaceholdersToModel() {
958+
TestLlm testLlm = createTestLlm(createTextLlmResponse("response"));
959+
LlmAgent agent =
960+
createTestAgentBuilder(testLlm)
961+
.instruction("{name} shows ${price}.")
962+
.preserveEscapedPlaceholders(true)
963+
.build();
964+
InvocationContext invocationContext = createInvocationContext(agent);
965+
invocationContext.session().state().put("name", "TestBot");
966+
invocationContext.session().state().put("price", 9.99);
967+
968+
List<Event> unused = agent.runAsync(invocationContext).toList().blockingGet();
969+
970+
assertThat(getOnlyElement(testLlm.getLastRequest().getSystemInstructions()))
971+
.startsWith("TestBot shows ${price}.\n\n");
972+
}
973+
974+
@Test
975+
public void run_withoutPreserveEscapedPlaceholders_sendsResolvedPlaceholdersToModel() {
976+
TestLlm testLlm = createTestLlm(createTextLlmResponse("response"));
977+
LlmAgent agent = createTestAgentBuilder(testLlm).instruction("{name} shows ${price}.").build();
978+
InvocationContext invocationContext = createInvocationContext(agent);
979+
invocationContext.session().state().put("name", "TestBot");
980+
invocationContext.session().state().put("price", 9.99);
981+
982+
List<Event> unused = agent.runAsync(invocationContext).toList().blockingGet();
983+
984+
assertThat(getOnlyElement(testLlm.getLastRequest().getSystemInstructions()))
985+
.startsWith("TestBot shows $9.99.\n\n");
986+
}
938987
}

0 commit comments

Comments
 (0)