Skip to content

Commit f5a7529

Browse files
google-genai-botcopybara-github
authored andcommitted
feat: Reorder compaction events in chronological order
PiperOrigin-RevId: 862589626
1 parent efe58d6 commit f5a7529

File tree

2 files changed

+214
-37
lines changed

2 files changed

+214
-37
lines changed

core/src/main/java/com/google/adk/flows/llmflows/Contents.java

Lines changed: 118 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,6 @@
3838
import java.util.HashMap;
3939
import java.util.HashSet;
4040
import java.util.List;
41-
import java.util.ListIterator;
4241
import java.util.Map;
4342
import java.util.Optional;
4443
import java.util.Set;
@@ -175,17 +174,26 @@ private boolean isEmptyContent(Event event) {
175174

176175
/**
177176
* Filters events that are covered by compaction events by identifying compacted ranges and
178-
* filters out events that are covered by compaction summaries
177+
* filters out events that are covered by compaction summaries. Also filters out redundant
178+
* compaction events (i.e., those fully covered by a later compaction event).
179179
*
180-
* <p>Example of input
180+
* <p>Compaction events are inserted into the stream relative to the events they cover.
181+
* Specifically, a compaction event is placed immediately before the first retained event that
182+
* follows the compaction range (or at the end of the covered range if no events are retained).
183+
* This ensures a logical flow of "Summary of History" -> "Recent/Retained Events".
184+
*
185+
* <p><b>Case 1: Sliding Window + Retention</b>
186+
*
187+
* <p>Compaction events have some overlap but do not fully cover each other. Therefore, all
188+
* compaction events are preserved, as well as the final retained events.
181189
*
182190
* <pre>
183191
* [
184192
* event_1(timestamp=1),
185193
* event_2(timestamp=2),
186194
* compaction_1(event_1, event_2, timestamp=3, content=summary_1_2, startTime=1, endTime=2),
187195
* event_3(timestamp=4),
188-
* compaction_2(event_2, event_3, timestamp=5, content=summary_2_3, startTime=2, endTime=3),
196+
* compaction_2(event_2, event_3, timestamp=5, content=summary_2_3, startTime=2, endTime=4),
189197
* event_4(timestamp=6)
190198
* ]
191199
* </pre>
@@ -200,50 +208,123 @@ private boolean isEmptyContent(Event event) {
200208
* ]
201209
* </pre>
202210
*
203-
* Compaction events are always strictly in order based on event timestamp.
211+
* <p><b>Case 2: Rolling Summary + Retention</b>
212+
*
213+
* <p>The newer compaction event fully covers the older one. Therefore, the older compaction event
214+
* is removed, leaving only the latest summary and the final retained events.
215+
*
216+
* <pre>
217+
* [
218+
* event_1(timestamp=1),
219+
* event_2(timestamp=2),
220+
* event_3(timestamp=3),
221+
* event_4(timestamp=4),
222+
* compaction_1(event_1, timestamp=5, content=summary_1, startTime=1, endTime=1),
223+
* event_6(timestamp=6),
224+
* event_7(timestamp=7),
225+
* compaction_2(compaction_1, event_2, event_3, timestamp=8, content=summary_1_3, startTime=1, endTime=3),
226+
* event_9(timestamp=9)
227+
* ]
228+
* </pre>
229+
*
230+
* Will result in the following events output
231+
*
232+
* <pre>
233+
* [
234+
* compaction_2,
235+
* event_4,
236+
* event_6,
237+
* event_7,
238+
* event_9
239+
* ]
240+
* </pre>
204241
*
205242
* @param events the list of event to filter.
206243
* @return a new list with compaction applied.
207244
*/
208245
private List<Event> processCompactionEvent(List<Event> events) {
246+
// Step 1: Split events into compaction events and regular events.
247+
List<Event> compactionEvents = new ArrayList<>();
248+
List<Event> regularEvents = new ArrayList<>();
249+
for (Event event : events) {
250+
if (event.actions().compaction().isPresent()) {
251+
compactionEvents.add(event);
252+
} else {
253+
regularEvents.add(event);
254+
}
255+
}
256+
257+
// Step 2: Remove redundant compaction events (overlapping ones).
258+
compactionEvents = removeOverlappingCompactions(compactionEvents);
259+
260+
// Step 3: Merge regular events and compaction events based on timestamps.
261+
// We iterate backwards from the latest to the earliest event.
209262
List<Event> result = new ArrayList<>();
210-
ListIterator<Event> iter = events.listIterator(events.size());
211-
Long lastCompactionStartTime = null;
212-
Long lastCompactionEndTime = null;
213-
214-
while (iter.hasPrevious()) {
215-
Event event = iter.previous();
216-
EventCompaction compaction = event.actions().compaction().orElse(null);
217-
if (compaction == null) {
218-
if (lastCompactionStartTime == null
219-
|| event.timestamp() < lastCompactionStartTime
220-
|| (lastCompactionEndTime != null && event.timestamp() > lastCompactionEndTime)) {
221-
result.add(event);
222-
}
223-
continue;
263+
int c = compactionEvents.size() - 1;
264+
int e = regularEvents.size() - 1;
265+
while (e >= 0 && c >= 0) {
266+
Event event = regularEvents.get(e);
267+
EventCompaction compaction = compactionEvents.get(c).actions().compaction().get();
268+
269+
if (event.timestamp() >= compaction.startTimestamp()
270+
&& event.timestamp() <= compaction.endTimestamp()) {
271+
// If the event is covered by compaction, skip it.
272+
e--;
273+
} else if (event.timestamp() > compaction.endTimestamp()) {
274+
// If the event is after compaction, keep it.
275+
result.add(event);
276+
e--;
277+
} else {
278+
// Otherwise the event is before the compaction, let's move to the next compaction event;
279+
result.add(createCompactionEvent(compactionEvents.get(c)));
280+
c--;
281+
}
282+
}
283+
// Flush any remaining compactions.
284+
while (c >= 0) {
285+
result.add(createCompactionEvent(compactionEvents.get(c)));
286+
c--;
287+
}
288+
// Flush any remaining regular events.
289+
while (e >= 0) {
290+
result.add(regularEvents.get(e));
291+
e--;
292+
}
293+
return Lists.reverse(result);
294+
}
295+
296+
private static List<Event> removeOverlappingCompactions(List<Event> events) {
297+
List<Event> result = new ArrayList<>();
298+
// Iterate backwards to prioritize later compactions
299+
for (int i = events.size() - 1; i >= 0; i--) {
300+
Event current = events.get(i);
301+
EventCompaction c = current.actions().compaction().get();
302+
303+
// Check if this compaction is covered by the last compaction we've already kept.
304+
boolean covered = false;
305+
if (!result.isEmpty()) {
306+
EventCompaction lastKept = Iterables.getLast(result).actions().compaction().get();
307+
covered =
308+
c.startTimestamp() >= lastKept.startTimestamp()
309+
&& c.endTimestamp() <= lastKept.endTimestamp();
310+
}
311+
312+
if (!covered) {
313+
result.add(current);
224314
}
225-
// Create a new event for the compaction event in the result.
226-
result.add(
227-
Event.builder()
228-
.timestamp(compaction.endTimestamp())
229-
.author("model")
230-
.content(compaction.compactedContent())
231-
.branch(event.branch())
232-
.invocationId(event.invocationId())
233-
.actions(event.actions())
234-
.build());
235-
lastCompactionStartTime =
236-
lastCompactionStartTime == null
237-
? compaction.startTimestamp()
238-
: Long.min(lastCompactionStartTime, compaction.startTimestamp());
239-
lastCompactionEndTime =
240-
lastCompactionEndTime == null
241-
? compaction.endTimestamp()
242-
: Long.max(lastCompactionEndTime, compaction.endTimestamp());
243315
}
244316
return Lists.reverse(result);
245317
}
246318

319+
private static Event createCompactionEvent(Event event) {
320+
EventCompaction compaction = event.actions().compaction().get();
321+
return event.toBuilder()
322+
.timestamp(compaction.endTimestamp())
323+
.author("model")
324+
.content(compaction.compactedContent())
325+
.build();
326+
}
327+
247328
/** Whether the event is a reply from another agent. */
248329
private static boolean isOtherAgentReply(String agentName, Event event) {
249330
return !agentName.isEmpty()

core/src/test/java/com/google/adk/flows/llmflows/ContentsTest.java

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -577,6 +577,102 @@ public void processRequest_compactionWithUncompactedEventsBetween() {
577577
.containsExactly("content 3", "Summary 1-2");
578578
}
579579

580+
@Test
581+
public void processRequest_rollingSummary_removesRedundancy() {
582+
// Input: [E1(1), C1(Cover 1-1), E3(3), C2(Cover 1-3)]
583+
// Expected: [C2] (C1 is redundant, E1/E3 are covered).
584+
ImmutableList<Event> events =
585+
ImmutableList.of(
586+
createUserEvent("e1", "E1", "inv1", 1),
587+
createCompactedEvent(1, 1, "C1"),
588+
createUserEvent("e3", "E3", "inv3", 3),
589+
createCompactedEvent(1, 3, "C2"));
590+
591+
List<Content> contents = runContentsProcessor(events);
592+
assertThat(contents)
593+
.comparingElementsUsing(
594+
transforming((Content c) -> c.parts().get().get(0).text().get(), "content text"))
595+
.containsExactly("C2");
596+
}
597+
598+
@Test
599+
public void processRequest_rollingSummaryWithRetention() {
600+
// Input: with retention size 3: [E1, E2, E3, E4, C1(Cover 1-1), E6, E7, C2(Cover 1-3), E9]
601+
// Expected: [C2, E4, E6, E7, E9]
602+
ImmutableList<Event> events =
603+
ImmutableList.of(
604+
createUserEvent("e1", "E1", "inv1", 1),
605+
createUserEvent("e2", "E2", "inv2", 2),
606+
createUserEvent("e3", "E3", "inv3", 3),
607+
createUserEvent("e4", "E4", "inv4", 4),
608+
createCompactedEvent(1, 1, "C1"),
609+
createUserEvent("e6", "E6", "inv6", 6),
610+
createUserEvent("e7", "E7", "inv7", 7),
611+
createCompactedEvent(1, 3, "C2"),
612+
createUserEvent("e9", "E9", "inv9", 9));
613+
614+
List<Content> contents = runContentsProcessor(events);
615+
assertThat(contents)
616+
.comparingElementsUsing(
617+
transforming((Content c) -> c.parts().get().get(0).text().get(), "content text"))
618+
.containsExactly("C2", "E4", "E6", "E7", "E9");
619+
}
620+
621+
@Test
622+
public void processRequest_rollingSummary_preservesUncoveredHistory() {
623+
// Input: [E1(1), E2(2), E3(3), E4(4), C1(2-2), E6(6), E7(7), C2(2-3), E9(9)]
624+
// Expected: [E1, C2, E4, E6, E7, E9]
625+
// E1 is before C1/C2 range, so it is preserved.
626+
// C1 (2-2) is covered by C2 (2-3), so C1 is removed.
627+
// E2, E3 are covered by C2.
628+
// E4, E6, E7, E9 are retained.
629+
ImmutableList<Event> events =
630+
ImmutableList.of(
631+
createUserEvent("e1", "E1", "inv1", 1),
632+
createUserEvent("e2", "E2", "inv2", 2),
633+
createUserEvent("e3", "E3", "inv3", 3),
634+
createUserEvent("e4", "E4", "inv4", 4),
635+
createCompactedEvent(2, 2, "C1"),
636+
createUserEvent("e6", "E6", "inv6", 6),
637+
createUserEvent("e7", "E7", "inv7", 7),
638+
createCompactedEvent(2, 3, "C2"),
639+
createUserEvent("e9", "E9", "inv9", 9));
640+
641+
List<Content> contents = runContentsProcessor(events);
642+
assertThat(contents)
643+
.comparingElementsUsing(
644+
transforming((Content c) -> c.parts().get().get(0).text().get(), "content text"))
645+
.containsExactly("E1", "C2", "E4", "E6", "E7", "E9");
646+
}
647+
648+
@Test
649+
public void processRequest_slidingWindow_preservesOverlappingCompactions() {
650+
// Case 1: Sliding Window + Retention
651+
// Input: [E1(1), E2(2), E3(3), C1(1-2), E4(5), C2(2-3), E5(7)]
652+
// Overlap: C1 and C2 overlap at 2. C1 is NOT redundant (start 1 < start 2).
653+
// Expected: [C1, C2, E4, E5]
654+
// E1(1) covered by C1.
655+
// E2(2) covered by C1 (and C2).
656+
// E3(3) covered by C2.
657+
// E4(5) retained.
658+
// E5(7) retained.
659+
ImmutableList<Event> events =
660+
ImmutableList.of(
661+
createUserEvent("e1", "E1", "inv1", 1),
662+
createUserEvent("e2", "E2", "inv2", 2),
663+
createUserEvent("e3", "E3", "inv3", 3),
664+
createCompactedEvent(1, 2, "C1"),
665+
createUserEvent("e4", "E4", "inv4", 5),
666+
createCompactedEvent(2, 3, "C2"),
667+
createUserEvent("e5", "E5", "inv5", 7));
668+
669+
List<Content> contents = runContentsProcessor(events);
670+
assertThat(contents)
671+
.comparingElementsUsing(
672+
transforming((Content c) -> c.parts().get().get(0).text().get(), "content text"))
673+
.containsExactly("C1", "C2", "E4", "E5");
674+
}
675+
580676
private static Event createUserEvent(String id, String text) {
581677
return Event.builder()
582678
.id(id)

0 commit comments

Comments
 (0)