Skip to content

Commit d00706e

Browse files
committed
fix: address PR review feedback
- Auto-normalize plain string metadata values to {"stringValue": ...} so users can write {"project": "atlas"} instead of the verbose form. Applied via pydantic validator on default_metadata and at runtime for metadata_provider return values. - Move inline datetime imports to top of test file (nit from Hweinstock) - Fix lint/format issues that caused CI Lint and Format check to fail - Add tests for normalization in both config and session manager
1 parent feefb4d commit d00706e

5 files changed

Lines changed: 110 additions & 25 deletions

File tree

src/bedrock_agentcore/memory/integrations/strands/README.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -257,15 +257,18 @@ config = AgentCoreMemoryConfig(
257257
session_id=SESSION_ID,
258258
actor_id=ACTOR_ID,
259259
default_metadata={
260-
"project": {"stringValue": "atlas"},
261-
"env": {"stringValue": "production"},
260+
"project": "atlas",
261+
"env": "production",
262262
},
263263
)
264264
session_manager = AgentCoreMemorySessionManager(config, region_name='us-east-1')
265265
agent = Agent(session_manager=session_manager)
266266
agent("Hello!") # This event will have project=atlas and env=production metadata
267267
```
268268

269+
> Plain strings are auto-wrapped to `{"stringValue": "..."}`. The explicit form
270+
> `{"project": {"stringValue": "atlas"}}` also works.
271+
269272
### Dynamic Metadata (metadata_provider)
270273

271274
For values that change per invocation (e.g., traceId for Langfuse), use `metadata_provider`
@@ -275,7 +278,7 @@ a callable invoked at each event creation:
275278
from langfuse.decorators import langfuse_context
276279

277280
def get_trace_metadata():
278-
return {"traceId": {"stringValue": langfuse_context.get_current_trace_id() or ""}}
281+
return {"traceId": langfuse_context.get_current_trace_id() or ""}
279282

280283
config = AgentCoreMemoryConfig(
281284
memory_id=MEM_ID,
@@ -296,7 +299,7 @@ with `default_metadata` and `metadata_provider` (per-call values override both f
296299
```python
297300
session_manager.create_message(
298301
session_id, agent_id, message,
299-
metadata={"priority": {"stringValue": "high"}},
302+
metadata={"priority": "high"},
300303
)
301304
```
302305

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

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@
22

33
from typing import Any, Callable, Dict, Optional
44

5-
from pydantic import BaseModel, ConfigDict, Field
5+
from pydantic import BaseModel, ConfigDict, Field, field_validator
6+
7+
8+
def normalize_metadata(raw: Dict[str, Any]) -> Dict[str, Any]:
9+
"""Normalize metadata values: plain strings become {"stringValue": value}."""
10+
return {k: {"stringValue": v} if isinstance(v, str) else v for k, v in raw.items()}
611

712

813
class RetrievalConfig(BaseModel):
@@ -40,10 +45,12 @@ class AgentCoreMemoryConfig(BaseModel):
4045
restored messages before loading them into Strands runtime memory. Default is False.
4146
default_metadata: Optional default metadata key-value pairs to attach to every message event.
4247
Merged with any per-call metadata. Maximum 15 total keys per event (including internal keys).
43-
Example: {"location": {"stringValue": "NYC"}}
48+
Accepts plain strings (auto-wrapped) or explicit MetadataValue dicts.
49+
Example: {"location": "NYC"} or {"location": {"stringValue": "NYC"}}
4450
metadata_provider: Optional callable that returns metadata key-value pairs. Called at each
4551
event creation, so it can return dynamic values (e.g. current traceId). The returned
4652
dict is merged after default_metadata but before per-call metadata.
53+
Accepts plain strings (auto-wrapped) or explicit MetadataValue dicts.
4754
"""
4855

4956
model_config = ConfigDict(arbitrary_types_allowed=True)
@@ -58,3 +65,10 @@ class AgentCoreMemoryConfig(BaseModel):
5865
filter_restored_tool_context: bool = Field(default=False)
5966
default_metadata: Optional[Dict[str, Any]] = None
6067
metadata_provider: Optional[Callable[[], Dict[str, Any]]] = None
68+
69+
@field_validator("default_metadata", mode="before")
70+
@classmethod
71+
def _normalize_default_metadata(cls, v: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
72+
if v is None:
73+
return None
74+
return normalize_metadata(v)

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

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
)
3030

3131
from .bedrock_converter import AgentCoreMemoryConverter
32-
from .config import AgentCoreMemoryConfig, RetrievalConfig
32+
from .config import AgentCoreMemoryConfig, RetrievalConfig, normalize_metadata
3333
from .converters import MemoryConverter
3434

3535
if TYPE_CHECKING:
@@ -215,7 +215,7 @@ def _build_metadata(
215215
merged.update(self.config.default_metadata)
216216

217217
if self.config.metadata_provider:
218-
merged.update(self.config.metadata_provider())
218+
merged.update(normalize_metadata(self.config.metadata_provider()))
219219

220220
if per_call_metadata:
221221
merged.update(per_call_metadata)
@@ -224,17 +224,14 @@ def _build_metadata(
224224
user_reserved = RESERVED_METADATA_KEYS & merged.keys()
225225
if user_reserved:
226226
raise ValueError(
227-
f"Metadata keys {user_reserved} are reserved for internal use. "
228-
f"Reserved keys: {RESERVED_METADATA_KEYS}"
227+
f"Metadata keys {user_reserved} are reserved for internal use. Reserved keys: {RESERVED_METADATA_KEYS}"
229228
)
230229

231230
if internal_metadata:
232231
merged.update(internal_metadata)
233232

234233
if len(merged) > MAX_METADATA_KEYS:
235-
raise ValueError(
236-
f"Combined metadata has {len(merged)} keys, exceeding the maximum of {MAX_METADATA_KEYS}."
237-
)
234+
raise ValueError(f"Combined metadata has {len(merged)} keys, exceeding the maximum of {MAX_METADATA_KEYS}.")
238235

239236
return merged or None
240237

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

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,3 +87,39 @@ def test_with_retrieval_config(self):
8787
memory_id="mem-123", session_id="sess-456", actor_id="actor-789", retrieval_config={"namespace1": retrieval}
8888
)
8989
assert config.retrieval_config["namespace1"].top_k == 5
90+
91+
def test_default_metadata_plain_strings_normalized(self):
92+
"""Plain string values are auto-wrapped to {"stringValue": ...}."""
93+
config = AgentCoreMemoryConfig(
94+
memory_id="mem-123",
95+
session_id="sess-456",
96+
actor_id="actor-789",
97+
default_metadata={"project": "atlas", "env": "prod"},
98+
)
99+
assert config.default_metadata == {
100+
"project": {"stringValue": "atlas"},
101+
"env": {"stringValue": "prod"},
102+
}
103+
104+
def test_default_metadata_explicit_format_unchanged(self):
105+
"""Explicit {"stringValue": ...} dicts pass through unchanged."""
106+
config = AgentCoreMemoryConfig(
107+
memory_id="mem-123",
108+
session_id="sess-456",
109+
actor_id="actor-789",
110+
default_metadata={"project": {"stringValue": "atlas"}},
111+
)
112+
assert config.default_metadata == {"project": {"stringValue": "atlas"}}
113+
114+
def test_default_metadata_mixed_formats(self):
115+
"""Mixed plain strings and explicit dicts both work."""
116+
config = AgentCoreMemoryConfig(
117+
memory_id="mem-123",
118+
session_id="sess-456",
119+
actor_id="actor-789",
120+
default_metadata={"project": "atlas", "env": {"stringValue": "prod"}},
121+
)
122+
assert config.default_metadata == {
123+
"project": {"stringValue": "atlas"},
124+
"env": {"stringValue": "prod"},
125+
}

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

Lines changed: 47 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import logging
44
import time
5+
from datetime import datetime, timezone
56
from unittest.mock import Mock, patch
67

78
import pytest
@@ -1680,8 +1681,6 @@ def test__flush_messages_multiple_sessions_grouped_into_separate_api_calls(
16801681
so all messages go to one session. This test verifies the internal grouping logic
16811682
by directly manipulating the buffer.
16821683
"""
1683-
from datetime import datetime, timezone
1684-
16851684
calls_by_session = {}
16861685

16871686
def track_create_event(**kwargs):
@@ -1749,8 +1748,6 @@ def track_create_event(**kwargs):
17491748
# The combined event should use the latest timestamp (12:10:00)
17501749
assert len(captured_timestamps) == 1
17511750
# The timestamp should be the latest one (12:10:00)
1752-
from datetime import datetime, timezone
1753-
17541751
expected_latest = datetime(2024, 1, 1, 12, 10, 0, tzinfo=timezone.utc)
17551752
# Account for monotonic timestamp adjustment (may add microseconds)
17561753
assert captured_timestamps[0] >= expected_latest
@@ -1762,7 +1759,6 @@ def test__flush_messages_partial_failure_multiple_sessions_preserves_buffer(
17621759
17631760
Note: Tests internal grouping logic by directly manipulating buffer.
17641761
"""
1765-
from datetime import datetime, timezone
17661762

17671763
def fail_on_second_session(**kwargs):
17681764
session_id = kwargs.get("sessionId")
@@ -1830,8 +1826,6 @@ def test_mixed_sessions_with_blobs_and_conversational(self, batching_session_man
18301826
18311827
Note: Tests internal grouping logic by directly manipulating buffer.
18321828
"""
1833-
from datetime import datetime, timezone
1834-
18351829
calls_by_session = {}
18361830

18371831
def track_create_event(**kwargs):
@@ -2543,7 +2537,12 @@ def test_after_invocation_hook_flushes_buffer(self, batching_session_manager, mo
25432537
# Add messages to buffer
25442538
with batching_session_manager._message_lock:
25452539
batching_session_manager._message_buffer.append(
2546-
BufferedMessage("test-session", [("user", "test message")], False, batching_session_manager._get_monotonic_timestamp())
2540+
BufferedMessage(
2541+
"test-session",
2542+
[("user", "test message")],
2543+
False,
2544+
batching_session_manager._get_monotonic_timestamp(),
2545+
)
25472546
)
25482547

25492548
assert batching_session_manager.pending_message_count() == 1
@@ -2766,7 +2765,9 @@ def test_interval_flush_callback_flushes_when_buffer_has_messages(self):
27662765
# Add messages to buffer
27672766
with manager._message_lock:
27682767
manager._message_buffer.append(
2769-
BufferedMessage("test-session", [("user", "test message")], False, manager._get_monotonic_timestamp())
2768+
BufferedMessage(
2769+
"test-session", [("user", "test message")], False, manager._get_monotonic_timestamp()
2770+
)
27702771
)
27712772

27722773
assert manager.pending_message_count() == 1
@@ -2921,7 +2922,9 @@ def test_interval_flush_callback_flushes_when_both_buffers_have_data(self):
29212922
# Add both messages and agent state to buffers
29222923
with manager._message_lock:
29232924
manager._message_buffer.append(
2924-
BufferedMessage("test-session", [("user", "test message")], False, manager._get_monotonic_timestamp())
2925+
BufferedMessage(
2926+
"test-session", [("user", "test message")], False, manager._get_monotonic_timestamp()
2927+
)
29252928
)
29262929

29272930
from strands.types.session import SessionAgent
@@ -3079,8 +3082,6 @@ def test_create_message_no_metadata_passes_none(self, session_manager, mock_memo
30793082

30803083
def test_batched_messages_include_metadata(self, mock_memory_client):
30813084
"""Metadata flows through the batching path and appears in the flushed event."""
3082-
from datetime import datetime, timezone
3083-
30843085
config = AgentCoreMemoryConfig(
30853086
memory_id="test-memory-123",
30863087
session_id="test-session-456",
@@ -3199,3 +3200,37 @@ def test_metadata_provider_reserved_keys_rejected(self, mock_memory_client):
31993200
msg = SessionMessage.from_message({"role": "user", "content": [{"text": "hello"}]}, 0)
32003201
with pytest.raises(ValueError, match="reserved"):
32013202
manager.create_message("test-session-456", "agent-1", msg)
3203+
3204+
def test_metadata_provider_plain_strings_normalized(self, mock_memory_client):
3205+
"""metadata_provider returning plain strings gets auto-normalized."""
3206+
config = AgentCoreMemoryConfig(
3207+
memory_id="test-memory-123",
3208+
session_id="test-session-456",
3209+
actor_id="test-actor-789",
3210+
metadata_provider=lambda: {"traceId": "trace-abc"},
3211+
)
3212+
manager = _create_session_manager(config, mock_memory_client)
3213+
mock_memory_client.create_event.return_value = {"eventId": "evt_1"}
3214+
3215+
msg = SessionMessage.from_message({"role": "user", "content": [{"text": "hello"}]}, 0)
3216+
manager.create_message("test-session-456", "agent-1", msg)
3217+
3218+
kwargs = mock_memory_client.create_event.call_args[1]
3219+
assert kwargs["metadata"]["traceId"] == {"stringValue": "trace-abc"}
3220+
3221+
def test_default_metadata_plain_strings_normalized(self, mock_memory_client):
3222+
"""default_metadata with plain strings gets auto-normalized at config time."""
3223+
config = AgentCoreMemoryConfig(
3224+
memory_id="test-memory-123",
3225+
session_id="test-session-456",
3226+
actor_id="test-actor-789",
3227+
default_metadata={"project": "atlas"},
3228+
)
3229+
manager = _create_session_manager(config, mock_memory_client)
3230+
mock_memory_client.create_event.return_value = {"eventId": "evt_1"}
3231+
3232+
msg = SessionMessage.from_message({"role": "user", "content": [{"text": "hello"}]}, 0)
3233+
manager.create_message("test-session-456", "agent-1", msg)
3234+
3235+
kwargs = mock_memory_client.create_event.call_args[1]
3236+
assert kwargs["metadata"]["project"] == {"stringValue": "atlas"}

0 commit comments

Comments
 (0)