Skip to content

Commit d00ad67

Browse files
UlookEEDeanChensj
authored andcommitted
fix: N sized sliding window
This fix allows for more efficient interaction with Context Caching, as Context Windows are removed when they exceed N, rather than immediately when they exceed the desired number. Adapted to the new baseline (using invocation_start_indices instead of num_model_turns). Merges #3271 Co-authored-by: Shangjie Chen <deanchen@google.com> COPYBARA_INTEGRATE_REVIEW=#3271 from UlookEE:n_sized_sliding_window 25429aa PiperOrigin-RevId: 936165882
1 parent 3cbcefc commit d00ad67

2 files changed

Lines changed: 133 additions & 1 deletion

File tree

src/google/adk/plugins/context_filter_plugin.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ def __init__(
107107
Callable[[list[types.Content]], list[types.Content]]
108108
] = None,
109109
name: str = "context_filter_plugin",
110+
remove_amount: int = 1,
110111
):
111112
"""Initializes the context management plugin.
112113
@@ -117,10 +118,15 @@ def __init__(
117118
message starts a new invocation.
118119
custom_filter: A function to filter the context.
119120
name: The name of the plugin instance.
121+
remove_amount: The number of invocations to remove when the context
122+
exceeds the limit.
120123
"""
124+
if remove_amount < 1:
125+
raise ValueError("remove_amount must be at least 1")
121126
super().__init__(name)
122127
self._num_invocations_to_keep = num_invocations_to_keep
123128
self._custom_filter = custom_filter
129+
self._remove_amount = remove_amount
124130

125131
async def before_model_callback(
126132
self, *, callback_context: CallbackContext, llm_request: LlmRequest
@@ -134,7 +140,10 @@ async def before_model_callback(
134140
and self._num_invocations_to_keep > 0
135141
):
136142
invocation_start_indices = _get_invocation_start_indices(contents)
137-
if len(invocation_start_indices) > self._num_invocations_to_keep:
143+
if (
144+
len(invocation_start_indices)
145+
>= self._num_invocations_to_keep + self._remove_amount
146+
):
138147
split_index = invocation_start_indices[-self._num_invocations_to_keep]
139148

140149
# Adjust split_index to avoid orphaned function_responses.

tests/unittests/plugins/test_context_filtering_plugin.py

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,3 +343,126 @@ async def test_last_invocation_with_tool_call_keeps_user_prompt():
343343

344344
assert "user_prompt_2" in texts
345345
assert "final_answer_2" in texts
346+
347+
348+
@pytest.mark.asyncio
349+
async def test_filter_with_remove_amount():
350+
"""Tests that remove_amount correctly removes additional invocations."""
351+
plugin = ContextFilterPlugin(num_invocations_to_keep=2, remove_amount=1)
352+
contents = [
353+
_create_content("user", "user_prompt_1"),
354+
_create_content("model", "model_response_1"),
355+
_create_content("user", "user_prompt_2"),
356+
_create_content("model", "model_response_2"),
357+
_create_content("user", "user_prompt_3"),
358+
_create_content("model", "model_response_3"),
359+
]
360+
llm_request = LlmRequest(contents=contents)
361+
362+
await plugin.before_model_callback(
363+
callback_context=mock.create_autospec(CallbackContext, instance=True),
364+
llm_request=llm_request,
365+
)
366+
367+
# With num_invocations_to_keep=2 and remove_amount=1, keeps last 2.
368+
assert len(llm_request.contents) == 4
369+
assert llm_request.contents[0].parts[0].text == "user_prompt_2"
370+
assert llm_request.contents[1].parts[0].text == "model_response_2"
371+
assert llm_request.contents[2].parts[0].text == "user_prompt_3"
372+
assert llm_request.contents[3].parts[0].text == "model_response_3"
373+
374+
375+
@pytest.mark.asyncio
376+
async def test_filter_with_higher_remove_amount():
377+
"""Tests remove_amount with a higher value to remove more invocations."""
378+
plugin = ContextFilterPlugin(num_invocations_to_keep=3, remove_amount=2)
379+
contents = [
380+
_create_content("user", "user_prompt_1"),
381+
_create_content("model", "model_response_1"),
382+
_create_content("user", "user_prompt_2"),
383+
_create_content("model", "model_response_2"),
384+
_create_content("user", "user_prompt_3"),
385+
_create_content("model", "model_response_3"),
386+
_create_content("user", "user_prompt_4"),
387+
_create_content("model", "model_response_4"),
388+
_create_content("user", "user_prompt_5"),
389+
_create_content("model", "model_response_5"),
390+
]
391+
llm_request = LlmRequest(contents=contents)
392+
393+
await plugin.before_model_callback(
394+
callback_context=mock.create_autospec(CallbackContext, instance=True),
395+
llm_request=llm_request,
396+
)
397+
398+
# With num_invocations_to_keep=3 and remove_amount=2, keeps last 3.
399+
assert len(llm_request.contents) == 6
400+
assert llm_request.contents[0].parts[0].text == "user_prompt_3"
401+
assert llm_request.contents[1].parts[0].text == "model_response_3"
402+
assert llm_request.contents[2].parts[0].text == "user_prompt_4"
403+
assert llm_request.contents[3].parts[0].text == "model_response_4"
404+
assert llm_request.contents[4].parts[0].text == "user_prompt_5"
405+
assert llm_request.contents[5].parts[0].text == "model_response_5"
406+
407+
408+
def test_invalid_remove_amount():
409+
"""Tests that initializing with remove_amount < 1 raises ValueError."""
410+
with pytest.raises(ValueError, match="remove_amount must be at least 1"):
411+
ContextFilterPlugin(num_invocations_to_keep=1, remove_amount=0)
412+
413+
with pytest.raises(ValueError, match="remove_amount must be at least 1"):
414+
ContextFilterPlugin(num_invocations_to_keep=1, remove_amount=-1)
415+
416+
417+
@pytest.mark.asyncio
418+
async def test_filter_remove_amount_with_multiple_user_turns():
419+
"""Tests remove_amount with multiple user turns in invocations."""
420+
plugin = ContextFilterPlugin(num_invocations_to_keep=2, remove_amount=1)
421+
contents = [
422+
_create_content("user", "user_prompt_1"),
423+
_create_content("model", "model_response_1"),
424+
_create_content("user", "user_prompt_2a"),
425+
_create_content("user", "user_prompt_2b"),
426+
_create_content("model", "model_response_2"),
427+
_create_content("user", "user_prompt_3"),
428+
_create_content("model", "model_response_3"),
429+
]
430+
llm_request = LlmRequest(contents=contents)
431+
432+
await plugin.before_model_callback(
433+
callback_context=mock.create_autospec(CallbackContext, instance=True),
434+
llm_request=llm_request,
435+
)
436+
437+
# Should keep last 2 invocations including multiple user turns
438+
assert len(llm_request.contents) == 5
439+
assert llm_request.contents[0].parts[0].text == "user_prompt_2a"
440+
assert llm_request.contents[1].parts[0].text == "user_prompt_2b"
441+
assert llm_request.contents[2].parts[0].text == "model_response_2"
442+
assert llm_request.contents[3].parts[0].text == "user_prompt_3"
443+
assert llm_request.contents[4].parts[0].text == "model_response_3"
444+
445+
446+
@pytest.mark.asyncio
447+
async def test_filter_bypass_when_under_remove_threshold():
448+
"""Tests that filtering is bypassed when total invocations are between keep limit and keep+remove limit."""
449+
plugin = ContextFilterPlugin(num_invocations_to_keep=2, remove_amount=2)
450+
contents = [
451+
_create_content("user", "user_prompt_1"),
452+
_create_content("model", "model_response_1"),
453+
_create_content("user", "user_prompt_2"),
454+
_create_content("model", "model_response_2"),
455+
_create_content("user", "user_prompt_3"),
456+
_create_content("model", "model_response_3"),
457+
]
458+
llm_request = LlmRequest(contents=contents)
459+
original_contents = list(llm_request.contents)
460+
461+
await plugin.before_model_callback(
462+
callback_context=mock.create_autospec(CallbackContext, instance=True),
463+
llm_request=llm_request,
464+
)
465+
466+
# With num_invocations_to_keep=2 and remove_amount=2, threshold is 4.
467+
# We have 3 invocations, so no filtering should occur.
468+
assert llm_request.contents == original_contents

0 commit comments

Comments
 (0)