Skip to content

Commit 2c87462

Browse files
committed
fix(memory): paginate list_messages by converted message count, not raw event count
1 parent f8710fa commit 2c87462

File tree

5 files changed

+229
-111
lines changed

5 files changed

+229
-111
lines changed
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"""Reusable pagination utility for fetching and converting paginated results."""
2+
3+
from typing import Any, Callable, TypeVar
4+
5+
T = TypeVar("T")
6+
R = TypeVar("R")
7+
8+
DEFAULT_PAGE_SIZE = 100
9+
10+
11+
def paginate_for_n_results(
12+
fetch_page: Callable[[dict[str, Any]], tuple[list[R], str | None]],
13+
initial_params: dict[str, Any],
14+
converter: Callable[[list[R]], list[T]],
15+
target_count: int,
16+
) -> list[T]:
17+
"""Paginate an arbitrary API, converting accumulated results after each page.
18+
19+
The full accumulated list is re-converted after each page rather than converting
20+
per-page, because some converters (e.g. events_to_messages) iterate the input in
21+
reverse — converting per-page would produce incorrect ordering.
22+
23+
Args:
24+
fetch_page: Takes params dict, returns (items, next_token). next_token is None when exhausted.
25+
initial_params: Base params for the first call. "nextToken" is added for subsequent pages.
26+
converter: Converts accumulated raw items to desired output type.
27+
target_count: Stop after collecting this many converted items.
28+
"""
29+
all_items: list[R] = []
30+
next_token: str | None = None
31+
32+
while True:
33+
params = {**initial_params}
34+
if next_token:
35+
params["nextToken"] = next_token
36+
37+
items, next_token = fetch_page(params)
38+
all_items.extend(items)
39+
40+
converted = converter(all_items)
41+
if len(converted) >= target_count or not next_token:
42+
return converted[:target_count]

src/bedrock_agentcore/memory/integrations/strands/session_manager.py

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from strands.types.session import Session, SessionAgent, SessionMessage
2020
from typing_extensions import override
2121

22+
from bedrock_agentcore._utils.pagination import DEFAULT_PAGE_SIZE, paginate_for_n_results
2223
from bedrock_agentcore.memory.client import MemoryClient
2324
from bedrock_agentcore.memory.models.filters import (
2425
EventMetadataFilter,
@@ -596,15 +597,24 @@ def list_messages(
596597
raise SessionException(f"Session ID mismatch: expected {self.config.session_id}, got {session_id}")
597598

598599
try:
599-
max_results = (limit + offset) if limit else MAX_FETCH_ALL_RESULTS
600-
601-
events = self.memory_client.list_events(
602-
memory_id=self.config.memory_id,
603-
actor_id=self.config.actor_id,
604-
session_id=session_id,
605-
max_results=max_results,
600+
target = (limit + offset) if limit else MAX_FETCH_ALL_RESULTS
601+
602+
def fetch_page(params: dict) -> tuple[list, str | None]:
603+
response = self.memory_client.gmdp_client.list_events(**params)
604+
return response.get("events", []), response.get("nextToken")
605+
606+
messages = paginate_for_n_results(
607+
fetch_page=fetch_page,
608+
initial_params={
609+
"memoryId": self.config.memory_id,
610+
"actorId": self.config.actor_id,
611+
"sessionId": session_id,
612+
"maxResults": DEFAULT_PAGE_SIZE,
613+
"includePayloads": True,
614+
},
615+
converter=self.converter.events_to_messages,
616+
target_count=target,
606617
)
607-
messages = self.converter.events_to_messages(events)
608618
if self.config.filter_restored_tool_context:
609619
messages = self._filter_restored_tool_context(messages)
610620
if limit is not None:

tests/bedrock_agentcore/memory/integrations/strands/test_agentcore_memory_session_manager.py

Lines changed: 142 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -302,36 +302,22 @@ def test_create_message(self, session_manager, mock_memory_client):
302302

303303
def test_list_messages(self, session_manager, mock_memory_client):
304304
"""Test listing messages."""
305-
mock_memory_client.list_events.return_value = [
306-
{
307-
"eventId": "event-1",
308-
"eventTimestamp": "2024-01-01T12:00:00Z",
309-
"payload": [
310-
{
311-
"conversational": {
312-
"content": {
313-
"text": '{"message": {"role": "user", "content": [{"text": "Hello"}]}, "message_id": 1}'
314-
},
315-
"role": "USER",
316-
}
317-
}
318-
],
319-
},
320-
{
321-
"eventId": "event-2",
322-
"eventTimestamp": "2024-01-01T12:00:00Z",
323-
"payload": [
324-
{
325-
"conversational": {
326-
"content": {
327-
"text": '{"message": {"role": "assistant", "content": [{"text": "Hi there"}]}, "message_id": 2}' # noqa E501
328-
},
329-
"role": "ASSISTANT",
330-
}
331-
}
332-
],
333-
},
334-
]
305+
user_text = '{"message": {"role": "user", "content": [{"text": "Hello"}]}, "message_id": 1}'
306+
asst_text = '{"message": {"role": "assistant", "content": [{"text": "Hi there"}]}, "message_id": 2}'
307+
session_manager.memory_client.gmdp_client.list_events.return_value = {
308+
"events": [
309+
{
310+
"eventId": "event-1",
311+
"eventTimestamp": "2024-01-01T12:00:00Z",
312+
"payload": [{"conversational": {"content": {"text": user_text}, "role": "USER"}}],
313+
},
314+
{
315+
"eventId": "event-2",
316+
"eventTimestamp": "2024-01-01T12:00:00Z",
317+
"payload": [{"conversational": {"content": {"text": asst_text}, "role": "ASSISTANT"}}],
318+
},
319+
]
320+
}
335321

336322
messages = session_manager.list_messages("test-session-456", "test-agent-123")
337323

@@ -341,36 +327,22 @@ def test_list_messages(self, session_manager, mock_memory_client):
341327

342328
def test_list_messages_returns_values_in_correct_reverse_order(self, session_manager, mock_memory_client):
343329
"""Test listing messages."""
344-
mock_memory_client.list_events.return_value = [
345-
{
346-
"eventId": "event-1",
347-
"eventTimestamp": "2024-01-01T12:00:00Z",
348-
"payload": [
349-
{
350-
"conversational": {
351-
"content": {
352-
"text": '{"message": {"role": "user", "content": [{"text": "Hello"}]}, "message_id": 1}'
353-
},
354-
"role": "USER",
355-
}
356-
}
357-
],
358-
},
359-
{
360-
"eventId": "event-2",
361-
"eventTimestamp": "2024-01-01T12:00:00Z",
362-
"payload": [
363-
{
364-
"conversational": {
365-
"content": {
366-
"text": '{"message": {"role": "assistant", "content": [{"text": "Hi there"}]}, "message_id": 2}' # noqa E501
367-
},
368-
"role": "ASSISTANT",
369-
}
370-
}
371-
],
372-
},
373-
]
330+
user_text = '{"message": {"role": "user", "content": [{"text": "Hello"}]}, "message_id": 1}'
331+
asst_text = '{"message": {"role": "assistant", "content": [{"text": "Hi there"}]}, "message_id": 2}'
332+
session_manager.memory_client.gmdp_client.list_events.return_value = {
333+
"events": [
334+
{
335+
"eventId": "event-1",
336+
"eventTimestamp": "2024-01-01T12:00:00Z",
337+
"payload": [{"conversational": {"content": {"text": user_text}, "role": "USER"}}],
338+
},
339+
{
340+
"eventId": "event-2",
341+
"eventTimestamp": "2024-01-01T12:00:00Z",
342+
"payload": [{"conversational": {"content": {"text": asst_text}, "role": "ASSISTANT"}}],
343+
},
344+
]
345+
}
374346

375347
messages = session_manager.list_messages("test-session-456", "test-agent-123")
376348

@@ -508,37 +480,39 @@ def test_update_message_wrong_session(self, session_manager):
508480

509481
def test_list_messages_with_limit(self, session_manager, mock_memory_client):
510482
"""Test listing messages with limit."""
511-
mock_memory_client.list_events.return_value = [
512-
{
513-
"eventId": "event-1",
514-
"eventTimestamp": "2024-01-01T12:00:00Z",
515-
"payload": [
516-
{
517-
"conversational": {
518-
"content": {
519-
"text": '{"message": {"role": "user", '
520-
'"content": [{"text": "Message 1"}]}, "message_id": 1}'
521-
},
522-
"role": "USER",
483+
session_manager.memory_client.gmdp_client.list_events.return_value = {
484+
"events": [
485+
{
486+
"eventId": "event-1",
487+
"eventTimestamp": "2024-01-01T12:00:00Z",
488+
"payload": [
489+
{
490+
"conversational": {
491+
"content": {
492+
"text": '{"message": {"role": "user", '
493+
'"content": [{"text": "Message 1"}]}, "message_id": 1}'
494+
},
495+
"role": "USER",
496+
}
523497
}
524-
}
525-
],
526-
},
527-
{
528-
"eventId": "event-2",
529-
"eventTimestamp": "2024-01-01T12:00:00Z",
530-
"payload": [
531-
{
532-
"conversational": {
533-
"content": {
534-
"text": '{"message": {"role": "assistant", "content": [{"text": "Message 2"}]}, "message_id": 2}' # noqa E501
535-
},
536-
"role": "ASSISTANT",
498+
],
499+
},
500+
{
501+
"eventId": "event-2",
502+
"eventTimestamp": "2024-01-01T12:00:00Z",
503+
"payload": [
504+
{
505+
"conversational": {
506+
"content": {
507+
"text": '{"message": {"role": "assistant", "content": [{"text": "Message 2"}]}, "message_id": 2}' # noqa E501
508+
},
509+
"role": "ASSISTANT",
510+
}
537511
}
538-
}
539-
],
540-
},
541-
]
512+
],
513+
},
514+
]
515+
}
542516

543517
messages = session_manager.list_messages("test-session-456", "test-agent-123", limit=1, offset=1)
544518

@@ -1189,25 +1163,90 @@ def test_retrieve_customer_context_filters_by_relevance_score(self, mock_memory_
11891163
assert "Low relevance 1" not in injected_context
11901164
assert "Low relevance 2" not in injected_context
11911165

1192-
def test_list_messages_default_max_results(self, session_manager, mock_memory_client):
1193-
"""Test listing messages without limit uses default max_results=10000."""
1194-
mock_memory_client.list_events.return_value = []
1166+
def test_list_messages_with_limit_skips_state_events(self, session_manager, mock_memory_client):
1167+
"""list_messages with limit returns exactly limit messages even when state events are mixed in.
1168+
1169+
State events (session/agent blobs) share the same actorId as conversational events
1170+
after the metadata-based identification change. If list_messages counts raw events
1171+
toward the limit, state events consume slots and the caller gets fewer messages
1172+
than requested.
1173+
"""
1174+
1175+
def _conv_event(eid, text, role):
1176+
return {
1177+
"eventId": eid,
1178+
"payload": [
1179+
{
1180+
"conversational": {
1181+
"content": {
1182+
"text": f'{{"message": {{"role": "{role}", '
1183+
f'"content": [{{"text": "{text}"}}]}}, "message_id": {eid}}}'
1184+
},
1185+
"role": role.upper(),
1186+
}
1187+
}
1188+
],
1189+
}
11951190

1196-
session_manager.list_messages("test-session-456", "test-agent-123")
1191+
def _state_event(eid):
1192+
return {
1193+
"eventId": eid,
1194+
"payload": [{"blob": '{"session_id": "s", "session_type": "AGENT"}'}],
1195+
"metadata": {"stateType": {"stringValue": "SESSION"}},
1196+
}
11971197

1198-
mock_memory_client.list_events.assert_called_once()
1199-
call_kwargs = mock_memory_client.list_events.call_args[1]
1200-
assert call_kwargs["max_results"] == 10000
1198+
# Page 1: 2 state + 3 conversational (5 raw events, only 3 convert to messages)
1199+
# Page 2: 3 more conversational
1200+
page1 = [
1201+
_state_event("s1"),
1202+
_conv_event(1, "Hello", "user"),
1203+
_conv_event(2, "Hi", "assistant"),
1204+
_state_event("s2"),
1205+
_conv_event(3, "How are you?", "user"),
1206+
]
1207+
page2 = [
1208+
_conv_event(4, "Good", "assistant"),
1209+
_conv_event(5, "Great", "user"),
1210+
_conv_event(6, "Thanks", "assistant"),
1211+
]
12011212

1202-
def test_list_messages_with_limit_calculates_max_results(self, session_manager, mock_memory_client):
1203-
"""Test listing messages with limit calculates max_results correctly."""
1204-
mock_memory_client.list_events.return_value = []
1213+
mock_gmdp = session_manager.memory_client.gmdp_client
1214+
mock_gmdp.list_events.side_effect = [
1215+
{"events": page1, "nextToken": "tok"},
1216+
{"events": page2},
1217+
]
1218+
1219+
messages = session_manager.list_messages("test-session-456", "test-agent-123", limit=5)
1220+
1221+
assert len(messages) == 5
12051222

1206-
session_manager.list_messages("test-session-456", "test-agent-123", limit=500, offset=50)
1223+
def test_list_messages_with_limit_returns_fewer_when_not_enough(self, session_manager, mock_memory_client):
1224+
"""list_messages returns all available messages when fewer than limit exist."""
12071225

1208-
mock_memory_client.list_events.assert_called_once()
1209-
call_kwargs = mock_memory_client.list_events.call_args[1]
1210-
assert call_kwargs["max_results"] == 550 # limit + offset
1226+
def _conv_event(eid, text, role):
1227+
return {
1228+
"eventId": eid,
1229+
"payload": [
1230+
{
1231+
"conversational": {
1232+
"content": {
1233+
"text": f'{{"message": {{"role": "{role}", '
1234+
f'"content": [{{"text": "{text}"}}]}}, "message_id": {eid}}}'
1235+
},
1236+
"role": role.upper(),
1237+
}
1238+
}
1239+
],
1240+
}
1241+
1242+
mock_gmdp = session_manager.memory_client.gmdp_client
1243+
mock_gmdp.list_events.return_value = {
1244+
"events": [_conv_event(1, "Hello", "user"), _conv_event(2, "Hi", "assistant")]
1245+
}
1246+
1247+
messages = session_manager.list_messages("test-session-456", "test-agent-123", limit=10)
1248+
1249+
assert len(messages) == 2
12111250

12121251
def test_append_message_handles_none_from_create_message(self, session_manager, test_agent):
12131252
"""Test that append_message gracefully handles None return from create_message."""

tests/bedrock_agentcore/memory/integrations/strands/test_agentcore_memory_session_manager_openai_converter.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ def test_list_messages_filters_restored_tool_context():
9494
manager.session_id = config.session_id
9595
manager.session = Session(session_id=config.session_id, session_type=SessionType.AGENT)
9696

97+
manager.memory_client.gmdp_client.list_events.return_value = {"events": [{"payload": []}]}
9798
manager.converter = Mock()
9899
manager.converter.events_to_messages.return_value = [
99100
SessionMessage(message={"role": "user", "content": [{"text": "hello"}]}, message_id=0),

0 commit comments

Comments
 (0)