Skip to content

Commit 81c5ed7

Browse files
committed
feat: add RunConfig.dollarEscapedPlaceholders to leave escaped placeholders in instructions as written
1 parent 48e9ae7 commit 81c5ed7

6 files changed

Lines changed: 302 additions & 2 deletions

File tree

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,13 @@ public enum ToolExecutionMode {
8888

8989
public abstract boolean autoCreateSession();
9090

91+
/**
92+
* Whether an instruction placeholder right after {@code $} or {@code \}, such as {@code ${key}},
93+
* {@code ${{key}}} or {@code \{key}}, is left as written instead of being resolved, as adk-python
94+
* does. Defaults to {@code false}, which resolves it and keeps the character before it.
95+
*/
96+
public abstract boolean dollarEscapedPlaceholders();
97+
9198
/**
9299
* Three-state override for grouping function calls before function responses in history (FC1,
93100
* FC2, FR1, FR2) instead of pairing each response with its call (FC1, FR1, FC2, FR2).
@@ -129,6 +136,7 @@ public static Builder builder() {
129136
.toolExecutionMode(ToolExecutionMode.NONE)
130137
.maxLlmCalls(500)
131138
.autoCreateSession(false)
139+
.dollarEscapedPlaceholders(false)
132140
.customMetadata(ImmutableMap.of());
133141
}
134142

@@ -145,6 +153,7 @@ public static Builder builder(RunConfig runConfig) {
145153
.outputAudioTranscription(runConfig.outputAudioTranscription())
146154
.inputAudioTranscription(runConfig.inputAudioTranscription())
147155
.autoCreateSession(runConfig.autoCreateSession())
156+
.dollarEscapedPlaceholders(runConfig.dollarEscapedPlaceholders())
148157
.groupFunctionResponsesInHistoryOverride(
149158
runConfig.groupFunctionResponsesInHistoryOverride())
150159
.customMetadata(runConfig.customMetadata());
@@ -242,6 +251,9 @@ public final Builder setAutoCreateSession(boolean autoCreateSession) {
242251
@CanIgnoreReturnValue
243252
public abstract Builder autoCreateSession(boolean autoCreateSession);
244253

254+
@CanIgnoreReturnValue
255+
public abstract Builder dollarEscapedPlaceholders(boolean dollarEscapedPlaceholders);
256+
245257
@CanIgnoreReturnValue
246258
public abstract Builder customMetadata(Map<String, Object> customMetadata);
247259

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

Lines changed: 22 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.RunConfig;
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 DOLLAR_ESCAPED_PLACEHOLDER_PATTERN =
42+
Pattern.compile("(?<![\\$\\{\\\\])\\{+[^\\{\\}]*\\}+");
43+
3844
private InstructionUtils() {}
3945

4046
/**
@@ -74,9 +80,18 @@ 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 {@link com.google.adk.agents.RunConfig#dollarEscapedPlaceholders()} is {@code
87+
* true}, it is left as written, backslash included.
88+
* <li>When it is {@code false}, the default, it is resolved like any other placeholder
89+
* and the {@code $} or {@code \} before it is kept, so {@code ${key}} with {@code
90+
* key} set to {@code 9.99} becomes {@code $9.99}.
91+
* </ul>
7792
* </ul>
7893
*
79-
* <b>Example Usage:</b>
94+
* <p><b>Example Usage:</b>
8095
*
8196
* <pre>{@code
8297
* InvocationContext context = ...; // Assume this is initialized with session and artifact service
@@ -113,7 +128,12 @@ public static Single<String> injectSessionState(InvocationContext context, Strin
113128
if (context == null) {
114129
return Single.error(new NullPointerException("context cannot be null"));
115130
}
116-
Matcher matcher = INSTRUCTION_PLACEHOLDER_PATTERN.matcher(template);
131+
RunConfig runConfig = context.runConfig();
132+
Pattern placeholderPattern =
133+
runConfig != null && runConfig.dollarEscapedPlaceholders()
134+
? DOLLAR_ESCAPED_PLACEHOLDER_PATTERN
135+
: INSTRUCTION_PLACEHOLDER_PATTERN;
136+
Matcher matcher = placeholderPattern.matcher(template);
117137
List<Single<String>> parts = new ArrayList<>();
118138
int lastEnd = 0;
119139

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,10 +72,19 @@ public void testBuilderDefaults() {
7272
assertThat(runConfig.inputAudioTranscription()).isNull();
7373
assertThat(runConfig.maxLlmCalls()).isEqualTo(500);
7474
assertThat(runConfig.autoCreateSession()).isFalse();
75+
assertThat(runConfig.dollarEscapedPlaceholders()).isFalse();
7576
assertThat(runConfig.groupFunctionResponsesInHistoryOverride()).isEmpty();
7677
assertThat(runConfig.groupFunctionResponsesInHistory()).isFalse();
7778
}
7879

80+
@Test
81+
public void dollarEscapedPlaceholders_propagatedByCopy() {
82+
RunConfig source = RunConfig.builder().dollarEscapedPlaceholders(true).build();
83+
84+
assertThat(RunConfig.builder(source).build().dollarEscapedPlaceholders()).isTrue();
85+
assertThat(source.toBuilder().build().dollarEscapedPlaceholders()).isTrue();
86+
}
87+
7988
@Test
8089
public void groupFunctionResponsesInHistory_booleanSetter_setsOverrideAndBackwardCompatGetter() {
8190
RunConfig enabled = RunConfig.builder().groupFunctionResponsesInHistory(true).build();

‎core/src/test/java/com/google/adk/flows/llmflows/InstructionsTest.java‎

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
import com.google.adk.agents.Instruction;
2626
import com.google.adk.agents.InvocationContext;
2727
import com.google.adk.agents.LlmAgent;
28+
import com.google.adk.agents.RunConfig;
2829
import com.google.adk.artifacts.BaseArtifactService;
2930
import com.google.adk.models.LlmRequest;
3031
import com.google.adk.sessions.InMemorySessionService;
@@ -252,4 +253,86 @@ public void processRequest_agentInstructionAndGlobalInstruction_bothAreAppendedT
252253
assertThat(result.updatedRequest().getSystemInstructions())
253254
.containsExactly("Global instruction.\n\nAgent instruction.");
254255
}
256+
257+
@Test
258+
public void processRequest_dollarEscapedPlaceholders_keepsEscapedPlaceholders() {
259+
Session session = createSession();
260+
session.state().put("price", 9.99);
261+
session.state().put("name", "TestBot");
262+
LlmAgent agent =
263+
LlmAgent.builder()
264+
.name("agent")
265+
.instruction("{name} shows ${price}, ${{price}} and \\{price}.")
266+
.build();
267+
InvocationContext context = dollarEscaped(createContext(agent, session));
268+
269+
RequestProcessor.RequestProcessingResult result =
270+
instructionsProcessor.processRequest(context, initialRequest).blockingGet();
271+
272+
assertThat(result.updatedRequest().getSystemInstructions())
273+
.containsExactly("TestBot shows ${price}, ${{price}} and \\{price}.");
274+
}
275+
276+
@Test
277+
public void processRequest_defaultRunConfig_resolvesEscapedPlaceholders() {
278+
Session session = createSession();
279+
session.state().put("price", 9.99);
280+
session.state().put("name", "TestBot");
281+
LlmAgent agent =
282+
LlmAgent.builder()
283+
.name("agent")
284+
.instruction("{name} shows ${price}, ${{price}} and \\{price}.")
285+
.build();
286+
InvocationContext context = createContext(agent, session);
287+
288+
RequestProcessor.RequestProcessingResult result =
289+
instructionsProcessor.processRequest(context, initialRequest).blockingGet();
290+
291+
assertThat(result.updatedRequest().getSystemInstructions())
292+
.containsExactly("TestBot shows $9.99, $9.99 and \\9.99.");
293+
}
294+
295+
@Test
296+
public void processRequest_dollarEscapedPlaceholders_appliesToGlobalAndSubAgentInstructions() {
297+
Session session = createSession();
298+
session.state().put("price", 9.99);
299+
LlmAgent subAgent = LlmAgent.builder().name("sub_agent").instruction("Sub: ${price}").build();
300+
LlmAgent unused =
301+
LlmAgent.builder()
302+
.name("root_agent")
303+
.globalInstruction("Global: ${price}")
304+
.subAgents(subAgent)
305+
.build();
306+
InvocationContext context = dollarEscaped(createContext(subAgent, session));
307+
308+
RequestProcessor.RequestProcessingResult result =
309+
instructionsProcessor.processRequest(context, initialRequest).blockingGet();
310+
311+
assertThat(result.updatedRequest().getSystemInstructions())
312+
.containsExactly("Global: ${price}\n\nSub: ${price}");
313+
}
314+
315+
@Test
316+
public void processRequest_providerInstruction_isNotTemplatedWithDollarEscapedPlaceholders() {
317+
Session session = createSession();
318+
session.state().put("price", 9.99);
319+
LlmAgent agent =
320+
LlmAgent.builder()
321+
.name("agent")
322+
.instruction(new Instruction.Provider(unused -> Single.just("{price} and ${price}")))
323+
.build();
324+
InvocationContext context = dollarEscaped(createContext(agent, session));
325+
326+
RequestProcessor.RequestProcessingResult result =
327+
instructionsProcessor.processRequest(context, initialRequest).blockingGet();
328+
329+
assertThat(result.updatedRequest().getSystemInstructions())
330+
.containsExactly("{price} and ${price}");
331+
}
332+
333+
private static InvocationContext dollarEscaped(InvocationContext context) {
334+
return context.toBuilder()
335+
.runConfig(RunConfig.builder().dollarEscapedPlaceholders(true).build())
336+
.build();
337+
}
255338
}

‎core/src/test/java/com/google/adk/plugins/GlobalInstructionPluginTest.java‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020

2121
import com.google.adk.agents.CallbackContext;
2222
import com.google.adk.agents.InvocationContext;
23+
import com.google.adk.agents.RunConfig;
2324
import com.google.adk.artifacts.BaseArtifactService;
2425
import com.google.adk.models.LlmRequest;
2526
import com.google.adk.sessions.Session;
@@ -115,6 +116,18 @@ public void beforeModelCallback_withStringInstruction_injectsState() {
115116
assertThat(systemInstruction.role()).isEmpty();
116117
}
117118

119+
@Test
120+
public void beforeModelCallback_dollarEscapedPlaceholders_keepsEscapedPlaceholder() {
121+
state.put("price", "9.99");
122+
when(mockInvocationContext.runConfig())
123+
.thenReturn(RunConfig.builder().dollarEscapedPlaceholders(true).build());
124+
LlmRequest.Builder llmRequestBuilder = LlmRequest.builder();
125+
GlobalInstructionPlugin plugin = new GlobalInstructionPlugin("Price: ${price}, {price}");
126+
plugin.beforeModelCallback(mockCallbackContext, llmRequestBuilder).test().assertComplete();
127+
Content systemInstruction = llmRequestBuilder.build().config().get().systemInstruction().get();
128+
assertThat(systemInstruction.parts().get().get(0).text()).hasValue("Price: ${price}, 9.99");
129+
}
130+
118131
@Test
119132
public void beforeModelCallback_nullInstruction() {
120133
LlmRequest.Builder llmRequestBuilder = LlmRequest.builder();

0 commit comments

Comments
 (0)