Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion core/src/main/java/com/google/adk/agents/Instruction.java
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
* <p>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
Expand Down
12 changes: 12 additions & 0 deletions core/src/main/java/com/google/adk/agents/RunConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -129,6 +136,7 @@ public static Builder builder() {
.toolExecutionMode(ToolExecutionMode.NONE)
.maxLlmCalls(500)
.autoCreateSession(false)
.dollarEscapedPlaceholders(false)
.customMetadata(ImmutableMap.of());
}

Expand All @@ -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());
Expand Down Expand Up @@ -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<String, Object> customMetadata);

Expand Down
24 changes: 22 additions & 2 deletions core/src/main/java/com/google/adk/utils/InstructionUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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("(?<![\\$\\{\\\\])\\{+[^\\{\\}]*\\}+");

private InstructionUtils() {}

/**
Expand Down Expand Up @@ -74,9 +80,18 @@ private InstructionUtils() {}
* <li>If an optional placeholder cannot be resolved (e.g., variable not found, artifact
* not found), it is replaced with an empty string.
* </ul>
* <li><b>Escaped Placeholders:</b> A placeholder right after {@code $} or {@code \}, such as
* {@code ${key}}, {@code ${{key}}} or {@code \{key}}.
* <ul>
* <li>When {@link com.google.adk.agents.RunConfig#dollarEscapedPlaceholders()} is {@code
* true}, it is left as written, backslash included.
* <li>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}.
* </ul>
* </ul>
*
* <b>Example Usage:</b>
* <p><b>Example Usage:</b>
*
* <pre>{@code
* InvocationContext context = ...; // Assume this is initialized with session and artifact service
Expand Down Expand Up @@ -113,7 +128,12 @@ public static Single<String> 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<Single<String>> parts = new ArrayList<>();
int lastEnd = 0;

Expand Down
9 changes: 9 additions & 0 deletions core/src/test/java/com/google/adk/agents/RunConfigTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down
Loading
Loading