Skip to content

Commit 6cda0a3

Browse files
authored
feat(strands-memory): add converter injection and optional restored-tool filtering (#288)
* feat(strands-memory): add converter injection and optional restored-tool filtering * fix: update conversational payload size limit to 100000 chars across converters and tests * feat: introduce AutoConverseConverter for dynamic converter selection and enhance OpenAI converter handling - Added AutoConverseConverter to facilitate automatic converter selection based on agent model. - Updated AgentCoreMemorySessionManager to support auto converter mode. - Enhanced OpenAI converter to preserve reasoning content and handle oversized payloads. - Updated tests to validate new auto converter functionality and reasoning content preservation. * fix: update model module paths in tests to reflect new structure - Changed module paths for OpenAI, Anthropic, and Gemini models in test files to align with the updated project structure. - Ensured consistency across tests for accurate model identification. * Remove nit and kept only openai converter * fix: streamline converter usage in AgentCoreMemorySessionManager - Updated the AgentCoreMemorySessionManager to directly use the instance's converter, removing unnecessary fallback logic. - Ensured consistent handling of message conversion and size checks across methods.
1 parent a29aeec commit 6cda0a3

11 files changed

Lines changed: 542 additions & 13 deletions

File tree

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,5 @@
11
"""Strands integration for Bedrock AgentCore Memory."""
2+
3+
from .converters import MemoryConverter, OpenAIConverseConverter
4+
5+
__all__ = ["MemoryConverter", "OpenAIConverseConverter"]

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@
88

99
logger = logging.getLogger(__name__)
1010

11-
CONVERSATIONAL_MAX_SIZE = 9000
11+
# Bedrock AgentCore Data Plane conversational payload text max is 100000 chars.
12+
# Ref: https://docs.aws.amazon.com/cli/latest/reference/bedrock-agentcore/create-event.html
13+
CONVERSATIONAL_MAX_SIZE = 100000
1214

1315

1416
class AgentCoreMemoryConverter:

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ class AgentCoreMemoryConfig(BaseModel):
3636
Default is None (disabled).
3737
context_tag: XML tag name used to wrap retrieved memory context injected into messages.
3838
Default is "user_context".
39+
filter_restored_tool_context: When True, strip historical toolUse/toolResult blocks from
40+
restored messages before loading them into Strands runtime memory. Default is False.
3941
"""
4042

4143
memory_id: str = Field(min_length=1)
@@ -45,3 +47,4 @@ class AgentCoreMemoryConfig(BaseModel):
4547
batch_size: int = Field(default=1, ge=1, le=100)
4648
flush_interval_seconds: Optional[float] = Field(default=None, gt=0)
4749
context_tag: str = Field(default="user_context", min_length=1)
50+
filter_restored_tool_context: bool = Field(default=False)
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
"""Converters for Strands <-> STM message formats."""
2+
3+
from .openai import OpenAIConverseConverter
4+
from .protocol import MemoryConverter
5+
6+
__all__ = [
7+
"OpenAIConverseConverter",
8+
"MemoryConverter",
9+
]
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
"""OpenAI-format converter for AgentCore Memory.
2+
3+
Converts between Strands SessionMessages (Strands-native message shape) and OpenAI message format
4+
stored in AgentCore Memory STM events.
5+
"""
6+
7+
import json
8+
import logging
9+
from typing import Any, Tuple
10+
11+
from strands.types.session import SessionMessage
12+
13+
from .protocol import exceeds_conversational_limit
14+
15+
logger = logging.getLogger(__name__)
16+
17+
18+
def _bedrock_to_openai(message: dict) -> dict:
19+
"""Convert a Strands-native message dict to OpenAI message format."""
20+
role = message.get("role", "user")
21+
content = message.get("content", [])
22+
23+
if content and "toolResult" in content[0]:
24+
tool_result = content[0]["toolResult"]
25+
text_parts = [c.get("text", "") for c in tool_result.get("content", []) if "text" in c]
26+
result = {
27+
"role": "tool",
28+
"tool_call_id": tool_result["toolUseId"],
29+
"content": "\n".join(text_parts),
30+
}
31+
if "status" in tool_result:
32+
result["status"] = tool_result["status"]
33+
return result
34+
35+
text_parts = []
36+
tool_calls = []
37+
reasoning_blocks: list[dict[str, Any]] = []
38+
for item in content:
39+
if "text" in item:
40+
text_value = item.get("text")
41+
if isinstance(text_value, str):
42+
text = text_value.strip()
43+
if text:
44+
text_parts.append(text)
45+
elif "reasoningContent" in item:
46+
# OpenAI message shape does not have a stable multi-turn reasoning block field.
47+
# Preserve original block(s) in storage-only extension field for lossless restore.
48+
reasoning_blocks.append(item)
49+
elif "toolUse" in item:
50+
tu = item["toolUse"]
51+
tool_calls.append(
52+
{
53+
"id": tu["toolUseId"],
54+
"type": "function",
55+
"function": {
56+
"name": tu["name"],
57+
"arguments": json.dumps(tu.get("input", {})),
58+
},
59+
}
60+
)
61+
62+
result: dict[str, Any] = {"role": role}
63+
64+
if tool_calls:
65+
result["content"] = "\n".join(text_parts) if text_parts else None
66+
result["tool_calls"] = tool_calls
67+
else:
68+
result["content"] = "\n".join(text_parts) if text_parts else ""
69+
70+
if reasoning_blocks:
71+
result["_strands_reasoning_content"] = reasoning_blocks
72+
73+
return result
74+
75+
76+
def _openai_to_bedrock(openai_msg: dict) -> dict:
77+
"""Convert an OpenAI message dict to Strands-native message shape."""
78+
role = openai_msg.get("role", "user")
79+
content_items: list[dict[str, Any]] = []
80+
81+
if role == "tool":
82+
tool_result: dict[str, Any] = {
83+
"toolUseId": openai_msg["tool_call_id"],
84+
"content": [{"text": openai_msg.get("content", "")}],
85+
}
86+
if "status" in openai_msg:
87+
tool_result["status"] = openai_msg["status"]
88+
return {
89+
"role": "user",
90+
"content": [{"toolResult": tool_result}],
91+
}
92+
93+
if role == "system":
94+
return {
95+
"role": "user",
96+
"content": [{"text": openai_msg.get("content", "")}],
97+
}
98+
99+
text_content = openai_msg.get("content")
100+
if text_content and isinstance(text_content, str):
101+
content_items.append({"text": text_content})
102+
103+
for tc in openai_msg.get("tool_calls", []):
104+
fn = tc.get("function", {})
105+
args_str = fn.get("arguments", "{}")
106+
try:
107+
args = json.loads(args_str)
108+
except (json.JSONDecodeError, ValueError):
109+
args = {}
110+
content_items.append(
111+
{
112+
"toolUse": {
113+
"toolUseId": tc["id"],
114+
"name": fn["name"],
115+
"input": args,
116+
}
117+
}
118+
)
119+
120+
for rc in openai_msg.get("_strands_reasoning_content", []):
121+
if isinstance(rc, dict) and "reasoningContent" in rc:
122+
content_items.append(rc)
123+
124+
bedrock_role = "assistant" if role == "assistant" else "user"
125+
126+
return {"role": bedrock_role, "content": content_items}
127+
128+
129+
class OpenAIConverseConverter:
130+
"""Converts between Strands SessionMessages and OpenAI message format in STM."""
131+
132+
@staticmethod
133+
def message_to_payload(session_message: SessionMessage) -> list[Tuple[str, str]]:
134+
"""Convert a SessionMessage (Strands-native shape) to OpenAI-format STM payload."""
135+
message = session_message.message
136+
content = message.get("content", [])
137+
if not content:
138+
return []
139+
140+
has_non_empty = any(
141+
(isinstance(item.get("text"), str) and item["text"].strip())
142+
or "toolUse" in item
143+
or "toolResult" in item
144+
for item in content
145+
)
146+
if not has_non_empty:
147+
return []
148+
149+
openai_msg = _bedrock_to_openai(message)
150+
role = openai_msg.get("role", "user")
151+
return [(json.dumps(openai_msg), role)]
152+
153+
@staticmethod
154+
def events_to_messages(events: list[dict[str, Any]]) -> list[SessionMessage]:
155+
"""Convert STM events containing OpenAI-format messages to SessionMessages."""
156+
messages: list[SessionMessage] = []
157+
158+
for event in reversed(events):
159+
for payload_item in event.get("payload", []):
160+
openai_msg = None
161+
162+
if "conversational" in payload_item:
163+
conv = payload_item["conversational"]
164+
try:
165+
openai_msg = json.loads(conv["content"]["text"])
166+
except (json.JSONDecodeError, KeyError, ValueError):
167+
logger.error("Failed to parse conversational payload as OpenAI message")
168+
continue
169+
170+
elif "blob" in payload_item:
171+
try:
172+
blob_data = json.loads(payload_item["blob"])
173+
if isinstance(blob_data, (tuple, list)) and len(blob_data) == 2:
174+
openai_msg = json.loads(blob_data[0])
175+
except (json.JSONDecodeError, ValueError):
176+
logger.error("Failed to parse blob payload: %s", payload_item)
177+
continue
178+
179+
if openai_msg and isinstance(openai_msg, dict):
180+
bedrock_msg = _openai_to_bedrock(openai_msg)
181+
if bedrock_msg.get("content"):
182+
session_msg = SessionMessage(message=bedrock_msg, message_id=0)
183+
messages.append(session_msg)
184+
185+
return messages
186+
187+
@staticmethod
188+
def exceeds_conversational_limit(message: tuple[str, str]) -> bool:
189+
"""Check if message exceeds conversational payload size limit."""
190+
return exceeds_conversational_limit(message)
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
"""Shared protocol and utilities for memory converters."""
2+
3+
from typing import Any, Protocol, Tuple
4+
5+
from strands.types.session import SessionMessage
6+
7+
CONVERSATIONAL_MAX_SIZE = 100000
8+
9+
10+
class MemoryConverter(Protocol):
11+
"""Protocol for converting between Strands messages and STM event payloads."""
12+
13+
@staticmethod
14+
def message_to_payload(session_message: SessionMessage) -> list[Tuple[str, str]]:
15+
"""Convert SessionMessage to STM event payload format."""
16+
17+
@staticmethod
18+
def events_to_messages(events: list[dict[str, Any]]) -> list[SessionMessage]:
19+
"""Convert STM events to SessionMessages."""
20+
21+
@staticmethod
22+
def exceeds_conversational_limit(message: tuple[str, str]) -> bool:
23+
"""Check if message exceeds conversational payload size limit."""
24+
25+
26+
def exceeds_conversational_limit(message: tuple[str, str]) -> bool:
27+
"""Check if message exceeds the conversational payload size limit."""
28+
return sum(len(text) for text in message) >= CONVERSATIONAL_MAX_SIZE

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

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,16 @@
2020
from typing_extensions import override
2121

2222
from bedrock_agentcore.memory.client import MemoryClient
23-
from bedrock_agentcore.memory.models.filters import EventMetadataFilter, LeftExpression, OperatorType, RightExpression
23+
from bedrock_agentcore.memory.models.filters import (
24+
EventMetadataFilter,
25+
LeftExpression,
26+
OperatorType,
27+
RightExpression,
28+
)
2429

2530
from .bedrock_converter import AgentCoreMemoryConverter
2631
from .config import AgentCoreMemoryConfig, RetrievalConfig
32+
from .converters import MemoryConverter
2733

2834
if TYPE_CHECKING:
2935
from strands.agent.agent import Agent
@@ -98,6 +104,7 @@ def _get_monotonic_timestamp(cls, desired_timestamp: Optional[datetime] = None)
98104
def __init__(
99105
self,
100106
agentcore_memory_config: AgentCoreMemoryConfig,
107+
converter: Optional[type[MemoryConverter]] = None,
101108
region_name: Optional[str] = None,
102109
boto_session: Optional[boto3.Session] = None,
103110
boto_client_config: Optional[BotocoreConfig] = None,
@@ -107,12 +114,15 @@ def __init__(
107114
108115
Args:
109116
agentcore_memory_config (AgentCoreMemoryConfig): Configuration for AgentCore Memory integration.
117+
converter (Optional[type[MemoryConverter]], optional): Optional custom converter.
118+
If None, native Bedrock/Strands converter is used.
110119
region_name (Optional[str], optional): AWS region for Bedrock AgentCore Memory. Defaults to None.
111120
boto_session (Optional[boto3.Session], optional): Optional boto3 session. Defaults to None.
112121
boto_client_config (Optional[BotocoreConfig], optional): Optional boto3 client configuration.
113122
Defaults to None.
114123
**kwargs (Any): Additional keyword arguments.
115124
"""
125+
self.converter = converter or AgentCoreMemoryConverter
116126
self.config = agentcore_memory_config
117127
self.memory_client = MemoryClient(region_name=region_name)
118128
session = boto_session or boto3.Session(region_name=region_name)
@@ -465,11 +475,11 @@ def create_message(
465475
raise SessionException(f"Session ID mismatch: expected {self.config.session_id}, got {session_id}")
466476

467477
# Convert and check size ONCE (not again at flush)
468-
messages = AgentCoreMemoryConverter.message_to_payload(session_message)
478+
messages = self.converter.message_to_payload(session_message)
469479
if not messages:
470480
return None
471481

472-
is_blob = AgentCoreMemoryConverter.exceeds_conversational_limit(messages[0])
482+
is_blob = self.converter.exceeds_conversational_limit(messages[0])
473483

474484
# Parse the original timestamp and use it as desired timestamp
475485
original_timestamp = datetime.fromisoformat(session_message.created_at.replace("Z", "+00:00"))
@@ -593,7 +603,9 @@ def list_messages(
593603
session_id=session_id,
594604
max_results=max_results,
595605
)
596-
messages = AgentCoreMemoryConverter.events_to_messages(events)
606+
messages = self.converter.events_to_messages(events)
607+
if self.config.filter_restored_tool_context:
608+
messages = self._filter_restored_tool_context(messages)
597609
if limit is not None:
598610
return messages[offset : offset + limit]
599611
else:
@@ -603,6 +615,33 @@ def list_messages(
603615
logger.error("Failed to list messages from AgentCore Memory: %s", e)
604616
return []
605617

618+
def _filter_restored_tool_context(self, messages: list[SessionMessage]) -> list[SessionMessage]:
619+
"""Strip historical toolUse/toolResult context from restored messages."""
620+
filtered_messages: list[SessionMessage] = []
621+
for session_message in messages:
622+
message = session_message.to_message()
623+
filtered_content = [
624+
content
625+
for content in message.get("content", [])
626+
if "toolUse" not in content and "toolResult" not in content
627+
]
628+
629+
if not filtered_content:
630+
continue
631+
632+
filtered_message: Message = {"role": message["role"], "content": filtered_content}
633+
filtered_messages.append(
634+
SessionMessage(
635+
message=filtered_message,
636+
message_id=session_message.message_id,
637+
redact_message=session_message.redact_message,
638+
created_at=session_message.created_at,
639+
updated_at=session_message.updated_at,
640+
)
641+
)
642+
643+
return filtered_messages
644+
606645
# endregion SessionRepository interface implementation
607646

608647
# region RepositorySessionManager overrides
@@ -734,7 +773,8 @@ def _flush_messages_only(self) -> list[dict[str, Any]]:
734773
This is called when the message buffer reaches batch_size.
735774
Messages are batched by session_id - all conversational messages for the same
736775
session are combined into a single create_event() call to reduce API calls.
737-
Blob messages (>9KB) are sent individually as they require a different API path.
776+
Messages that exceed the conversational payload limit are sent as blob events individually
777+
as they require a different API path.
738778
739779
Returns:
740780
list[dict[str, Any]]: List of created event responses from AgentCore Memory.

0 commit comments

Comments
 (0)