diff --git a/core/src/main/java/com/google/adk/flows/llmflows/Contents.java b/core/src/main/java/com/google/adk/flows/llmflows/Contents.java index f0bfcd09a..a772accb4 100644 --- a/core/src/main/java/com/google/adk/flows/llmflows/Contents.java +++ b/core/src/main/java/com/google/adk/flows/llmflows/Contents.java @@ -410,8 +410,13 @@ private static boolean isOtherAgentReply(String agentName, Event event) { /** * Converts an {@code event} authored by another agent to a 'contextual-only' event. * - *
Returns {@code null} when nothing but the "For context:" preamble survives the conversion, - * so the caller drops the event instead of sending a preamble with no context after it. + *
Returns {@code null} when nothing but the preamble survives the conversion, so the caller + * drops the event instead of sending a preamble with no context after it. + * + *
The relayed text is attacker-reachable: whoever talks to the other agent steers what it
+ * says, and its tool results carry whatever the tool read. Each relayed text payload is therefore
+ * fenced (see {@link Fencing}), and the leading part states that fenced content is data, so a
+ * payload has to be believed rather than merely obeyed.
*/
private static @Nullable Event convertForeignEvent(Event event) {
if (event.content().isEmpty()
@@ -421,7 +426,7 @@ private static boolean isOtherAgentReply(String agentName, Event event) {
}
List Some of what a request carries is attacker-reachable: another agent's turn, a tool result,
+ * anything a model was talked into emitting. It travels on the same text channel the real user
+ * speaks on, so text posing as a directive is otherwise indistinguishable from one.
+ *
+ * Fencing marks where such a payload starts and ends and says, in the message itself, that what
+ * sits between the markers is data to read and not instructions to follow. This raises the bar
+ * rather than closing the class: a model can still be talked round by text it was told to distrust.
+ * What it removes is the structural ambiguity.
+ *
+ * Ported from adk-python's flows/llm_flows/_fencing.py.
+ */
+final class Fencing {
+
+ static final String QUOTED_CONTENT_BEGIN = "<< Markers inside the text are elided first, so quoted content cannot forge the end of its own
+ * block and carry on speaking as the framework.
+ */
+ static String quoteUntrusted(String text) {
+ return QUOTED_CONTENT_BEGIN + "\n" + elideQuoteMarkers(text) + "\n" + QUOTED_CONTENT_END;
+ }
+}
diff --git a/core/src/test/java/com/google/adk/flows/llmflows/ContentsTest.java b/core/src/test/java/com/google/adk/flows/llmflows/ContentsTest.java
index ce7655333..5597ce8f2 100644
--- a/core/src/test/java/com/google/adk/flows/llmflows/ContentsTest.java
+++ b/core/src/test/java/com/google/adk/flows/llmflows/ContentsTest.java
@@ -427,14 +427,15 @@ public void convertForeignEvent_eventsFromOtherAgents_returnsContextualOnlyEvent
.containsExactly(
u1.content().get(),
Content.fromParts(
- Part.fromText("For context:"),
- Part.fromText("[other_agent] said: Some text"),
- Part.fromText(
- "[other_agent] called tool `tool1` with parameters: "
- + "{\"arg1\":\"value\",\"arg2\":[1,2]}")),
+ otherAgentPreamblePart(),
+ otherAgentPart("[other_agent] said:", "Some text"),
+ otherAgentPart(
+ "[other_agent] called tool `tool1` with parameters:",
+ "{\"arg1\":\"value\",\"arg2\":[1,2]}")),
Content.fromParts(
- Part.fromText("For context:"),
- Part.fromText("[other_agent] `tool1` tool returned result: {\"result\":\"ok\"}")),
+ otherAgentPreamblePart(),
+ otherAgentPart(
+ "[other_agent] `tool1` tool returned result:", "{\"result\":\"ok\"}")),
a1.content().get(),
fr2.content().get())
.inOrder();
@@ -464,8 +465,8 @@ public void processRequest_includeContentsNone_lastEventIsOtherAgent() {
assertThat(result)
.containsExactly(
Content.fromParts(
- Part.fromText("For context:"),
- Part.fromText("[other_agent] said: Other Agent Turn")));
+ otherAgentPreamblePart(),
+ otherAgentPart("[other_agent] said:", "Other Agent Turn")));
}
@Test
@@ -1201,7 +1202,9 @@ public void processRequest_thoughtTextFromOtherAgent_isNotNarrated() {
contents.get(1).parts().get().stream()
.map(part -> part.text().orElse(""))
.collect(toImmutableList()))
- .containsExactly("For context:", "[" + OTHER_AGENT + "] said: It is in Paris.");
+ .containsExactly(
+ Fencing.OTHER_AGENT_CONTEXT_PREAMBLE,
+ "[" + OTHER_AGENT + "] said:\n" + Fencing.quoteUntrusted("It is in Paris."));
}
// The other-agent path still narrates what it can: media parts pass through unchanged, so the
@@ -1234,7 +1237,8 @@ public void processRequest_mediaPartFromOtherAgent_isKept() {
assertThat(contents).hasSize(2);
assertThat(contents.get(1).parts().get()).hasSize(2);
- assertThat(contents.get(1).parts().get().get(0).text()).hasValue("For context:");
+ assertThat(contents.get(1).parts().get().get(0).text())
+ .hasValue(Fencing.OTHER_AGENT_CONTEXT_PREAMBLE);
assertThat(contents.get(1).parts().get().get(1).inlineData()).isPresent();
}
@@ -1352,7 +1356,7 @@ public void processRequest_noInvocationBranch_includesBranchedEvent() {
assertThat(result)
.containsExactly(
Content.fromParts(
- Part.fromText("For context:"), Part.fromText("[agent_1] said: sibling output")));
+ otherAgentPreamblePart(), otherAgentPart("[agent_1] said:", "sibling output")));
}
private static Event createUserEvent(String id, String text) {
@@ -1364,6 +1368,14 @@ private static Event createUserEvent(String id, String text) {
.build();
}
+ private static Part otherAgentPreamblePart() {
+ return Part.fromText(Fencing.OTHER_AGENT_CONTEXT_PREAMBLE);
+ }
+
+ private static Part otherAgentPart(String attribution, String payload) {
+ return Part.fromText(attribution + "\n" + Fencing.quoteUntrusted(payload));
+ }
+
private static Event createUserEvent(
String id, String text, String invocationId, long timestamp) {
return Event.builder()
diff --git a/core/src/test/java/com/google/adk/flows/llmflows/FencingTest.java b/core/src/test/java/com/google/adk/flows/llmflows/FencingTest.java
new file mode 100644
index 000000000..fbfc60bd6
--- /dev/null
+++ b/core/src/test/java/com/google/adk/flows/llmflows/FencingTest.java
@@ -0,0 +1,150 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.adk.flows.llmflows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/**
+ * Unit tests for {@link Fencing}, in particular {@link Fencing#elideQuoteMarkers}, which the
+ * existing fixtures in {@link ContentsTest} exercise only indirectly through content that never
+ * contains a fence marker of its own. These tests cover the elision behavior directly: relayed
+ * content that contains a literal {@code <<