Skip to content

Commit e0dd06f

Browse files
hangfeicopybara-github
authored andcommitted
feat: implementation of LLM context compaction
Provide a more efficient way to compact LLM context for better agentic performance. PiperOrigin-RevId: 815785898
1 parent ca6a434 commit e0dd06f

File tree

10 files changed

+905
-36
lines changed

10 files changed

+905
-36
lines changed

src/google/adk/apps/app.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,26 @@ class ResumabilityConfig(BaseModel):
4747
"""
4848

4949

50+
@experimental
51+
class EventsCompactionConfig(BaseModel):
52+
"""The config of event compaction for an application."""
53+
54+
model_config = ConfigDict(
55+
arbitrary_types_allowed=True,
56+
extra="forbid",
57+
)
58+
59+
compactor: BaseEventsCompactor
60+
"""The event compactor strategy for the application."""
61+
compaction_interval: int
62+
"""The number of *new* user-initiated invocations that, once
63+
fully represented in the session's events, will trigger a compaction."""
64+
overlap_size: int
65+
"""The number of preceding invocations to include from the
66+
end of the last compacted range. This creates an overlap between consecutive
67+
compacted summaries, maintaining context."""
68+
69+
5070
@experimental
5171
class App(BaseModel):
5272
"""Represents an LLM-backed agentic application.
@@ -73,8 +93,8 @@ class App(BaseModel):
7393
plugins: list[BasePlugin] = Field(default_factory=list)
7494
"""The plugins in the application."""
7595

76-
event_compactor: Optional[BaseEventsCompactor] = None
77-
"""The event compactor strategy for the application."""
96+
events_compaction_config: Optional[EventsCompactionConfig] = None
97+
"""The config of event compaction for the application."""
7898

7999
context_cache_config: Optional[ContextCacheConfig] = None
80100
"""Context cache configuration that applies to all LLM agents in the app."""

src/google/adk/apps/base_events_compactor.py

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -26,25 +26,22 @@
2626
class BaseEventsCompactor(abc.ABC):
2727
"""Base interface for compacting events."""
2828

29+
@abc.abstractmethod
2930
async def maybe_compact_events(
3031
self, *, events: list[Event]
31-
) -> Optional[Content]:
32-
"""A list of uncompacted events, decide whether to compact.
32+
) -> Optional[Event]:
33+
"""Compact a list of events into a single event.
3334
34-
If no need to compact, return None. Otherwise, compact into a content and
35+
If compaction failed, return None. Otherwise, compact into a content and
3536
return it.
3637
37-
This method will summarize the events and return a new summray event
38-
indicating the range of events it summarized.
39-
40-
When sending events to the LLM, if a summary event is present, the events it
41-
replaces (those identified in itssummary_range) should not be included.
38+
This method will summarize the events and return a new summray event
39+
indicating the range of events it summarized.
4240
4341
Args:
4442
events: Events to compact.
45-
agent_name: The name of the agent.
4643
4744
Returns:
48-
The new compacted content, or None if no compaction is needed.
45+
The new compacted event, or None if no compaction happended.
4946
"""
5047
raise NotImplementedError()

src/google/adk/apps/compaction.py

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
# Copyright 2025 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from __future__ import annotations
16+
17+
import logging
18+
19+
from google.adk.apps.app import App
20+
from google.adk.sessions.base_session_service import BaseSessionService
21+
from google.adk.sessions.session import Session
22+
23+
logger = logging.getLogger('google_adk.' + __name__)
24+
25+
26+
async def _run_compaction_for_sliding_window(
27+
app: App, session: Session, session_service: BaseSessionService
28+
):
29+
"""Runs compaction for SlidingWindowCompactor.
30+
31+
This method implements the sliding window compaction logic. It determines
32+
if enough new invocations have occurred since the last compaction based on
33+
`compaction_invocation_threshold`. If so, it selects a range of events to
34+
compact based on `overlap_size`, and calls `maybe_compact_events` on the
35+
compactor.
36+
37+
The compaction process is controlled by two parameters:
38+
1. `compaction_invocation_threshold`: The number of *new* user-initiated
39+
invocations that, once fully
40+
represented in the session's events, will trigger a compaction.
41+
2. `overlap_size`: The number of preceding invocations to include from the
42+
end of the last
43+
compacted range. This creates an overlap between consecutive compacted
44+
summaries,
45+
maintaining context.
46+
47+
The compactor is called after an agent has finished processing a turn and all
48+
its events
49+
have been added to the session. It checks if a new compaction is needed.
50+
51+
When a compaction is triggered:
52+
- The compactor identifies the range of `invocation_id`s to be summarized.
53+
- This range starts `overlap_size` invocations before the beginning of the
54+
new block of `compaction_invocation_threshold` invocations and ends
55+
with the last
56+
invocation
57+
in the current block.
58+
- A `CompactedEvent` is created, summarizing all events within this
59+
determined
60+
`invocation_id` range. This `CompactedEvent` is then appended to the
61+
session.
62+
63+
Here is an example with `compaction_invocation_threshold = 2` and
64+
`overlap_size = 1`:
65+
Let's assume events are added for `invocation_id`s 1, 2, 3, and 4 in order.
66+
67+
1. **After `invocation_id` 2 events are added:**
68+
- The session now contains events for invocations 1 and 2. This
69+
fulfills the `compaction_invocation_threshold = 2` criteria.
70+
- Since this is the first compaction, the range starts from the
71+
beginning.
72+
- A `CompactedEvent` is generated, summarizing events within
73+
`invocation_id` range [1, 2].
74+
- The session now contains: `[E(inv=1, role=user), E(inv=1, role=model),
75+
E(inv=2, role=user), E(inv=2, role=model), E(inv=2, role=user),
76+
CompactedEvent(inv=[1, 2])]`.
77+
78+
2. **After `invocation_id` 3 events are added:**
79+
- No compaction happens yet, because only 1 new invocation (`inv=3`)
80+
has been completed since the last compaction, and
81+
`compaction_invocation_threshold` is 2.
82+
83+
3. **After `invocation_id` 4 events are added:**
84+
- The session now contains new events for invocations 3 and 4, again
85+
fulfilling `compaction_invocation_threshold = 2`.
86+
- The last `CompactedEvent` covered up to `invocation_id` 2. With
87+
`overlap_size = 1`, the new compaction range
88+
will start one invocation before the new block (inv 3), which is
89+
`invocation_id` 2.
90+
- The new compaction range is from `invocation_id` 2 to 4.
91+
- A new `CompactedEvent` is generated, summarizing events within
92+
`invocation_id` range [2, 4].
93+
- The session now contains: `[E(inv=1, role=user), E(inv=1, role=model),
94+
E(inv=2, role=user), E(inv=2, role=model), E(inv=2, role=user),
95+
CompactedEvent(inv=[1, 2]), E(inv=3, role=user), E(inv=3, role=model),
96+
E(inv=4, role=user), E(inv=4, role=model), CompactedEvent(inv=[2, 4])]`.
97+
98+
99+
Args:
100+
app: The application instance.
101+
session: The session containing events to compact.
102+
session_service: The session service for appending events.
103+
"""
104+
events = session.events
105+
if not events:
106+
return None
107+
# Find the last compaction event and its range.
108+
last_compacted_end_timestamp = 0.0
109+
for event in reversed(events):
110+
if (
111+
event.actions
112+
and event.actions.compaction
113+
and event.actions.compaction.end_timestamp
114+
):
115+
last_compacted_end_timestamp = event.actions.compaction.end_timestamp
116+
break
117+
118+
# Get unique invocation IDs and their latest timestamps.
119+
invocation_latest_timestamps = {}
120+
for event in events:
121+
# Only consider non-compaction events for unique invocation IDs.
122+
if event.invocation_id and not (event.actions and event.actions.compaction):
123+
invocation_latest_timestamps[event.invocation_id] = max(
124+
invocation_latest_timestamps.get(event.invocation_id, 0.0),
125+
event.timestamp,
126+
)
127+
128+
unique_invocation_ids = list(invocation_latest_timestamps.keys())
129+
130+
# Determine which invocations are new since the last compaction.
131+
new_invocation_ids = [
132+
inv_id
133+
for inv_id in unique_invocation_ids
134+
if invocation_latest_timestamps[inv_id] > last_compacted_end_timestamp
135+
]
136+
137+
if len(new_invocation_ids) < app.events_compaction_config.compaction_interval:
138+
return None # Not enough new invocations to trigger compaction.
139+
140+
# Determine the range of invocations to compact.
141+
# The end of the compaction range is the last of the new invocations.
142+
end_inv_id = new_invocation_ids[-1]
143+
144+
# The start of the compaction range is overlap_size invocations before
145+
# the first of the new invocations.
146+
first_new_inv_id = new_invocation_ids[0]
147+
first_new_inv_idx = unique_invocation_ids.index(first_new_inv_id)
148+
149+
start_idx = max(
150+
0, first_new_inv_idx - app.events_compaction_config.overlap_size
151+
)
152+
start_inv_id = unique_invocation_ids[start_idx]
153+
154+
# Find the index of the last event with end_inv_id.
155+
last_event_idx = -1
156+
for i in range(len(events) - 1, -1, -1):
157+
if events[i].invocation_id == end_inv_id:
158+
last_event_idx = i
159+
break
160+
161+
events_to_compact = []
162+
# Trim events_to_compact to include all events up to and including the
163+
# last event of end_inv_id.
164+
if last_event_idx != -1:
165+
# Find the index of the first event of start_inv_id in events.
166+
first_event_start_inv_idx = -1
167+
for i, event in enumerate(events):
168+
if event.invocation_id == start_inv_id:
169+
first_event_start_inv_idx = i
170+
break
171+
if first_event_start_inv_idx != -1:
172+
events_to_compact = events[first_event_start_inv_idx : last_event_idx + 1]
173+
# Filter out any existing compaction events from the list.
174+
events_to_compact = [
175+
e
176+
for e in events_to_compact
177+
if not (e.actions and e.actions.compaction)
178+
]
179+
180+
if not events_to_compact:
181+
return None
182+
183+
compaction_event = (
184+
await app.events_compaction_config.compactor.maybe_compact_events(
185+
events=events_to_compact
186+
)
187+
)
188+
if compaction_event:
189+
await session_service.append_event(session=session, event=compaction_event)
190+
logger.debug('Event compactor finished.')

0 commit comments

Comments
 (0)