Skip to content

Commit 59a5a34

Browse files
wikaaaaacopybara-github
authored andcommitted
fix: exclude rewound invocations from sliding-window compaction
PiperOrigin-RevId: 934307347
1 parent affc9bf commit 59a5a34

5 files changed

Lines changed: 123 additions & 29 deletions

File tree

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/*
2+
* Copyright 2026 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.adk.kt.events
18+
19+
/**
20+
* Returns [events] with rewound invocations removed.
21+
*
22+
* Iterates backward. When an event carries `actions.rewindBeforeInvocationId == X`, drops that
23+
* event together with every event between it and the earliest event of invocation `X` (inclusive),
24+
* then resumes the backward walk from there.
25+
*
26+
* This is the single source of truth for "which events are live" after rewinds. Both LLM prompt
27+
* building ([com.google.adk.kt.processors.HistoryRewriterProcessor]) and context compaction must
28+
* agree on it, otherwise rewound content can leak back into prompts through a compaction summary.
29+
*/
30+
internal fun applyRewinds(events: List<Event>): List<Event> {
31+
val kept = mutableListOf<Event>()
32+
var i = events.size - 1
33+
while (i >= 0) {
34+
val event = events[i]
35+
val rewindInvocationId = event.actions.rewindBeforeInvocationId
36+
if (!rewindInvocationId.isNullOrEmpty()) {
37+
for (j in 0 until i) {
38+
if (events[j].invocationId == rewindInvocationId) {
39+
i = j
40+
break
41+
}
42+
}
43+
} else {
44+
kept.add(event)
45+
}
46+
i--
47+
}
48+
return kept.asReversed()
49+
}

core/src/commonMain/kotlin/com/google/adk/kt/processors/HistoryRewriterProcessor.kt

Lines changed: 1 addition & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ package com.google.adk.kt.processors
1717

1818
import com.google.adk.kt.agents.LlmAgent.IncludeContents
1919
import com.google.adk.kt.events.Event
20+
import com.google.adk.kt.events.applyRewinds
2021
import com.google.adk.kt.serialization.Json
2122
import com.google.adk.kt.types.Content
2223
import com.google.adk.kt.types.FunctionCall
@@ -92,34 +93,6 @@ internal class HistoryRewriterProcessor {
9293
return emptyList()
9394
}
9495

95-
/**
96-
* Returns [events] with rewound invocations removed.
97-
*
98-
* Iterates backward. When an event carries `actions.rewindBeforeInvocationId == X`, drops that
99-
* event together with every event between it and the earliest event of invocation `X`
100-
* (inclusive), then resumes the backward walk from there.
101-
*/
102-
private fun applyRewinds(events: List<Event>): List<Event> {
103-
val kept = mutableListOf<Event>()
104-
var i = events.size - 1
105-
while (i >= 0) {
106-
val event = events[i]
107-
val rewindInvocationId = event.actions.rewindBeforeInvocationId
108-
if (!rewindInvocationId.isNullOrEmpty()) {
109-
for (j in 0 until i) {
110-
if (events[j].invocationId == rewindInvocationId) {
111-
i = j
112-
break
113-
}
114-
}
115-
} else {
116-
kept.add(event)
117-
}
118-
i--
119-
}
120-
return kept.asReversed()
121-
}
122-
12396
/**
12497
* Returns whether [event] qualifies as the start of the current turn for [agentName] on
12598
* [currentBranch]: it must be visible in this agent's context, and it must be a user input or

core/src/commonMain/kotlin/com/google/adk/kt/summarizer/SlidingWindowEventCompactor.kt

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
package com.google.adk.kt.summarizer
1717

1818
import com.google.adk.kt.events.Event
19+
import com.google.adk.kt.events.applyRewinds
1920
import com.google.adk.kt.logging.LoggerFactory
2021
import com.google.adk.kt.sessions.Session
2122
import com.google.adk.kt.sessions.SessionService
@@ -48,7 +49,11 @@ class SlidingWindowEventCompactor(private val config: EventsCompactionConfig) :
4849
if (!config.hasSlidingWindowConfig()) return
4950
val summarizer =
5051
requireNotNull(config.summarizer) { "Missing EventSummarizer for event compaction." }
51-
val compactionWindow = selectCompactionWindow(session.events) ?: return
52+
// Drop rewound invocations first so the summary covers only live events. This keeps the
53+
// compactor consistent with prompt building (HistoryRewriterProcessor also applies rewinds);
54+
// otherwise rewound content would leak back into future prompts via the compaction summary.
55+
val liveEvents = applyRewinds(session.events)
56+
val compactionWindow = selectCompactionWindow(liveEvents) ?: return
5257
val compactionEvent = summarizer.summarizeEvents(compactionWindow) ?: return
5358
val appendedEvent = sessionService.appendEvent(session, compactionEvent)
5459
logger.debug {

core/src/commonTest/kotlin/com/google/adk/kt/summarizer/SlidingWindowEventCompactorTest.kt

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import com.google.adk.kt.testing.eventWithFunctionCall
2727
import com.google.adk.kt.testing.eventWithFunctionResponse
2828
import com.google.adk.kt.testing.eventWithHitlRequest
2929
import com.google.adk.kt.testing.modelEvent
30+
import com.google.adk.kt.testing.rewindEvent
3031
import com.google.adk.kt.testing.testSession
3132
import com.google.adk.kt.testing.userEvent
3233
import kotlin.test.Test
@@ -397,6 +398,63 @@ class SlidingWindowEventCompactorTest {
397398
assertEquals(listOf(firstUser, firstModel), summarizer.calls.single())
398399
}
399400

401+
@Test
402+
fun compact_rewoundInvocation_excludedFromSummary() = runTest {
403+
val summarizer = RecordingSummarizer(returning = compactionEvent(startTs = 100L, endTs = 310L))
404+
val sessionService = RecordingSessionService()
405+
val compactor =
406+
SlidingWindowEventCompactor(
407+
EventsCompactionConfig(compactionInterval = 2, overlapSize = 1, summarizer = summarizer)
408+
)
409+
val session = testSession()
410+
val firstUser = userEvent("user event to keep", invocationId = "inv_1", timestamp = 100L)
411+
val firstModel = modelEvent("model response", invocationId = "inv_1", timestamp = 110L)
412+
// inv_2 was rewound by the user; its content must never reach the summarizer.
413+
val rewoundUser = userEvent("REWOUND_EVENT", invocationId = "inv_2", timestamp = 200L)
414+
val rewoundModel = modelEvent("rewound reply", invocationId = "inv_2", timestamp = 210L)
415+
val rewindMarker =
416+
rewindEvent(invocationId = "rewind_inv", rewoundInvocationId = "inv_2", timestamp = 220L)
417+
val thirdUser = userEvent("user event to keep", invocationId = "inv_3", timestamp = 300L)
418+
val thirdModel = modelEvent("model response", invocationId = "inv_3", timestamp = 310L)
419+
session.events.addAll(
420+
listOf(firstUser, firstModel, rewoundUser, rewoundModel, rewindMarker, thirdUser, thirdModel)
421+
)
422+
423+
compactor.compact(session, sessionService)
424+
425+
// Only the two live invocations (inv_1, inv_3) are summarized; the rewound invocation and the
426+
// rewind marker are dropped before window selection, so the rewound content never leaks.
427+
assertEquals(listOf(firstUser, firstModel, thirdUser, thirdModel), summarizer.calls.single())
428+
// Raw events stay persisted; compaction reads through rewinds but does not delete history.
429+
assertTrue(session.events.containsAll(listOf(rewoundUser, rewoundModel, rewindMarker)))
430+
}
431+
432+
@Test
433+
fun compact_rewoundInvocationDoesNotCountTowardThreshold() = runTest {
434+
val summarizer = RecordingSummarizer()
435+
val sessionService = RecordingSessionService()
436+
val compactor =
437+
SlidingWindowEventCompactor(
438+
EventsCompactionConfig(compactionInterval = 2, overlapSize = 1, summarizer = summarizer)
439+
)
440+
val session = testSession()
441+
// One live invocation plus one rewound invocation: only 1 live invocation < interval (2).
442+
session.events.add(userEvent("live", invocationId = "inv_1", timestamp = 100L))
443+
session.events.add(modelEvent("ok", invocationId = "inv_1", timestamp = 110L))
444+
session.events.add(userEvent("REWOUND_SECRET", invocationId = "inv_2", timestamp = 200L))
445+
session.events.add(modelEvent("rewound reply", invocationId = "inv_2", timestamp = 210L))
446+
session.events.add(
447+
rewindEvent(invocationId = "rewind_inv", rewoundInvocationId = "inv_2", timestamp = 220L)
448+
)
449+
450+
compactor.compact(session, sessionService)
451+
452+
// Rewound invocations (and the marker) do not count toward the interval, so the threshold is
453+
// not met and nothing is summarized.
454+
assertTrue(summarizer.calls.isEmpty())
455+
assertTrue(sessionService.appended.isEmpty())
456+
}
457+
400458
@Test
401459
fun compact_nullSummarizer_throwsIllegalArgumentException() = runTest {
402460
val compactor =

core/src/commonTest/kotlin/com/google/adk/kt/testing/TestEvent.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,15 @@ fun eventWithHitlRequest(invocationId: String, timestamp: Long, callId: String):
8686
timestamp = timestamp,
8787
)
8888

89+
/** A `user`-authored marker [Event] that rewinds history before [rewoundInvocationId]. */
90+
fun rewindEvent(invocationId: String, rewoundInvocationId: String, timestamp: Long = 0L): Event =
91+
Event(
92+
author = Role.USER,
93+
invocationId = invocationId,
94+
actions = EventActions(rewindBeforeInvocationId = rewoundInvocationId),
95+
timestamp = timestamp,
96+
)
97+
8998
/** An [Event] carrying an [EventCompaction] [summary] spanning [startTs]..[endTs]. */
9099
fun compactionEvent(
91100
startTs: Long,

0 commit comments

Comments
 (0)