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 0c415f1a..5730d979 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
@@ -38,7 +38,6 @@
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
-import java.util.ListIterator;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
@@ -175,9 +174,18 @@ private boolean isEmptyContent(Event event) {
/**
* Filters events that are covered by compaction events by identifying compacted ranges and
- * filters out events that are covered by compaction summaries
+ * filters out events that are covered by compaction summaries. Also filters out redundant
+ * compaction events (i.e., those fully covered by a later compaction event).
*
- *
Example of input
+ *
Compaction events are inserted into the stream relative to the events they cover.
+ * Specifically, a compaction event is placed immediately before the first retained event that
+ * follows the compaction range (or at the end of the covered range if no events are retained).
+ * This ensures a logical flow of "Summary of History" -> "Recent/Retained Events".
+ *
+ *
Case 1: Sliding Window + Retention
+ *
+ *
Compaction events have some overlap but do not fully cover each other. Therefore, all
+ * compaction events are preserved, as well as the final retained events.
*
*
* [
@@ -185,7 +193,7 @@ private boolean isEmptyContent(Event event) {
* event_2(timestamp=2),
* compaction_1(event_1, event_2, timestamp=3, content=summary_1_2, startTime=1, endTime=2),
* event_3(timestamp=4),
- * compaction_2(event_2, event_3, timestamp=5, content=summary_2_3, startTime=2, endTime=3),
+ * compaction_2(event_2, event_3, timestamp=5, content=summary_2_3, startTime=2, endTime=4),
* event_4(timestamp=6)
* ]
*
@@ -200,50 +208,123 @@ private boolean isEmptyContent(Event event) {
* ]
*
*
- * Compaction events are always strictly in order based on event timestamp.
+ * Case 2: Rolling Summary + Retention
+ *
+ *
The newer compaction event fully covers the older one. Therefore, the older compaction event
+ * is removed, leaving only the latest summary and the final retained events.
+ *
+ *
+ * [
+ * event_1(timestamp=1),
+ * event_2(timestamp=2),
+ * event_3(timestamp=3),
+ * event_4(timestamp=4),
+ * compaction_1(event_1, timestamp=5, content=summary_1, startTime=1, endTime=1),
+ * event_6(timestamp=6),
+ * event_7(timestamp=7),
+ * compaction_2(compaction_1, event_2, event_3, timestamp=8, content=summary_1_3, startTime=1, endTime=3),
+ * event_9(timestamp=9)
+ * ]
+ *
+ *
+ * Will result in the following events output
+ *
+ *
+ * [
+ * compaction_2,
+ * event_4,
+ * event_6,
+ * event_7,
+ * event_9
+ * ]
+ *
*
* @param events the list of event to filter.
* @return a new list with compaction applied.
*/
private List processCompactionEvent(List events) {
+ // Step 1: Split events into compaction events and regular events.
+ List compactionEvents = new ArrayList<>();
+ List regularEvents = new ArrayList<>();
+ for (Event event : events) {
+ if (event.actions().compaction().isPresent()) {
+ compactionEvents.add(event);
+ } else {
+ regularEvents.add(event);
+ }
+ }
+
+ // Step 2: Remove redundant compaction events (overlapping ones).
+ compactionEvents = removeOverlappingCompactions(compactionEvents);
+
+ // Step 3: Merge regular events and compaction events based on timestamps.
+ // We iterate backwards from the latest to the earliest event.
List result = new ArrayList<>();
- ListIterator iter = events.listIterator(events.size());
- Long lastCompactionStartTime = null;
- Long lastCompactionEndTime = null;
-
- while (iter.hasPrevious()) {
- Event event = iter.previous();
- EventCompaction compaction = event.actions().compaction().orElse(null);
- if (compaction == null) {
- if (lastCompactionStartTime == null
- || event.timestamp() < lastCompactionStartTime
- || (lastCompactionEndTime != null && event.timestamp() > lastCompactionEndTime)) {
- result.add(event);
- }
- continue;
+ int c = compactionEvents.size() - 1;
+ int e = regularEvents.size() - 1;
+ while (e >= 0 && c >= 0) {
+ Event event = regularEvents.get(e);
+ EventCompaction compaction = compactionEvents.get(c).actions().compaction().get();
+
+ if (event.timestamp() >= compaction.startTimestamp()
+ && event.timestamp() <= compaction.endTimestamp()) {
+ // If the event is covered by compaction, skip it.
+ e--;
+ } else if (event.timestamp() > compaction.endTimestamp()) {
+ // If the event is after compaction, keep it.
+ result.add(event);
+ e--;
+ } else {
+ // Otherwise the event is before the compaction, let's move to the next compaction event;
+ result.add(createCompactionEvent(compactionEvents.get(c)));
+ c--;
}
- // Create a new event for the compaction event in the result.
- result.add(
- Event.builder()
- .timestamp(compaction.endTimestamp())
- .author("model")
- .content(compaction.compactedContent())
- .branch(event.branch())
- .invocationId(event.invocationId())
- .actions(event.actions())
- .build());
- lastCompactionStartTime =
- lastCompactionStartTime == null
- ? compaction.startTimestamp()
- : Long.min(lastCompactionStartTime, compaction.startTimestamp());
- lastCompactionEndTime =
- lastCompactionEndTime == null
- ? compaction.endTimestamp()
- : Long.max(lastCompactionEndTime, compaction.endTimestamp());
+ }
+ // Flush any remaining compactions.
+ while (c >= 0) {
+ result.add(createCompactionEvent(compactionEvents.get(c)));
+ c--;
+ }
+ // Flush any remaining regular events.
+ while (e >= 0) {
+ result.add(regularEvents.get(e));
+ e--;
}
return Lists.reverse(result);
}
+ private static List removeOverlappingCompactions(List events) {
+ List result = new ArrayList<>();
+ // Iterate backwards to prioritize later compactions
+ for (int i = events.size() - 1; i >= 0; i--) {
+ Event current = events.get(i);
+ EventCompaction c = current.actions().compaction().get();
+
+ // Check if this compaction is covered by any later compaction we've already kept
+ boolean covered =
+ result.stream()
+ .map(e -> e.actions().compaction().get())
+ .anyMatch(
+ k ->
+ c.startTimestamp() >= k.startTimestamp()
+ && c.endTimestamp() <= k.endTimestamp());
+
+ if (!covered) {
+ result.add(current);
+ }
+ }
+ return Lists.reverse(result);
+ }
+
+ private static Event createCompactionEvent(Event event) {
+ EventCompaction compaction = event.actions().compaction().get();
+ return event.toBuilder()
+ .timestamp(compaction.endTimestamp())
+ .author("model")
+ .content(compaction.compactedContent())
+ .build();
+ }
+
/** Whether the event is a reply from another agent. */
private static boolean isOtherAgentReply(String agentName, Event event) {
return !agentName.isEmpty()
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 85895088..6d706dc3 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
@@ -577,6 +577,102 @@ public void processRequest_compactionWithUncompactedEventsBetween() {
.containsExactly("content 3", "Summary 1-2");
}
+ @Test
+ public void processRequest_rollingSummary_removesRedundancy() {
+ // Input: [E1(1), C1(Cover 1-1), E3(3), C2(Cover 1-3)]
+ // Expected: [C2] (C1 is redundant, E1/E3 are covered).
+ ImmutableList events =
+ ImmutableList.of(
+ createUserEvent("e1", "E1", "inv1", 1),
+ createCompactedEvent(1, 1, "C1"),
+ createUserEvent("e3", "E3", "inv3", 3),
+ createCompactedEvent(1, 3, "C2"));
+
+ List contents = runContentsProcessor(events);
+ assertThat(contents)
+ .comparingElementsUsing(
+ transforming((Content c) -> c.parts().get().get(0).text().get(), "content text"))
+ .containsExactly("C2");
+ }
+
+ @Test
+ public void processRequest_rollingSummaryWithRetention() {
+ // Input: with retention size 3: [E1, E2, E3, E4, C1(Cover 1-1), E6, E7, C2(Cover 1-3), E9]
+ // Expected: [C2, E4, E6, E7, E9]
+ ImmutableList events =
+ ImmutableList.of(
+ createUserEvent("e1", "E1", "inv1", 1),
+ createUserEvent("e2", "E2", "inv2", 2),
+ createUserEvent("e3", "E3", "inv3", 3),
+ createUserEvent("e4", "E4", "inv4", 4),
+ createCompactedEvent(1, 1, "C1"),
+ createUserEvent("e6", "E6", "inv6", 6),
+ createUserEvent("e7", "E7", "inv7", 7),
+ createCompactedEvent(1, 3, "C2"),
+ createUserEvent("e9", "E9", "inv9", 9));
+
+ List contents = runContentsProcessor(events);
+ assertThat(contents)
+ .comparingElementsUsing(
+ transforming((Content c) -> c.parts().get().get(0).text().get(), "content text"))
+ .containsExactly("C2", "E4", "E6", "E7", "E9");
+ }
+
+ @Test
+ public void processRequest_rollingSummary_preservesUncoveredHistory() {
+ // Input: [E1(1), E2(2), E3(3), E4(4), C1(2-2), E6(6), E7(7), C2(2-3), E9(9)]
+ // Expected: [E1, C2, E4, E6, E7, E9]
+ // E1 is before C1/C2 range, so it is preserved.
+ // C1 (2-2) is covered by C2 (2-3), so C1 is removed.
+ // E2, E3 are covered by C2.
+ // E4, E6, E7, E9 are retained.
+ ImmutableList events =
+ ImmutableList.of(
+ createUserEvent("e1", "E1", "inv1", 1),
+ createUserEvent("e2", "E2", "inv2", 2),
+ createUserEvent("e3", "E3", "inv3", 3),
+ createUserEvent("e4", "E4", "inv4", 4),
+ createCompactedEvent(2, 2, "C1"),
+ createUserEvent("e6", "E6", "inv6", 6),
+ createUserEvent("e7", "E7", "inv7", 7),
+ createCompactedEvent(2, 3, "C2"),
+ createUserEvent("e9", "E9", "inv9", 9));
+
+ List contents = runContentsProcessor(events);
+ assertThat(contents)
+ .comparingElementsUsing(
+ transforming((Content c) -> c.parts().get().get(0).text().get(), "content text"))
+ .containsExactly("E1", "C2", "E4", "E6", "E7", "E9");
+ }
+
+ @Test
+ public void processRequest_slidingWindow_preservesOverlappingCompactions() {
+ // Case 1: Sliding Window + Retention
+ // Input: [E1(1), E2(2), E3(3), C1(1-2), E4(5), C2(2-3), E5(7)]
+ // Overlap: C1 and C2 overlap at 2. C1 is NOT redundant (start 1 < start 2).
+ // Expected: [C1, C2, E4, E5]
+ // E1(1) covered by C1.
+ // E2(2) covered by C1 (and C2).
+ // E3(3) covered by C2.
+ // E4(5) retained.
+ // E5(7) retained.
+ ImmutableList events =
+ ImmutableList.of(
+ createUserEvent("e1", "E1", "inv1", 1),
+ createUserEvent("e2", "E2", "inv2", 2),
+ createUserEvent("e3", "E3", "inv3", 3),
+ createCompactedEvent(1, 2, "C1"),
+ createUserEvent("e4", "E4", "inv4", 5),
+ createCompactedEvent(2, 3, "C2"),
+ createUserEvent("e5", "E5", "inv5", 7));
+
+ List contents = runContentsProcessor(events);
+ assertThat(contents)
+ .comparingElementsUsing(
+ transforming((Content c) -> c.parts().get().get(0).text().get(), "content text"))
+ .containsExactly("C1", "C2", "E4", "E5");
+ }
+
private static Event createUserEvent(String id, String text) {
return Event.builder()
.id(id)