feat(strands-memory): add event metadata support to AgentCoreMemorySessionManager#339
feat(strands-memory): add event metadata support to AgentCoreMemorySessionManager#339
Conversation
…ntCoreMemorySessionManager Allow users to attach custom key-value metadata to conversation events via a new `default_metadata` config field and per-call `metadata` kwarg. Metadata is merged (per-call > config defaults > internal) and validated against reserved keys and the 15-key API limit. Also refactors the internal message buffer from a raw tuple to a `BufferedMessage` NamedTuple for clarity and extensibility. Closes #149 (Phase 1: Metadata)
| Default is "user_context". | ||
| filter_restored_tool_context: When True, strip historical toolUse/toolResult blocks from | ||
| restored messages before loading them into Strands runtime memory. Default is False. | ||
| default_metadata: Optional default metadata key-value pairs to attach to every message event. |
There was a problem hiding this comment.
Does this actually solve the customer's ask?
What if they need different metadata for each message event? Also, what exactly do they mean by "message_event" — are they
referring to memory records, AgentCore Memory events, or individual conversation turns? Are they trying to attach a distinct metadata field to each conversation turn?
There was a problem hiding this comment.
are we sure this is the interface the customer is looking for? Could we ask them to send an example code block of the support they want?
There was a problem hiding this comment.
Yes, it solves the ask. metadata_provider is a callable invoked at each create_message(), so each event gets whatever traceId is current at that moment. We have an integration test confirming two invocations with different traceIds produce disjoint, independently filterable event sets.
Per-turn metadata works out of the box with batch_size=1 (the default) since each turn = its own event. With batch_size > 1 multiple turns collapse into one event, so the last traceId wins — but that's an inherent tradeoff of batching, not a metadata limitation.
The customer is talking about STM events, not LTM records (those are extracted async and don't carry event metadata).
There was a problem hiding this comment.
Yes — the customer's use case is tagging events with a traceId from Langfuse that changes per invocation. metadata_provider (a callable) gives them exactly that. We have an integ test that confirms two invocations with different traceIds produce disjoint event sets filterable by list_events.
…n metadata Add `metadata_provider` config field — a callable invoked at each event creation, enabling dynamic metadata like traceId that changes per agent invocation. This solves the Langfuse/user-feedback use case where a static `default_metadata` is insufficient because Strands controls the append_message → create_message call path. Merge precedence: default_metadata < metadata_provider() < per-call kwargs < internal keys.
| session_id=SESSION_ID, | ||
| actor_id=ACTOR_ID, | ||
| default_metadata={ | ||
| "project": {"stringValue": "atlas"}, |
There was a problem hiding this comment.
would we be able to build this map on behalf of the customer? It feels very verbose.
Ex:
{"project" : "atlas"} --> {"project" : { "stringValue": "atlas"}}
There was a problem hiding this comment.
Good call. Added auto-normalization — plain strings are now auto-wrapped:
{"project": "atlas"} → {"project": {"stringValue": "atlas"}}Both forms accepted. Updated the README examples to use the simpler format.
| RESERVED_METADATA_KEYS = frozenset({STATE_TYPE_KEY, AGENT_ID_KEY}) | ||
|
|
||
|
|
||
| class BufferedMessage(NamedTuple): |
There was a problem hiding this comment.
nice, I agree with the decision to add some structure here.
|
|
||
| def test_metadata_reserved_keys_rejected(self, session_manager): | ||
| """ValueError raised when user metadata contains reserved keys.""" | ||
| from bedrock_agentcore.memory.integrations.strands.session_manager import RESERVED_METADATA_KEYS |
There was a problem hiding this comment.
nit: personally still new to python, but in most languages I'm used to seeing imports at the top unless we have a strong reason not to. lmk if python convention is different.
There was a problem hiding this comment.
Moved all inline from datetime import ... to the top of the test file. You're right — top-level is the Python convention too.
| Default is "user_context". | ||
| filter_restored_tool_context: When True, strip historical toolUse/toolResult blocks from | ||
| restored messages before loading them into Strands runtime memory. Default is False. | ||
| default_metadata: Optional default metadata key-value pairs to attach to every message event. |
There was a problem hiding this comment.
are we sure this is the interface the customer is looking for? Could we ask them to send an example code block of the support they want?
| flush_interval_seconds: Optional[float] = Field(default=None, gt=0) | ||
| context_tag: str = Field(default="user_context", min_length=1) | ||
| filter_restored_tool_context: bool = Field(default=False) | ||
| default_metadata: Optional[Dict[str, Any]] = None |
There was a problem hiding this comment.
Is there a reason we need Any here instead of the MetadataValue used internally?
There was a problem hiding this comment.
Tried switching to MetadataValue (a TypedDict) but Pydantic on Python < 3.12 rejects TypedDict from typing in model fields. Kept Any in the type annotation but added a field_validator that normalizes plain strings at config construction time, and normalize_metadata() at runtime for metadata_provider output. So the internal plumbing always gets the right shape regardless of what the user passes.
| with `default_metadata` and `metadata_provider` (per-call values override both for the same key): | ||
|
|
||
| ```python | ||
| session_manager.create_message( |
There was a problem hiding this comment.
what happens when we flush messages in a batch and the metadata is different on each message? Does all the metadata get merged?
There was a problem hiding this comment.
When messages in a batch have different metadata, the metadata dicts are merged (later message's keys override earlier ones for the same key). So with batch_size > 1, the last value for each key wins in the combined event. This is documented in the batching tradeoff — with batch_size=1 (the default) each turn gets its own event with its own metadata, so no merging occurs.
- 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
Hweinstock
left a comment
There was a problem hiding this comment.
LGTM! probably worth getting a quick review from @jariy17 as well since he is more knowledgable here.
Pydantic v2 handles Callable natively, so arbitrary_types_allowed is not needed. Removing it avoids any risk of breaking subclasses or downstream validators.
Summary
Adds user-supplied event metadata support to
AgentCoreMemorySessionManager(Phase 1 of #149).Static metadata:
default_metadatafield onAgentCoreMemoryConfig— attaches custom key-value metadata to every message eventDynamic metadata (for traceId / Langfuse integration):
metadata_providerfield — a callable invoked at each event creation, so it can return per-invocation values (e.g. current traceId). This is needed because Strands controls theappend_message→create_messagecall path, so users can't pass per-call kwargs throughagent().default_metadata<metadata_provider()< per-callmetadatakwarg < internal keysInfrastructure:
_build_metadata()helper with validation: rejects reserved keys (stateType,agentId), enforces 15-key API limit_message_bufferfrom raw tuple toBufferedMessageNamedTuple for clarity and extensibilityUsage example (Langfuse traceId)
Test plan
TestMetadataSupport(default metadata, per-call, merge precedence, reserved keys, max keys, no-metadata, batched, blob, provider called per event, provider merge with defaults, provider reserved keys rejected)BufferedMessagemigration)Related