diff --git a/core/src/main/java/com/google/adk/agents/Instruction.java b/core/src/main/java/com/google/adk/agents/Instruction.java
index 1eb7dbe2f..5a713fa7f 100644
--- a/core/src/main/java/com/google/adk/agents/Instruction.java
+++ b/core/src/main/java/com/google/adk/agents/Instruction.java
@@ -29,7 +29,10 @@
* {artifact.artifact_name}}. These are replaced with values from the agent's session state or
* loaded artifacts, respectively. For example, an instruction like {@code "Translate the following
* text to {language}: {user_query}"} would substitute {@code {language}} and {@code {user_query}}
- * with their corresponding values from the session state.
+ * with their corresponding values from the session state. A placeholder right after {@code $} or
+ * {@code \}, such as {@code ${language}}, is resolved in the same way unless the run uses {@link
+ * RunConfig#dollarEscapedPlaceholders()} set to {@code true}, in which case it is left as written,
+ * backslash included.
*
*
Instructions can also be dynamically constructed using {@link Instruction.Provider}. This
* allows for more complex logic where the instruction text is generated based on the current {@link
diff --git a/core/src/main/java/com/google/adk/agents/RunConfig.java b/core/src/main/java/com/google/adk/agents/RunConfig.java
index 4023eaef4..d6d186418 100644
--- a/core/src/main/java/com/google/adk/agents/RunConfig.java
+++ b/core/src/main/java/com/google/adk/agents/RunConfig.java
@@ -88,6 +88,13 @@ public enum ToolExecutionMode {
public abstract boolean autoCreateSession();
+ /**
+ * Whether an instruction placeholder right after {@code $} or {@code \}, such as {@code ${key}},
+ * {@code ${{key}}} or {@code \{key}}, is left as written instead of being resolved, as adk-python
+ * does. Defaults to {@code false}, which resolves it and keeps the character before it.
+ */
+ public abstract boolean dollarEscapedPlaceholders();
+
/**
* Three-state override for grouping function calls before function responses in history (FC1,
* FC2, FR1, FR2) instead of pairing each response with its call (FC1, FR1, FC2, FR2).
@@ -129,6 +136,7 @@ public static Builder builder() {
.toolExecutionMode(ToolExecutionMode.NONE)
.maxLlmCalls(500)
.autoCreateSession(false)
+ .dollarEscapedPlaceholders(false)
.customMetadata(ImmutableMap.of());
}
@@ -145,6 +153,7 @@ public static Builder builder(RunConfig runConfig) {
.outputAudioTranscription(runConfig.outputAudioTranscription())
.inputAudioTranscription(runConfig.inputAudioTranscription())
.autoCreateSession(runConfig.autoCreateSession())
+ .dollarEscapedPlaceholders(runConfig.dollarEscapedPlaceholders())
.groupFunctionResponsesInHistoryOverride(
runConfig.groupFunctionResponsesInHistoryOverride())
.customMetadata(runConfig.customMetadata());
@@ -242,6 +251,9 @@ public final Builder setAutoCreateSession(boolean autoCreateSession) {
@CanIgnoreReturnValue
public abstract Builder autoCreateSession(boolean autoCreateSession);
+ @CanIgnoreReturnValue
+ public abstract Builder dollarEscapedPlaceholders(boolean dollarEscapedPlaceholders);
+
@CanIgnoreReturnValue
public abstract Builder customMetadata(Map customMetadata);
diff --git a/core/src/main/java/com/google/adk/utils/InstructionUtils.java b/core/src/main/java/com/google/adk/utils/InstructionUtils.java
index ff2a7b8bd..567783e98 100644
--- a/core/src/main/java/com/google/adk/utils/InstructionUtils.java
+++ b/core/src/main/java/com/google/adk/utils/InstructionUtils.java
@@ -17,6 +17,7 @@
package com.google.adk.utils;
import com.google.adk.agents.InvocationContext;
+import com.google.adk.agents.RunConfig;
import com.google.adk.sessions.Session;
import com.google.adk.sessions.State;
import com.google.common.collect.ImmutableSet;
@@ -35,6 +36,11 @@ public final class InstructionUtils {
private static final Pattern INSTRUCTION_PLACEHOLDER_PATTERN =
Pattern.compile("\\{+[^\\{\\}]*\\}+");
+ // Also skips braces preceded by '$', '{' or '\', so ${key}, ${{key}} and \{key} are left as
+ // written. The '{' keeps the inner braces of ${{key}} and \{{key}} from matching on their own.
+ private static final Pattern DOLLAR_ESCAPED_PLACEHOLDER_PATTERN =
+ Pattern.compile("(?If an optional placeholder cannot be resolved (e.g., variable not found, artifact
* not found), it is replaced with an empty string.
*
+ * Escaped Placeholders: A placeholder right after {@code $} or {@code \}, such as
+ * {@code ${key}}, {@code ${{key}}} or {@code \{key}}.
+ *
+ * - When {@link com.google.adk.agents.RunConfig#dollarEscapedPlaceholders()} is {@code
+ * true}, it is left as written, backslash included.
+ *
- When it is {@code false}, the default, it is resolved like any other placeholder
+ * and the {@code $} or {@code \} before it is kept, so {@code ${key}} with {@code
+ * key} set to {@code 9.99} becomes {@code $9.99}.
+ *
*
*
- * Example Usage:
+ * Example Usage:
*
*
{@code
* InvocationContext context = ...; // Assume this is initialized with session and artifact service
@@ -113,7 +128,12 @@ public static Single injectSessionState(InvocationContext context, Strin
if (context == null) {
return Single.error(new NullPointerException("context cannot be null"));
}
- Matcher matcher = INSTRUCTION_PLACEHOLDER_PATTERN.matcher(template);
+ RunConfig runConfig = context.runConfig();
+ Pattern placeholderPattern =
+ runConfig != null && runConfig.dollarEscapedPlaceholders()
+ ? DOLLAR_ESCAPED_PLACEHOLDER_PATTERN
+ : INSTRUCTION_PLACEHOLDER_PATTERN;
+ Matcher matcher = placeholderPattern.matcher(template);
List> parts = new ArrayList<>();
int lastEnd = 0;
diff --git a/core/src/test/java/com/google/adk/agents/RunConfigTest.java b/core/src/test/java/com/google/adk/agents/RunConfigTest.java
index fc6b9083f..c713a174d 100644
--- a/core/src/test/java/com/google/adk/agents/RunConfigTest.java
+++ b/core/src/test/java/com/google/adk/agents/RunConfigTest.java
@@ -72,10 +72,19 @@ public void testBuilderDefaults() {
assertThat(runConfig.inputAudioTranscription()).isNull();
assertThat(runConfig.maxLlmCalls()).isEqualTo(500);
assertThat(runConfig.autoCreateSession()).isFalse();
+ assertThat(runConfig.dollarEscapedPlaceholders()).isFalse();
assertThat(runConfig.groupFunctionResponsesInHistoryOverride()).isEmpty();
assertThat(runConfig.groupFunctionResponsesInHistory()).isFalse();
}
+ @Test
+ public void dollarEscapedPlaceholders_propagatedByCopy() {
+ RunConfig source = RunConfig.builder().dollarEscapedPlaceholders(true).build();
+
+ assertThat(RunConfig.builder(source).build().dollarEscapedPlaceholders()).isTrue();
+ assertThat(source.toBuilder().build().dollarEscapedPlaceholders()).isTrue();
+ }
+
@Test
public void groupFunctionResponsesInHistory_booleanSetter_setsOverrideAndBackwardCompatGetter() {
RunConfig enabled = RunConfig.builder().groupFunctionResponsesInHistory(true).build();
diff --git a/core/src/test/java/com/google/adk/flows/llmflows/InstructionsTest.java b/core/src/test/java/com/google/adk/flows/llmflows/InstructionsTest.java
index 90f710856..d32346766 100644
--- a/core/src/test/java/com/google/adk/flows/llmflows/InstructionsTest.java
+++ b/core/src/test/java/com/google/adk/flows/llmflows/InstructionsTest.java
@@ -25,6 +25,7 @@
import com.google.adk.agents.Instruction;
import com.google.adk.agents.InvocationContext;
import com.google.adk.agents.LlmAgent;
+import com.google.adk.agents.RunConfig;
import com.google.adk.artifacts.BaseArtifactService;
import com.google.adk.models.LlmRequest;
import com.google.adk.sessions.InMemorySessionService;
@@ -252,4 +253,86 @@ public void processRequest_agentInstructionAndGlobalInstruction_bothAreAppendedT
assertThat(result.updatedRequest().getSystemInstructions())
.containsExactly("Global instruction.\n\nAgent instruction.");
}
+
+ @Test
+ public void processRequest_dollarEscapedPlaceholders_keepsEscapedPlaceholders() {
+ Session session = createSession();
+ session.state().put("price", 9.99);
+ session.state().put("name", "TestBot");
+ LlmAgent agent =
+ LlmAgent.builder()
+ .name("agent")
+ .instruction("{name} shows ${price}, ${{price}} and \\{price}.")
+ .build();
+ InvocationContext context = dollarEscaped(createContext(agent, session));
+
+ RequestProcessor.RequestProcessingResult result =
+ instructionsProcessor.processRequest(context, initialRequest).blockingGet();
+
+ assertThat(result.updatedRequest().getSystemInstructions())
+ .containsExactly("TestBot shows ${price}, ${{price}} and \\{price}.");
+ }
+
+ @Test
+ public void processRequest_defaultRunConfig_resolvesEscapedPlaceholders() {
+ Session session = createSession();
+ session.state().put("price", 9.99);
+ session.state().put("name", "TestBot");
+ LlmAgent agent =
+ LlmAgent.builder()
+ .name("agent")
+ .instruction("{name} shows ${price}, ${{price}} and \\{price}.")
+ .build();
+ InvocationContext context = createContext(agent, session);
+
+ RequestProcessor.RequestProcessingResult result =
+ instructionsProcessor.processRequest(context, initialRequest).blockingGet();
+
+ assertThat(result.updatedRequest().getSystemInstructions())
+ .containsExactly("TestBot shows $9.99, $9.99 and \\9.99.");
+ }
+
+ @Test
+ public void processRequest_dollarEscapedPlaceholders_appliesToGlobalAndSubAgentInstructions() {
+ Session session = createSession();
+ session.state().put("price", 9.99);
+ LlmAgent subAgent = LlmAgent.builder().name("sub_agent").instruction("Sub: ${price}").build();
+ LlmAgent unused =
+ LlmAgent.builder()
+ .name("root_agent")
+ .globalInstruction("Global: ${price}")
+ .subAgents(subAgent)
+ .build();
+ InvocationContext context = dollarEscaped(createContext(subAgent, session));
+
+ RequestProcessor.RequestProcessingResult result =
+ instructionsProcessor.processRequest(context, initialRequest).blockingGet();
+
+ assertThat(result.updatedRequest().getSystemInstructions())
+ .containsExactly("Global: ${price}\n\nSub: ${price}");
+ }
+
+ @Test
+ public void processRequest_providerInstruction_isNotTemplatedWithDollarEscapedPlaceholders() {
+ Session session = createSession();
+ session.state().put("price", 9.99);
+ LlmAgent agent =
+ LlmAgent.builder()
+ .name("agent")
+ .instruction(new Instruction.Provider(unused -> Single.just("{price} and ${price}")))
+ .build();
+ InvocationContext context = dollarEscaped(createContext(agent, session));
+
+ RequestProcessor.RequestProcessingResult result =
+ instructionsProcessor.processRequest(context, initialRequest).blockingGet();
+
+ assertThat(result.updatedRequest().getSystemInstructions())
+ .containsExactly("{price} and ${price}");
+ }
+
+ private static InvocationContext dollarEscaped(InvocationContext context) {
+ return context.toBuilder()
+ .runConfig(RunConfig.builder().dollarEscapedPlaceholders(true).build())
+ .build();
+ }
}
diff --git a/core/src/test/java/com/google/adk/plugins/GlobalInstructionPluginTest.java b/core/src/test/java/com/google/adk/plugins/GlobalInstructionPluginTest.java
index 345314256..fa35404a0 100644
--- a/core/src/test/java/com/google/adk/plugins/GlobalInstructionPluginTest.java
+++ b/core/src/test/java/com/google/adk/plugins/GlobalInstructionPluginTest.java
@@ -20,6 +20,7 @@
import com.google.adk.agents.CallbackContext;
import com.google.adk.agents.InvocationContext;
+import com.google.adk.agents.RunConfig;
import com.google.adk.artifacts.BaseArtifactService;
import com.google.adk.models.LlmRequest;
import com.google.adk.sessions.Session;
@@ -115,6 +116,18 @@ public void beforeModelCallback_withStringInstruction_injectsState() {
assertThat(systemInstruction.role()).isEmpty();
}
+ @Test
+ public void beforeModelCallback_dollarEscapedPlaceholders_keepsEscapedPlaceholder() {
+ state.put("price", "9.99");
+ when(mockInvocationContext.runConfig())
+ .thenReturn(RunConfig.builder().dollarEscapedPlaceholders(true).build());
+ LlmRequest.Builder llmRequestBuilder = LlmRequest.builder();
+ GlobalInstructionPlugin plugin = new GlobalInstructionPlugin("Price: ${price}, {price}");
+ plugin.beforeModelCallback(mockCallbackContext, llmRequestBuilder).test().assertComplete();
+ Content systemInstruction = llmRequestBuilder.build().config().get().systemInstruction().get();
+ assertThat(systemInstruction.parts().get().get(0).text()).hasValue("Price: ${price}, 9.99");
+ }
+
@Test
public void beforeModelCallback_nullInstruction() {
LlmRequest.Builder llmRequestBuilder = LlmRequest.builder();
diff --git a/core/src/test/java/com/google/adk/utils/InstructionUtilsTest.java b/core/src/test/java/com/google/adk/utils/InstructionUtilsTest.java
index 4437b242d..f4c1f2369 100644
--- a/core/src/test/java/com/google/adk/utils/InstructionUtilsTest.java
+++ b/core/src/test/java/com/google/adk/utils/InstructionUtilsTest.java
@@ -21,6 +21,7 @@
import static org.junit.Assert.assertThrows;
import com.google.adk.agents.InvocationContext;
+import com.google.adk.agents.RunConfig;
import com.google.adk.artifacts.InMemoryArtifactService;
import com.google.adk.memory.InMemoryMemoryService;
import com.google.adk.sessions.InMemorySessionService;
@@ -220,4 +221,166 @@ public void injectSessionState_stateVariableWithInvalidPrefix_returnsPlaceholder
assertThat(result).isEqualTo(template);
}
+
+ @Test
+ public void
+ injectSessionState_dollarEscapedPlaceholders_dollarBracePattern_returnsTemplateAsIs() {
+ String template = "The formatString supports interpolation via ${expression} syntax.";
+
+ String result =
+ InstructionUtils.injectSessionState(dollarEscaped(templateContext), template).blockingGet();
+
+ assertThat(result).isEqualTo(template);
+ }
+
+ @Test
+ public void
+ injectSessionState_dollarEscapedPlaceholders_dollarBraceWithStateVariable_replacesOnlyStateVariable() {
+ InvocationContext testContext = templateContext.toBuilder().build();
+ testContext.session().state().put("user_name", "Foo");
+ String template = "Hello {user_name}! Interpolation via ${expression} syntax.";
+
+ String result =
+ InstructionUtils.injectSessionState(dollarEscaped(testContext), template).blockingGet();
+
+ assertThat(result).isEqualTo("Hello Foo! Interpolation via ${expression} syntax.");
+ }
+
+ @Test
+ public void
+ injectSessionState_dollarEscapedPlaceholders_escapedPlaceholder_returnsTemplateAsIs() {
+ InvocationContext testContext = templateContext.toBuilder().build();
+ testContext.session().state().put("user_name", "Foo");
+ String template = "Literal \\{user_name} syntax.";
+
+ String result =
+ InstructionUtils.injectSessionState(dollarEscaped(testContext), template).blockingGet();
+
+ assertThat(result).isEqualTo("Literal \\{user_name} syntax.");
+ }
+
+ @Test
+ public void
+ injectSessionState_dollarEscapedPlaceholders_dollarDoubleBracePattern_returnsPatternAsIs() {
+ InvocationContext testContext = templateContext.toBuilder().build();
+ testContext.session().state().put("expression", "foo");
+ testContext.session().state().put("user_name", "bar");
+ String template = "Workflow syntax: ${{expression}} and {user_name}.";
+
+ String result =
+ InstructionUtils.injectSessionState(dollarEscaped(testContext), template).blockingGet();
+
+ assertThat(result).isEqualTo("Workflow syntax: ${{expression}} and bar.");
+ }
+
+ @Test
+ public void
+ injectSessionState_dollarEscapedPlaceholders_escapedDoubleBracePattern_returnsPatternAsIs() {
+ InvocationContext testContext = templateContext.toBuilder().build();
+ testContext.session().state().put("user_name", "Foo");
+ String template = "Literal \\{{user_name}} syntax.";
+
+ String result =
+ InstructionUtils.injectSessionState(dollarEscaped(testContext), template).blockingGet();
+
+ assertThat(result).isEqualTo("Literal \\{{user_name}} syntax.");
+ }
+
+ @Test
+ public void
+ injectSessionState_dollarEscapedPlaceholders_dollarBraceWithStateValue_keepsPlaceholder() {
+ InvocationContext testContext = templateContext.toBuilder().build();
+ testContext.session().state().put("price", 9.99);
+ String template = "Price: ${price}";
+
+ String result =
+ InstructionUtils.injectSessionState(dollarEscaped(testContext), template).blockingGet();
+
+ assertThat(result).isEqualTo("Price: ${price}");
+ }
+
+ @Test
+ public void
+ injectSessionState_dollarEscapedPlaceholders_optionalDollarBraceMissing_keepsPlaceholder() {
+ String template = "A ${var?} B";
+
+ String result =
+ InstructionUtils.injectSessionState(dollarEscaped(templateContext), template).blockingGet();
+
+ assertThat(result).isEqualTo("A ${var?} B");
+ }
+
+ @Test
+ public void
+ injectSessionState_dollarEscapedPlaceholders_unescapedPlaceholders_areStillReplaced() {
+ InvocationContext testContext = templateContext.toBuilder().build();
+ testContext.session().state().put("user_name", "Foo");
+ testContext.session().state().put("price", 9.99);
+ String template = "{user_name} pays $ {price}, {{user_name}} and {missing?}.";
+
+ String result =
+ InstructionUtils.injectSessionState(dollarEscaped(testContext), template).blockingGet();
+
+ assertThat(result).isEqualTo("Foo pays $ 9.99, Foo and .");
+ }
+
+ @Test
+ public void injectSessionState_defaultRunConfig_dollarBracePattern_throws() {
+ String template = "The formatString supports interpolation via ${expression} syntax.";
+
+ IllegalArgumentException exception =
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> InstructionUtils.injectSessionState(templateContext, template).blockingGet());
+
+ assertThat(exception).hasMessageThat().isEqualTo("Context variable not found: `expression`.");
+ }
+
+ @Test
+ public void injectSessionState_defaultRunConfig_dollarDoubleBracePattern_keepsDollar() {
+ InvocationContext testContext = templateContext.toBuilder().build();
+ testContext.session().state().put("expression", "foo");
+ String template = "Workflow syntax: ${{expression}}.";
+
+ String result = InstructionUtils.injectSessionState(testContext, template).blockingGet();
+
+ assertThat(result).isEqualTo("Workflow syntax: $foo.");
+ }
+
+ @Test
+ public void injectSessionState_defaultRunConfig_dollarBraceWithStateValue_keepsDollar() {
+ InvocationContext testContext = templateContext.toBuilder().build();
+ testContext.session().state().put("price", 9.99);
+ String template = "Price: ${price}";
+
+ String result = InstructionUtils.injectSessionState(testContext, template).blockingGet();
+
+ assertThat(result).isEqualTo("Price: $9.99");
+ }
+
+ @Test
+ public void injectSessionState_defaultRunConfig_optionalDollarBraceMissing_keepsDollar() {
+ String template = "A ${var?} B";
+
+ String result = InstructionUtils.injectSessionState(templateContext, template).blockingGet();
+
+ assertThat(result).isEqualTo("A $ B");
+ }
+
+ @Test
+ public void injectSessionState_defaultRunConfig_escapedPlaceholder_keepsBackslash() {
+ InvocationContext testContext = templateContext.toBuilder().build();
+ testContext.session().state().put("user_name", "Foo");
+ String template = "Literal \\{user_name} syntax.";
+
+ String result = InstructionUtils.injectSessionState(testContext, template).blockingGet();
+
+ assertThat(result).isEqualTo("Literal \\Foo syntax.");
+ }
+
+ private static InvocationContext dollarEscaped(InvocationContext context) {
+ return context.toBuilder()
+ .runConfig(RunConfig.builder().dollarEscapedPlaceholders(true).build())
+ .build();
+ }
}
diff --git a/tokt/src/main/kotlin/com/google/adk/tokt/codecs/RunConfigCodec.kt b/tokt/src/main/kotlin/com/google/adk/tokt/codecs/RunConfigCodec.kt
index dfe1bf825..b072c611a 100644
--- a/tokt/src/main/kotlin/com/google/adk/tokt/codecs/RunConfigCodec.kt
+++ b/tokt/src/main/kotlin/com/google/adk/tokt/codecs/RunConfigCodec.kt
@@ -58,6 +58,7 @@ internal object RunConfigCodec {
if (config.inputAudioTranscription() != null) add("inputAudioTranscription")
if (config.groupFunctionResponsesInHistoryOverride().isPresent)
add("groupFunctionResponsesInHistoryOverride")
+ if (config.dollarEscapedPlaceholders()) add("dollarEscapedPlaceholders")
}
require(unsupported.isEmpty()) {
"RunConfig settings not supported by the ADK Kotlin engine: $unsupported"
diff --git a/tokt/src/test/kotlin/com/google/adk/tokt/KtRunnerInteropTest.kt b/tokt/src/test/kotlin/com/google/adk/tokt/KtRunnerInteropTest.kt
index b69dd387d..3bf99b4c6 100644
--- a/tokt/src/test/kotlin/com/google/adk/tokt/KtRunnerInteropTest.kt
+++ b/tokt/src/test/kotlin/com/google/adk/tokt/KtRunnerInteropTest.kt
@@ -4775,6 +4775,18 @@ class KtRunnerInteropTest {
)
}
+ @Test
+ fun runConfigFromJava_dollarEscapedPlaceholders_failsRatherThanDroppingIt() {
+ val java = JavaRunConfig.builder().dollarEscapedPlaceholders(true).build()
+
+ val failure = assertFailsWith { RunConfigCodec.fromJava(java) }
+
+ assertTrue(
+ failure.message?.contains("dollarEscapedPlaceholders") == true,
+ "the failure should name dollarEscapedPlaceholders, got ${failure.message}",
+ )
+ }
+
@Test
fun runConfigFromJava_bidiStreaming_failsRatherThanDowngradingToNone() {
val java = JavaRunConfig.builder().streamingMode(JavaRunConfig.StreamingMode.BIDI).build()