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 64bb793d7..c769544b3 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 146a4e92f..c2800207f 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();
}
@@ -1309,6 +1313,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()