Skip to content

Add AgentCore Memory Support for Short Term Memory - #14

Merged
philasmar merged 3 commits into
devfrom
asmarp/memory-support
May 14, 2026
Merged

Add AgentCore Memory Support for Short Term Memory#14
philasmar merged 3 commits into
devfrom
asmarp/memory-support

Conversation

@philasmar

@philasmar philasmar commented May 13, 2026

Copy link
Copy Markdown
Contributor

Issue #, if available:
DOTNET-8640

Description of changes

Integrates Amazon Bedrock AgentCore Memory as a ChatHistoryProvider to give agents persistent conversation history within a session. This is session-scoped short-term memory — the agent remembers what was said earlier in the same conversation across multiple request/response cycles, surviving container restarts and scaling events. This is not long-term/semantic memory (that's a separate future feature).

What it does

When a Memory ID is configured (via options.MemoryId or the AWS_AGENTCORE_MEMORY_ID environment variable), the provider automatically:

  • Before each LLM call: Loads the full conversation history from AgentCore Memory (ListEvents) and injects it as context
  • After each LLM call: Saves the new user message and assistant response to AgentCore Memory (CreateEvent)

When no Memory ID is configured, the provider is a no-op — zero overhead, no API calls.

How session identity flows (AsyncLocal)

The memory provider needs the session ID (from the X-Amzn-Bedrock-AgentCore-Runtime-Session-Id HTTP header) to scope memory operations. The challenge: the provider runs deep inside the MS Agent Framework pipeline and doesn't have access to the HTTP request.

We solve this with an AsyncLocal<AgentCoreRuntimeContext> on AgentCoreRuntimeContextProvider.Current. The MapAgentCore endpoint handlers set this value after extracting the runtime context from HTTP headers. Since AsyncLocal flows through the async call chain, the memory provider can read it later during RunAsync without the user needing to pass anything manually. Each concurrent request gets its own isolated value — no cross-request leakage.

This means users get memory for free — no session management code, no StateBag wiring, just set a Memory ID and it works.

Usage

builder.AddAgentCore(options =>
{
    options.ModelId = "anthropic.claude-sonnet-4-20250514-v1:0";
    options.MemoryId = "memory_abc123"; // or set AWS_AGENTCORE_MEMORY_ID env var
});

Changes

  • AgentCoreMemoryProvider — new ChatHistoryProvider implementation (load via ListEvents, save via CreateEvent, pagination, error handling)
  • AgentCoreRuntimeContextProvider — added AsyncLocal<AgentCoreRuntimeContext> for ambient session context
  • AgentCoreBuilderExtensions — registers IAmazonBedrockAgentCore and AgentCoreMemoryProvider in DI
  • AgentCoreEndpointExtensions / ParameterBindingPlan — sets the AsyncLocal in all endpoint handlers
  • AgentCoreOptions — added MemoryId property
  • Constants.cs — centralized AWS_AGENTCORE_MEMORY_ID env var name
  • CloudFormation templates — added AWS::BedrockAgentCore::Memory resource and IAM permissions for integration tests
  • Integration tests — memory recall tests for all 6 sample apps
  • Property-based tests — 10 FsCheck tests validating correctness properties (conversion, filtering, pagination, error handling)

What was tested

  • 79 unit tests passing (including 10 property-based tests)
  • 8 source generator snapshot tests passing
  • NativeAOT publish with no trimming warnings
  • All sample apps build successfully
  • Integration test infrastructure updated (CloudFormation creates Memory resource, passes ID to runtimes via env var)

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

@philasmar philasmar added the Release Not Needed Add this label if a PR does not need to be released. label May 13, 2026
@philasmar
philasmar force-pushed the asmarp/memory-support branch from d785f7c to 4075851 Compare May 13, 2026 15:12
@GarrettBeatty

Copy link
Copy Markdown
Contributor

Code Review

PR #14: Add AgentCore Memory Support for Short Term Memory

Summary

This PR is well-scoped, well-tested, and ships a clean integration of AgentCore Memory as a ChatHistoryProvider for the Microsoft Agent Framework pipeline. The AsyncLocal-based session-context flow is elegant: users get session-scoped memory for free with a single Memory ID, and the provider degrades to a true no-op when none is configured. Test coverage is unusually thorough - 79 unit tests including 10 FsCheck property-based tests, plus 6 end-to-end memory recall integration tests across every sample app. Before merging, please confirm the chronological ordering of ListEvents (the loaded history feeds directly into the LLM), and address a few smaller correctness/maintainability points: the user-supplied ChatHistoryProvider is silently overwritten, FilterMessagesForStorage collapses all non-User roles to ASSISTANT (including System), some property tests are declared async void which can mask assertion failures, and the env-var-based unit tests can race under xunit parallelization.

Important Issues - Should Fix

  1. User-supplied ChatHistoryProvider is silently overwritten

    • File: src/AWS.AgentCore/Extensions/AgentCoreBuilderExtensions.cs
    • AddAgentCore unconditionally assigns agentOptions.ChatHistoryProvider = memoryProvider. If a user passes options.AgentOptions with their own ChatHistoryProvider configured, it is silently replaced by AgentCoreMemoryProvider with no warning. This is a surprising behavior that violates the typical 'user-supplied wins' principle used elsewhere in this file (e.g. options.ChatClient takes precedence).
    • Suggestion: Only assign the memory provider when agentOptions.ChatHistoryProvider is null, or compose them. At minimum, log a warning when overwriting a user-provided value.
  2. FilterMessagesForStorage maps every non-User role to ASSISTANT

    • File: src/AWS.AgentCore/AgentCoreMemoryProvider.cs
    • The role mapping uses a binary check: ChatRole.User -> Role.USER, else -> Role.ASSISTANT. ChatRole.System (and any future role like Developer) silently becomes ASSISTANT in persisted history. Tool messages are filtered out separately by HasToolContent, but System and other roles fall through. On reload this would inject pseudo-assistant messages into the conversation that the LLM never authored.
    • Suggestion: Restrict storage to messages whose role is exactly ChatRole.User or ChatRole.Assistant; skip everything else. Even simpler: add an explicit allow-list early in the loop.
  3. Verify ListEvents returns events in chronological order

    • File: src/AWS.AgentCore/AgentCoreMemoryProvider.cs
    • LoadHistoryAsync appends events in the order returned by ListEventsAsync and returns them directly to the agent pipeline as conversation history. Many AWS list APIs return most-recent-first by default. If AgentCore Memory does the same, the LLM will see history reversed (most recent first), which will materially degrade output quality. There is no test that asserts ordering across multiple events with different timestamps, only across pages with the same content.
    • Suggestion: Confirm AgentCore Memory's ListEvents ordering. If it is reverse-chronological, sort by EventTimestamp ascending after collecting all pages, or pass an explicit sort parameter if the API supports one. Add a unit test that returns events with descending timestamps and asserts the resulting message order is ascending.
  4. Property-based tests declared as async void

    • File: test/AWS.AgentCore.UnitTests/AgentCoreMemoryProviderPropertyTests.cs
    • Pagination_FetchesAllPagesInOrder, Errors_NeverPropagate_OnLoad, Errors_NeverPropagate_OnSave, and PartialPaginationFailure_ReturnsLoadedPages are declared as async void. With FsCheck.Xunit.v3, async void test methods can swallow exceptions thrown after the first await (Assert.Equal failures included), and the runner cannot reliably await them across MaxTest = 100 iterations. This means failing assertions inside these properties may pass silently and MaxTest may not re-run as expected.
    • Suggestion: Change the return type to async Task and have FsCheck await it. For boolean properties use async Task; for assertion-based bodies, async Task is fine. This is the documented pattern for FsCheck.Xunit async properties.
  5. Environment-variable-based tests are not parallel-safe

    • File: test/AWS.AgentCore.UnitTests/AgentCoreMemoryProviderTests.cs
    • GetEffectiveMemoryId_WhenOptionsNotSet_FallsBackToEnvVar, _WhenBothSet_OptionsWins, and _WhenNeitherSet_ReturnsNull all mutate AWS_AGENTCORE_MEMORY_ID. xunit runs test classes in parallel by default, and Environment.SetEnvironmentVariable affects process-wide state. If any future test (or another class in the same assembly) reads this variable concurrently, results become order-dependent. The try/finally only protects the calling test, not concurrent ones.
    • Suggestion: Disable parallelism on this test class with [Collection("EnvVar")] sharing a fixture, or use ICollectionFixture to serialize them. Better: refactor GetEffectiveMemoryId to take an env-var accessor delegate (or IConfiguration) so tests can inject the value without touching process state.
  6. ActorId and SessionId both set to the same session ID

    • File: src/AWS.AgentCore/AgentCoreMemoryProvider.cs
    • Both ListEventsRequest and CreateEventRequest set ActorId = sessionId and SessionId = sessionId. In AgentCore Memory's data model these are distinct concepts: the actor is the user/agent identity, the session is one conversation belonging to that actor. Conflating them means a single user with multiple sessions cannot have actor-scoped queries work as designed, and any future long-term memory feature that pivots on actor identity will need to re-architect this. This is also not documented in the PR description.
    • Suggestion: Either expose an option for ActorId (with SessionId defaulting to the AgentCore Runtime session header), or document explicitly that this implementation deliberately uses session-as-actor and explain the trade-off. Adding a second AsyncLocal-flowed UserId/ActorId would be straightforward.

Minor Issues

  1. Warning message references only the StateBag, not the AsyncLocal fallback in src/AWS.AgentCore/AgentCoreMemoryProvider.cs
  2. DateTime.UtcNow used directly in SaveEventAsync in src/AWS.AgentCore/AgentCoreMemoryProvider.cs
  3. Unused message parameter in tests passed to TryConvertEventToChatMessage in test/AWS.AgentCore.UnitTests/AgentCoreMemoryProviderPropertyTests.cs

Positive Notes

  • Excellent test depth - 10 FsCheck property-based tests covering conversion, filtering, pagination ordering, and partial-failure semantics is well above typical bar.
  • AsyncLocal-based session context flow is a clean solution to the 'how do I plumb the session ID into a deeply nested provider' problem, and the per-request isolation argument in the PR description is correct.
  • Strong default behavior: no MemoryId means zero API calls and zero overhead - the feature opts itself out cleanly.
  • Memory load failures fall back to empty history rather than failing the request, and partial pagination errors return what was loaded - the right call for a non-essential history feature.
  • Integration tests cover every sample app variant (annotations, source-gen, NativeAOT, streaming) - strong cross-cut validation.

Recommendation

Request changes - please address the important issues listed above.

@philasmar

Copy link
Copy Markdown
Contributor Author

Code Review

PR #14: Add AgentCore Memory Support for Short Term Memory

Summary

This PR is well-scoped, well-tested, and ships a clean integration of AgentCore Memory as a ChatHistoryProvider for the Microsoft Agent Framework pipeline. The AsyncLocal-based session-context flow is elegant: users get session-scoped memory for free with a single Memory ID, and the provider degrades to a true no-op when none is configured. Test coverage is unusually thorough - 79 unit tests including 10 FsCheck property-based tests, plus 6 end-to-end memory recall integration tests across every sample app. Before merging, please confirm the chronological ordering of ListEvents (the loaded history feeds directly into the LLM), and address a few smaller correctness/maintainability points: the user-supplied ChatHistoryProvider is silently overwritten, FilterMessagesForStorage collapses all non-User roles to ASSISTANT (including System), some property tests are declared async void which can mask assertion failures, and the env-var-based unit tests can race under xunit parallelization.

Important Issues - Should Fix

  1. User-supplied ChatHistoryProvider is silently overwritten

    • File: src/AWS.AgentCore/Extensions/AgentCoreBuilderExtensions.cs
    • AddAgentCore unconditionally assigns agentOptions.ChatHistoryProvider = memoryProvider. If a user passes options.AgentOptions with their own ChatHistoryProvider configured, it is silently replaced by AgentCoreMemoryProvider with no warning. This is a surprising behavior that violates the typical 'user-supplied wins' principle used elsewhere in this file (e.g. options.ChatClient takes precedence).
    • Suggestion: Only assign the memory provider when agentOptions.ChatHistoryProvider is null, or compose them. At minimum, log a warning when overwriting a user-provided value.
  2. FilterMessagesForStorage maps every non-User role to ASSISTANT

    • File: src/AWS.AgentCore/AgentCoreMemoryProvider.cs
    • The role mapping uses a binary check: ChatRole.User -> Role.USER, else -> Role.ASSISTANT. ChatRole.System (and any future role like Developer) silently becomes ASSISTANT in persisted history. Tool messages are filtered out separately by HasToolContent, but System and other roles fall through. On reload this would inject pseudo-assistant messages into the conversation that the LLM never authored.
    • Suggestion: Restrict storage to messages whose role is exactly ChatRole.User or ChatRole.Assistant; skip everything else. Even simpler: add an explicit allow-list early in the loop.
  3. Verify ListEvents returns events in chronological order

    • File: src/AWS.AgentCore/AgentCoreMemoryProvider.cs
    • LoadHistoryAsync appends events in the order returned by ListEventsAsync and returns them directly to the agent pipeline as conversation history. Many AWS list APIs return most-recent-first by default. If AgentCore Memory does the same, the LLM will see history reversed (most recent first), which will materially degrade output quality. There is no test that asserts ordering across multiple events with different timestamps, only across pages with the same content.
    • Suggestion: Confirm AgentCore Memory's ListEvents ordering. If it is reverse-chronological, sort by EventTimestamp ascending after collecting all pages, or pass an explicit sort parameter if the API supports one. Add a unit test that returns events with descending timestamps and asserts the resulting message order is ascending.
  4. Property-based tests declared as async void

    • File: test/AWS.AgentCore.UnitTests/AgentCoreMemoryProviderPropertyTests.cs
    • Pagination_FetchesAllPagesInOrder, Errors_NeverPropagate_OnLoad, Errors_NeverPropagate_OnSave, and PartialPaginationFailure_ReturnsLoadedPages are declared as async void. With FsCheck.Xunit.v3, async void test methods can swallow exceptions thrown after the first await (Assert.Equal failures included), and the runner cannot reliably await them across MaxTest = 100 iterations. This means failing assertions inside these properties may pass silently and MaxTest may not re-run as expected.
    • Suggestion: Change the return type to async Task and have FsCheck await it. For boolean properties use async Task; for assertion-based bodies, async Task is fine. This is the documented pattern for FsCheck.Xunit async properties.
  5. Environment-variable-based tests are not parallel-safe

    • File: test/AWS.AgentCore.UnitTests/AgentCoreMemoryProviderTests.cs
    • GetEffectiveMemoryId_WhenOptionsNotSet_FallsBackToEnvVar, _WhenBothSet_OptionsWins, and _WhenNeitherSet_ReturnsNull all mutate AWS_AGENTCORE_MEMORY_ID. xunit runs test classes in parallel by default, and Environment.SetEnvironmentVariable affects process-wide state. If any future test (or another class in the same assembly) reads this variable concurrently, results become order-dependent. The try/finally only protects the calling test, not concurrent ones.
    • Suggestion: Disable parallelism on this test class with [Collection("EnvVar")] sharing a fixture, or use ICollectionFixture to serialize them. Better: refactor GetEffectiveMemoryId to take an env-var accessor delegate (or IConfiguration) so tests can inject the value without touching process state.
  6. ActorId and SessionId both set to the same session ID

    • File: src/AWS.AgentCore/AgentCoreMemoryProvider.cs
    • Both ListEventsRequest and CreateEventRequest set ActorId = sessionId and SessionId = sessionId. In AgentCore Memory's data model these are distinct concepts: the actor is the user/agent identity, the session is one conversation belonging to that actor. Conflating them means a single user with multiple sessions cannot have actor-scoped queries work as designed, and any future long-term memory feature that pivots on actor identity will need to re-architect this. This is also not documented in the PR description.
    • Suggestion: Either expose an option for ActorId (with SessionId defaulting to the AgentCore Runtime session header), or document explicitly that this implementation deliberately uses session-as-actor and explain the trade-off. Adding a second AsyncLocal-flowed UserId/ActorId would be straightforward.

Minor Issues

  1. Warning message references only the StateBag, not the AsyncLocal fallback in src/AWS.AgentCore/AgentCoreMemoryProvider.cs
  2. DateTime.UtcNow used directly in SaveEventAsync in src/AWS.AgentCore/AgentCoreMemoryProvider.cs
  3. Unused message parameter in tests passed to TryConvertEventToChatMessage in test/AWS.AgentCore.UnitTests/AgentCoreMemoryProviderPropertyTests.cs

Positive Notes

  • Excellent test depth - 10 FsCheck property-based tests covering conversion, filtering, pagination ordering, and partial-failure semantics is well above typical bar.
  • AsyncLocal-based session context flow is a clean solution to the 'how do I plumb the session ID into a deeply nested provider' problem, and the per-request isolation argument in the PR description is correct.
  • Strong default behavior: no MemoryId means zero API calls and zero overhead - the feature opts itself out cleanly.
  • Memory load failures fall back to empty history rather than failing the request, and partial pagination errors return what was loaded - the right call for a non-essential history feature.
  • Integration tests cover every sample app variant (annotations, source-gen, NativeAOT, streaming) - strong cross-cut validation.

Recommendation

Request changes - please address the important issues listed above.

Thanks for the thorough review. All important issues have been addressed:

Important Issues

1. User-supplied ChatHistoryProvider silently overwritten

Fixed. Changed to agentOptions.ChatHistoryProvider ??= memoryProvider so user-supplied providers are preserved. The memory provider only applies when the user hasn't configured their own.

2. FilterMessagesForStorage maps every non-User role to ASSISTANT

Fixed. Added an explicit allow-list: only ChatRole.User and ChatRole.Assistant are persisted. System, Tool, and any other roles are skipped with continue early in the loop.

3. Verify ListEvents returns events in chronological order

Confirmed. AgentCore Memory's ListEvents returns events in chronological order (oldest first). Added a comment in LoadHistoryAsync documenting this. The integration tests also validate ordering implicitly — the tell-then-ask pattern wouldn't work if history were reversed.

4. Property-based tests declared as async void

Already async Task. The current code uses public async Task for all async property tests (Pagination_FetchesAllPagesInOrder, Errors_NeverPropagate_OnLoad, Errors_NeverPropagate_OnSave, PartialPaginationFailure_ReturnsLoadedPages).

5. Environment-variable-based tests are not parallel-safe

Fixed. Added [Collection("EnvironmentVariableTests")] to the test class to serialize execution of env-var-mutating tests.

6. ActorId and SessionId both set to the same session ID

Intentional — documented with inline comments. The AgentCore Runtime doesn't provide a separate user/actor identity header. The only identity available at request time is the session ID. Added comments in both LoadHistoryAsync and SaveEventAsync explaining this trade-off. When long-term memory is added (which pivots on actor identity), we'll introduce an ActorId option. This is additive and won't require breaking changes to the current API surface.

Minor Issues

  1. Warning message — Already updated to reference MapAgentCore endpoint requirement rather than StateBag.
  2. DateTime.UtcNow — This is metadata for the Memory service's event ordering/expiry. We never read it back or assert on it. Injecting TimeProvider would add complexity with no testable benefit.
  3. "Unused message parameter" — It's not unused. The test asserts on message.Role and message.Text after the out var message call on line 58. The second usage on line 91 correctly uses the discard out _ since it only checks the boolean return.

Comment thread src/AWS.AgentCore/AgentCoreRuntimeContextProvider.cs Outdated
Comment thread src/AWS.AgentCore/AgentCoreMemoryProvider.cs Outdated
Comment thread src/AWS.AgentCore/AgentCoreMemoryProvider.cs Outdated
catch (Exception ex) when (nextToken is not null)
{
// Partial pagination failure — return what we have
logger.LogWarning(ex, "Error fetching page during pagination. Returning {Count} messages loaded so far.", messages.Count);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should there be an option whether to treat exceptions reading and writing the history as fatal or was there some precedence in the community you were following to say these should be treated as just log messages.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "log and continue" approach follows the same pattern used by Strands Agents SDK (Python) and LangChain's memory implementations — memory is treated as a non-essential enhancement, not a critical path dependency. The reasoning:

  1. An agent without memory is still useful — it just doesn't remember prior turns. A failed memory load shouldn't prevent the agent from answering the current question.
  2. Memory save failures are even less critical — the current response has already been generated and returned to the user. Failing to persist it means the next turn won't have that context, but the current request succeeds.
  3. The alternative (fatal) is worse for production — if the Memory service has a transient outage, every agent invocation would fail with a 500, even though the agent could still answer questions perfectly well without history.

That said, some users might want strict mode where memory failures are fatal (e.g., compliance scenarios where incomplete history is unacceptable). We could add a MemoryErrorBehavior option (LogAndContinue vs ThrowOnError) in a follow-up if there's demand. For now, the safe default is graceful degradation.

IEnumerable<ChatMessage>? requestMessages,
IEnumerable<ChatMessage>? responseMessages)
{
var allMessages = (requestMessages ?? []).Concat(responseMessages ?? []);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably my ignorance here but won't some the requestMessages already be saved when they were loaded from the chat history or is requestMessages only the new messages brought in for the request?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MAF handles this. The base InvokedCoreAsync implementation filters out messages that were produced by a ChatHistoryProvider (stamped with AgentRequestMessageSourceType.ChatHistory) before passing them to StoreChatHistoryAsync. So context.RequestMessages only contains new messages from the current turn, not previously loaded history.

@philasmar
philasmar marked this pull request as ready for review May 14, 2026 14:04
@philasmar
philasmar requested review from a team as code owners May 14, 2026 14:04
@philasmar
philasmar requested a review from normj May 14, 2026 14:04
@philasmar
philasmar merged commit d6caba1 into dev May 14, 2026
3 checks passed
@dscpinheiro
dscpinheiro deleted the asmarp/memory-support branch June 29, 2026 02:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Release Not Needed Add this label if a PR does not need to be released.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants