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}}. + * * * - * 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()