diff --git a/.kiro/specs/agentcore-memory/.config.kiro b/.kiro/specs/agentcore-memory/.config.kiro new file mode 100644 index 0000000..daf3917 --- /dev/null +++ b/.kiro/specs/agentcore-memory/.config.kiro @@ -0,0 +1 @@ +{"specId": "9f04c463-64ab-4cc2-8d4c-11793d775325", "workflowType": "requirements-first", "specType": "feature"} diff --git a/.kiro/specs/agentcore-memory/design.md b/.kiro/specs/agentcore-memory/design.md new file mode 100644 index 0000000..2080b78 --- /dev/null +++ b/.kiro/specs/agentcore-memory/design.md @@ -0,0 +1,633 @@ +# Design Document: AgentCore Memory Integration + +## Overview + +This design integrates the Amazon Bedrock AgentCore Memory service into AWS.AgentCore as a `ChatHistoryProvider` within the Microsoft Agent Framework pipeline. The provider automatically loads conversation history before each agent run and saves new messages after, giving agents persistent multi-turn memory that survives container restarts and scaling events. + +### Goals + +1. Implement `AgentCoreMemoryProvider` as a `ChatHistoryProvider` that bridges AgentCore Memory APIs (ListEvents/CreateEvent) into the MS AF pipeline +2. Automatically register the provider via `AddAgentCore()` with zero additional user code +3. Gracefully degrade to pass-through mode when MemoryId is not configured +4. Filter tool-call/tool-result messages and empty-text messages from persistence +5. Handle pagination for long conversation histories +6. Maintain NativeAOT compatibility with source-generated JSON +7. Ensure concurrent request isolation via per-request session state (no shared mutable state) + +### Non-Goals + +- Conversation windowing or summarization (separate future feature) +- Long-term memory / semantic search (AgentCore Memory Records, separate feature) +- Custom branching strategies (use default branch) +- Exposing the Memory client directly to users (internal implementation detail) + +## Architecture + +```mermaid +graph TD + subgraph "Request Pipeline" + A[POST /invocations] --> B[Extract Headers → AgentCoreRuntimeContext] + B --> C[Handler creates AgentSession] + C --> D[Store RuntimeContext in StateBag] + D --> E[agent.RunAsync] + end + + subgraph "MS AF Agent Pipeline" + E --> F[AgentCoreRuntimeContextProvider] + F --> G[AgentCoreMemoryProvider.InvokingCoreAsync] + G --> H["ProvideChatHistoryAsync (load)"] + H --> I[LLM Invocation] + I --> J[AgentCoreMemoryProvider.InvokedCoreAsync] + J --> K["StoreChatHistoryAsync (save)"] + end + + subgraph "AgentCore Memory Service" + H -->|ListEvents paginated| L[Memory API] + K -->|CreateEvent x2| L + end +``` + +### Data Flow: Load History + +```mermaid +sequenceDiagram + participant Pipeline as MS AF Pipeline + participant Provider as AgentCoreMemoryProvider + participant Memory as AgentCore Memory API + + Pipeline->>Provider: ProvideChatHistoryAsync(context) + Provider->>Provider: Get SessionId from StateBag + alt MemoryId not configured OR SessionId missing + Provider-->>Pipeline: return empty [] + else MemoryId configured + loop Until no NextToken + Provider->>Memory: ListEvents(memoryId, actorId, sessionId, includePayloads=true) + Memory-->>Provider: events[] + nextToken? + end + Provider->>Provider: Convert events to ChatMessages (filter non-text) + Provider-->>Pipeline: return ChatMessage[] + end +``` + +### Data Flow: Save Messages + +```mermaid +sequenceDiagram + participant Pipeline as MS AF Pipeline + participant Provider as AgentCoreMemoryProvider + participant Memory as AgentCore Memory API + + Pipeline->>Provider: StoreChatHistoryAsync(context) + Provider->>Provider: Get SessionId from StateBag + alt MemoryId not configured OR SessionId missing + Provider-->>Pipeline: return (no-op) + else MemoryId configured + Provider->>Provider: Filter messages (skip tool-call, tool-result, empty text) + loop For each valid message + Provider->>Memory: CreateEvent(memoryId, sessionId, actorId, payload) + Memory-->>Provider: event created + end + Provider-->>Pipeline: return + end +``` + +## Components and Interfaces + +### New Classes + +#### `AgentCoreMemoryProvider` (public) + +The core component — a `ChatHistoryProvider` that bridges AgentCore Memory into the MS AF pipeline. + +```csharp +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Amazon.BedrockAgentCore; +using Amazon.BedrockAgentCore.Model; + +namespace AWS.AgentCore; + +/// +/// A that persists conversation history to +/// Amazon Bedrock AgentCore Memory. Loads history before each agent run via ListEvents +/// and saves new messages after via CreateEvent. +/// +/// Registered automatically by . +/// Operates in pass-through mode (no-op) when MemoryId is not configured. +/// +/// +public sealed class AgentCoreMemoryProvider : ChatHistoryProvider +{ + private readonly IAmazonBedrockAgentCore? _memoryClient; + private readonly AgentCoreOptions _options; + private readonly ILogger _logger; + + public AgentCoreMemoryProvider( + AgentCoreOptions options, + ILogger logger, + IAmazonBedrockAgentCore? memoryClient = null) + : base(null, null) + { + _options = options; + _logger = logger; + _memoryClient = memoryClient; + } + + public override string StateKey => "AgentCore.Memory"; + + /// + /// Resolves the effective MemoryId from options or environment variable. + /// Options takes precedence over environment variable. + /// + private string? GetEffectiveMemoryId() + { + if (!string.IsNullOrWhiteSpace(_options.MemoryId)) + return _options.MemoryId; + + var envValue = Environment.GetEnvironmentVariable("MEMORY_ID"); + return string.IsNullOrWhiteSpace(envValue) ? null : envValue; + } + + protected override async ValueTask> ProvideChatHistoryAsync( + InvokingContext context, + CancellationToken cancellationToken = default) + { + var memoryId = GetEffectiveMemoryId(); + if (memoryId is null) + return []; + + if (_memoryClient is null) + { + _logger.LogError("MemoryId is configured but IAmazonBedrockAgentCore is not registered in DI. Memory operations will be skipped."); + return []; + } + + var sessionId = GetSessionId(context.Session); + if (sessionId is null) + { + _logger.LogWarning("SessionId not available in session StateBag. Skipping memory load."); + return []; + } + + try + { + return await LoadHistoryAsync(memoryId, sessionId, cancellationToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to load conversation history from AgentCore Memory. Proceeding without history."); + return []; + } + } + + protected override async ValueTask StoreChatHistoryAsync( + InvokedContext context, + CancellationToken cancellationToken = default) + { + var memoryId = GetEffectiveMemoryId(); + if (memoryId is null) + return; + + if (_memoryClient is null) + return; + + var sessionId = GetSessionId(context.Session); + if (sessionId is null) + return; + + var messagesToSave = FilterMessagesForStorage( + context.RequestMessages, context.ResponseMessages); + + foreach (var (role, text) in messagesToSave) + { + try + { + await SaveEventAsync(memoryId, sessionId, role, text, cancellationToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to save message to AgentCore Memory. Continuing."); + } + } + } + + // ... private helper methods (see detailed design below) +} +``` + +#### Key Private Methods + +```csharp +private string? GetSessionId(AgentSession session) +{ + // Retrieve AgentCoreRuntimeContext from the session StateBag + if (session.TryGetProperty(AgentCoreRuntimeContextProvider.ContextKey, out var contextObj) + && contextObj is AgentCoreRuntimeContext runtimeContext) + { + return runtimeContext.SessionId; + } + return null; +} + +private async Task> LoadHistoryAsync( + string memoryId, string sessionId, CancellationToken cancellationToken) +{ + var messages = new List(); + string? nextToken = null; + + do + { + ListEventsResponse response; + try + { + response = await _memoryClient!.ListEventsAsync(new ListEventsRequest + { + MemoryId = memoryId, + ActorId = sessionId, + SessionId = sessionId, + IncludePayloads = true, + NextToken = nextToken + }, cancellationToken); + } + 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); + break; + } + + foreach (var evt in response.Events ?? []) + { + if (TryConvertEventToChatMessage(evt, out var chatMessage)) + { + messages.Add(chatMessage); + } + } + + nextToken = response.NextToken; + } + while (!string.IsNullOrEmpty(nextToken)); + + return messages; +} + +private static bool TryConvertEventToChatMessage(Event evt, out ChatMessage message) +{ + message = default!; + + if (evt.Payload is null || evt.Payload.Count == 0) + return false; + + // Find the first conversational payload with text content + foreach (var payload in evt.Payload) + { + if (payload.Conversational is { } conversational + && conversational.Content?.Text is { Length: > 0 } text) + { + var role = conversational.Role switch + { + ConversationRole.USER => ChatRole.User, + ConversationRole.ASSISTANT => ChatRole.Assistant, + _ => (ChatRole?)null + }; + + if (role is not null) + { + message = new ChatMessage(role.Value, text); + return true; + } + } + } + + return false; +} + +private async Task SaveEventAsync( + string memoryId, string sessionId, ConversationRole role, string text, + CancellationToken cancellationToken) +{ + await _memoryClient!.CreateEventAsync(new CreateEventRequest + { + MemoryId = memoryId, + SessionId = sessionId, + ActorId = sessionId, + EventTimestamp = DateTime.UtcNow, + Payload = new List + { + new PayloadType + { + Conversational = new Conversational + { + Role = role, + Content = new Content { Text = text } + } + } + } + }, cancellationToken); +} + +private static IEnumerable<(ConversationRole Role, string Text)> FilterMessagesForStorage( + IEnumerable? requestMessages, + IEnumerable? responseMessages) +{ + var allMessages = (requestMessages ?? []).Concat(responseMessages ?? []); + + foreach (var message in allMessages) + { + // Skip messages with tool-call or tool-result content + if (HasToolContent(message)) + continue; + + // Extract text content + var text = message.Text; + if (string.IsNullOrWhiteSpace(text)) + continue; + + // Map role + var role = message.Role == ChatRole.User + ? ConversationRole.USER + : ConversationRole.ASSISTANT; + + yield return (role, text); + } +} + +private static bool HasToolContent(ChatMessage message) +{ + if (message.Contents is null) + return false; + + foreach (var content in message.Contents) + { + if (content is FunctionCallContent or FunctionResultContent) + return true; + } + + return false; +} +``` + +### Modified Classes + +#### `AgentCoreOptions` (modified) + +```csharp +public class AgentCoreOptions +{ + // ... existing properties ... + + /// + /// The AgentCore Memory ID for persistent conversation history. + /// When set, the Memory provider actively loads and saves conversation history. + /// Falls back to the MEMORY_ID environment variable when not set. + /// + public string? MemoryId { get; set; } +} +``` + +#### `AgentCoreBuilderExtensions.AddAgentCore()` (modified) + +Added registrations for the Memory provider and the AWS SDK client: + +```csharp +public static WebApplicationBuilder AddAgentCore(this WebApplicationBuilder builder, Action? configure = null) +{ + // ... existing registrations ... + + // Register IAmazonBedrockAgentCore for Memory operations (optional — TryAdd so it doesn't fail if not needed) + builder.Services.TryAddAWSService(); + + // Register AgentCoreMemoryProvider + builder.Services.AddSingleton(); + + // Wire the Memory provider into the agent's AIContextProviders list + // (done inside the AIAgent factory, after AgentCoreRuntimeContextProvider) + + return builder; +} +``` + +The `AIAgent` factory is updated to attach both context providers to the agent options: + +```csharp +builder.Services.AddSingleton(sp => +{ + // ... existing IChatClient resolution ... + + var agentOptions = options.AgentOptions ?? new ChatClientAgentOptions(); + + // Attach context providers: RuntimeContext first, then Memory + var runtimeContextProvider = sp.GetRequiredService(); + var memoryProvider = sp.GetRequiredService(); + + var providers = new List(); + providers.Add(runtimeContextProvider); + providers.Add(memoryProvider); + + // Preserve any user-registered providers + if (agentOptions.AIContextProviders is not null) + providers.AddRange(agentOptions.AIContextProviders); + + agentOptions.AIContextProviders = providers; + // Also set ChatHistoryProvider specifically + agentOptions.ChatHistoryProvider = memoryProvider; + + var agent = new ChatClientAgent(chatClient, agentOptions); + + if (options.ConfigureAgent is not null) + return options.ConfigureAgent(agent); + + return agent; +}); +``` + +### AWS SDK Package + +The .NET SDK package for AgentCore is **`AWSSDK.BedrockAgentCore`**. This follows the standard AWS SDK for .NET naming convention where the service name maps directly to the NuGet package name. The Java SDK uses `software.amazon.awssdk:bedrockagentcore`, and the .NET SDK follows the pattern `AWSSDK.{ServiceName}`. + +The service client interface is `IAmazonBedrockAgentCore` in the `Amazon.BedrockAgentCore` namespace, with model types in `Amazon.BedrockAgentCore.Model`. + +**Package reference to add to `AWS.AgentCore.csproj`:** + +```xml + +``` + +### DI Registration Summary + +| Service | Lifetime | Condition | +| --------------------------------- | --------- | -------------------------------------------------------------- | +| `AgentCoreOptions` | Singleton | Always | +| `IAmazonBedrockAgentCore` | Singleton | TryAdd (doesn't fail if already registered or not needed) | +| `AgentCoreRuntimeContextProvider` | Singleton | Always | +| `AgentCoreMemoryProvider` | Singleton | Always (operates in pass-through when MemoryId not configured) | +| `AIAgent` / `ChatClientAgent` | Singleton | Always | + +### Session State Flow + +``` +HTTP Header: X-Amzn-Bedrock-AgentCore-Runtime-Session-Id + ↓ +AgentCoreRuntimeContext.SessionId (extracted in MapAgentCore pipeline) + ↓ +Handler stores context in session StateBag via AgentCoreSessionFactory + ↓ +AgentCoreMemoryProvider reads SessionId from StateBag + ↓ +Uses SessionId as both sessionId and actorId for Memory API calls +``` + +## Data Models + +### AgentCore Memory API Types (from AWS SDK) + +| Type | Description | +| --------------------- | ----------------------------------------------------------------------------------------------- | +| `ListEventsRequest` | Request: memoryId (URI), actorId (URI), sessionId (URI), includePayloads, maxResults, nextToken | +| `ListEventsResponse` | Response: events[], nextToken | +| `Event` | eventId, actorId, sessionId, memoryId, eventTimestamp, payload[], metadata | +| `CreateEventRequest` | Request: memoryId (URI), actorId, sessionId, eventTimestamp, payload[] | +| `CreateEventResponse` | Response: event | +| `PayloadType` | Union: conversational OR blob | +| `Conversational` | role (USER, ASSISTANT, TOOL, OTHER), content | +| `Content` | Union: text (string, min 1, max 100000) | + +### Role Mapping + +| AgentCore Memory Role | MS AF ChatRole | Direction | +| --------------------- | -------------------- | ------------------------------- | +| `USER` | `ChatRole.User` | Load & Save | +| `ASSISTANT` | `ChatRole.Assistant` | Load & Save | +| `TOOL` | — | Skipped (not loaded, not saved) | +| `OTHER` | — | Skipped (not loaded, not saved) | + +### Message Filtering Rules (Save) + +A message is persisted to Memory only if ALL of the following are true: + +1. The message role is User or Assistant +2. The message does NOT contain `FunctionCallContent` or `FunctionResultContent` +3. The message's text content (`message.Text`) is not null/empty/whitespace +4. The text length is ≥ 1 character (Memory API constraint) + +## Correctness Properties + +_A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees._ + +### Property 1: Event-to-ChatMessage Conversion Preserves Data + +_For any_ valid AgentCore Memory event with a conversational payload containing a USER or ASSISTANT role and non-empty text content, converting it to a ChatMessage and examining the result should yield a ChatMessage with the corresponding ChatRole and identical text content. + +**Validates: Requirements 1.2, 3.3** + +### Property 2: Message Filtering Excludes Invalid Messages + +_For any_ ChatMessage, the message should be persisted to Memory if and only if: (a) it has User or Assistant role, (b) it contains no FunctionCallContent or FunctionResultContent, and (c) its text content is non-null and non-whitespace with length ≥ 1. + +**Validates: Requirements 2.3, 3.1, 3.2, 3.4** + +### Property 3: Pagination Fetches All Pages in Order + +_For any_ sequence of N paginated ListEvents responses (where responses 1..N-1 have a NextToken and response N does not), the provider should make exactly N API calls and return all events concatenated in the order they were received (page 1 events, then page 2 events, etc.). + +**Validates: Requirements 1.3, 10.1, 10.2** + +### Property 4: Errors Never Propagate to Caller + +_For any_ exception thrown by the AgentCore Memory client during either ListEvents or CreateEvent, the provider should catch the exception, log it, and return gracefully (empty collection for load, no-throw for save) — never allowing the exception to propagate to the agent pipeline. + +**Validates: Requirements 1.5, 2.4** + +### Property 5: Concurrent Session Isolation + +_For any_ two concurrent invocations with different SessionIds, the Memory operations for each invocation should use only its own SessionId — ListEvents and CreateEvent calls for invocation A should never use invocation B's SessionId, and vice versa. + +**Validates: Requirements 6.2, 8.1, 8.2, 8.4** + +### Property 6: Partial Pagination Failure Returns Loaded Pages + +_For any_ pagination sequence where an error occurs on page K (where K > 1), the provider should return all events successfully loaded from pages 1 through K-1, log a warning, and not throw. + +**Validates: Requirements 10.3** + +## Error Handling + +### Error Strategy: Log and Continue + +The Memory provider follows a strict "log and continue" policy. Memory is an enhancement — it must never cause an agent invocation to fail. + +| Scenario | Behavior | +| ----------------------------------------------------------- | --------------------------------------------------------------------- | +| ListEvents throws any exception | Log error, return empty history, agent proceeds without context | +| CreateEvent throws any exception | Log error, skip that message, continue saving remaining messages | +| Pagination error on page K > 1 | Log warning, return pages 1..K-1, agent proceeds with partial history | +| SessionId not in StateBag | Log warning, return empty / skip save | +| IAmazonBedrockAgentCore not in DI (but MemoryId configured) | Log error once, operate in pass-through mode | +| MemoryId not configured | No logging, no API calls, pure pass-through | + +### Error Logging Levels + +- `LogError` — Memory client threw an exception, or client not available when MemoryId is configured +- `LogWarning` — SessionId missing from StateBag, partial pagination failure +- No logging — MemoryId not configured (this is normal operation, not an error) + +### Retry Strategy + +The provider does NOT implement retries. The AWS SDK client handles retries internally via its configured retry policy. If the SDK exhausts retries and throws, the provider catches and logs. + +## Testing Strategy + +### Property-Based Testing + +This feature is suitable for property-based testing because: + +- The core logic involves data transformation (events ↔ ChatMessages) with a large input space +- Message filtering has universal properties that should hold across all message types +- Pagination logic has properties that hold regardless of page count +- Error handling has a universal property (never propagate) + +**Library:** [FsCheck](https://fscheck.github.io/FsCheck/) via `FsCheck.Xunit` (standard .NET PBT library) + +**Configuration:** Minimum 100 iterations per property test. + +**Tag format:** `Feature: agentcore-memory, Property {number}: {property_text}` + +### Unit Tests + +| Test | What It Verifies | +| ----------------------------------------------------------- | ----------------------------- | +| `ProvideChatHistory_NoMemoryId_ReturnsEmpty` | Pass-through mode | +| `ProvideChatHistory_NoSessionId_ReturnsEmptyAndLogsWarning` | Missing session handling | +| `ProvideChatHistory_NoMemoryClient_LogsErrorReturnsEmpty` | Missing SDK client | +| `StoreChatHistory_NoMemoryId_DoesNothing` | Pass-through mode for save | +| `StoreChatHistory_SkipsToolCallMessages` | Tool-call filtering | +| `StoreChatHistory_SkipsToolResultMessages` | Tool-result filtering | +| `StoreChatHistory_SkipsEmptyTextMessages` | Empty text filtering | +| `StoreChatHistory_SavesUserAndAssistantMessages` | Happy path save | +| `StoreChatHistory_UsesSessionIdAsActorId` | ActorId mapping | +| `GetEffectiveMemoryId_OptionsOverridesEnvVar` | Configuration precedence | +| `GetEffectiveMemoryId_FallsBackToEnvVar` | Environment variable fallback | +| `AddAgentCore_RegistersMemoryProvider` | DI registration | +| `AddAgentCore_MemoryProviderAfterRuntimeContextProvider` | Provider ordering | +| `AddAgentCore_PreservesUserContextProviders` | Non-interference | + +### Integration Tests + +| Test | What It Verifies | +| ---------------------------------------------------- | ------------------------------------------- | +| `MemoryProvider_LoadsAndSavesHistory_EndToEnd` | Full round-trip with real/mocked Memory API | +| `MemoryProvider_ConcurrentRequests_Isolated` | Session isolation under concurrency | +| `MemoryProvider_NativeAot_NoTrimmingWarnings` | AOT compatibility | +| `MemoryProvider_SourceGenerator_RegisteredCorrectly` | Source generator DX path | + +### Property Tests (from Correctness Properties) + +| Test | Property | +| ----------------------------------------------- | ---------- | +| `EventToMessageConversion_PreservesRoleAndText` | Property 1 | +| `MessageFiltering_OnlyPersistsValidMessages` | Property 2 | +| `Pagination_FetchesAllPagesInOrder` | Property 3 | +| `Errors_NeverPropagate` | Property 4 | +| `ConcurrentSessions_UseCorrectSessionId` | Property 5 | +| `PartialPaginationFailure_ReturnsLoadedPages` | Property 6 | diff --git a/.kiro/specs/agentcore-memory/requirements.md b/.kiro/specs/agentcore-memory/requirements.md new file mode 100644 index 0000000..04ce2cc --- /dev/null +++ b/.kiro/specs/agentcore-memory/requirements.md @@ -0,0 +1,150 @@ +# Requirements Document + +## Introduction + +Integration of the Amazon Bedrock AgentCore Memory service into the AWS.AgentCore .NET library as an `AIContextProvider` within the Microsoft Agent Framework pipeline. This feature provides persistent conversation history that survives container restarts and scaling events, enabling stateful multi-turn conversations for agents deployed to AgentCore Runtime. The integration is opt-in (activated only when a Memory ID is configured), gracefully degrades when Memory is unavailable, and works with both the source generator and extension method developer experiences. + +## Glossary + +- **AgentCore_Memory**: The Amazon Bedrock AgentCore managed service for persistent conversation history, accessed via AWS SDK operations (ListEvents, CreateEvent). +- **Memory_Provider**: The AIContextProvider implementation that bridges AgentCore Memory into the MS AF pipeline, loading history before agent runs and saving new messages after. +- **MemoryId**: A unique identifier for a memory store, configured per runtime via the `MEMORY_ID` environment variable or AgentCoreOptions. +- **SessionId**: The session identifier from the AgentCore Runtime HTTP header (`X-Amzn-Bedrock-AgentCore-Runtime-Session-Id`), used to scope memory operations. +- **ActorId**: An identifier for the actor performing memory operations, derived from the session context. +- **Event**: A single entry in AgentCore Memory, containing a Conversational payload with Role (USER or ASSISTANT) and Content (text). +- **ListEvents**: The AgentCore Memory API operation that retrieves conversation history, supporting pagination via NextToken. +- **CreateEvent**: The AgentCore Memory API operation that persists a new conversation event. +- **AIContextProvider**: The MS AF abstraction for injecting context into the agent pipeline, with InvokingAsync (before) and InvokedAsync (after) hooks. +- **ChatClientAgent**: The MS AF agent type registered in DI by AddAgentCore, which executes the agent pipeline including context providers. +- **AgentCoreRuntimeContext**: The typed object populated from AgentCore HTTP headers, stored in the session StateBag. +- **AddAgentCore**: The WebApplicationBuilder extension method that registers AgentCore services in DI. +- **StateBag**: The MS AF session state dictionary where AgentCoreRuntimeContext is stored by AgentCoreSessionFactory. + +## Requirements + +### Requirement 1: Load Conversation History Before Agent Execution + +**User Story:** As a .NET developer, I want the agent to automatically load previous conversation history from AgentCore Memory before each run, so that the agent has full context of prior interactions without manual history management. + +#### Acceptance Criteria + +1. WHEN the agent pipeline executes InvokingAsync and a MemoryId is configured, THE Memory_Provider SHALL call ListEvents on the AgentCore Memory service using the current SessionId and MemoryId. +2. WHEN ListEvents returns conversation events, THE Memory_Provider SHALL convert each event into a ChatMessage with the appropriate role (User or Assistant) and include them in the returned message collection. +3. WHEN ListEvents returns paginated results with a NextToken, THE Memory_Provider SHALL continue fetching subsequent pages until all history is retrieved. +4. WHEN the SessionId is not available in the session StateBag, THE Memory_Provider SHALL return an empty message collection and log a warning. +5. WHEN the AgentCore Memory service returns an error during history loading, THE Memory_Provider SHALL log the error and return an empty message collection, allowing the agent to proceed without history. + +### Requirement 2: Save New Messages After Agent Execution + +**User Story:** As a .NET developer, I want the agent to automatically persist new conversation messages to AgentCore Memory after each run, so that future invocations have access to the complete conversation history. + +#### Acceptance Criteria + +1. WHEN the agent pipeline executes InvokedAsync and a MemoryId is configured, THE Memory_Provider SHALL call CreateEvent on the AgentCore Memory service for the user input message. +2. WHEN the agent pipeline executes InvokedAsync and a MemoryId is configured, THE Memory_Provider SHALL call CreateEvent on the AgentCore Memory service for the assistant response message. +3. WHEN a message has empty or whitespace-only text content, THE Memory_Provider SHALL skip that message and not call CreateEvent for it. +4. WHEN the AgentCore Memory service returns an error during message saving, THE Memory_Provider SHALL log the error and allow the agent response to proceed without interruption. +5. THE Memory_Provider SHALL use the same SessionId and MemoryId for CreateEvent as was used for ListEvents in the same invocation. + +### Requirement 3: Filter Tool-Call Messages + +**User Story:** As a .NET developer, I want tool-call and tool-result messages to be excluded from Memory persistence, so that only human-readable conversation content is stored and the Memory service does not reject messages with empty text. + +#### Acceptance Criteria + +1. WHEN saving messages to AgentCore Memory, THE Memory_Provider SHALL exclude messages that contain tool-call content (function invocations). +2. WHEN saving messages to AgentCore Memory, THE Memory_Provider SHALL exclude messages that contain tool-result content (function responses). +3. WHEN loading history from AgentCore Memory, THE Memory_Provider SHALL only produce ChatMessages with User or Assistant roles containing text content. +4. THE Memory_Provider SHALL only persist messages where the text content has a length of at least 1 character. + +### Requirement 4: Graceful Degradation Without Memory Configuration + +**User Story:** As a .NET developer, I want my agent to work statelessly when AgentCore Memory is not configured, so that I can develop and test locally without a Memory service dependency. + +#### Acceptance Criteria + +1. WHEN MemoryId is not configured (neither in AgentCoreOptions nor the MEMORY_ID environment variable), THE Memory_Provider SHALL skip all Memory operations and return empty results from InvokingAsync. +2. WHEN MemoryId is not configured, THE Memory_Provider SHALL not call any AgentCore Memory API operations. +3. WHEN MemoryId is not configured, THE Memory_Provider SHALL not log errors or warnings about missing configuration. +4. WHEN MemoryId becomes available after initial startup (configuration change), THE Memory_Provider SHALL use the configured MemoryId for subsequent requests. + +### Requirement 5: Automatic Registration via AddAgentCore + +**User Story:** As a .NET developer, I want the Memory provider to be automatically registered when I call AddAgentCore, so that I get persistent conversation history without additional setup code. + +#### Acceptance Criteria + +1. WHEN AddAgentCore is called, THE AddAgentCore method SHALL always register the Memory_Provider as an AIContextProvider in the DI container. +2. WHEN a MemoryId is available (via AgentCoreOptions or MEMORY_ID environment variable), THE Memory_Provider SHALL actively load and save conversation history. +3. WHEN no MemoryId is available, THE Memory_Provider SHALL operate in pass-through mode without calling any Memory APIs. +4. THE Memory_Provider registration SHALL not interfere with other AIContextProviders registered by the user. +5. THE Memory_Provider SHALL execute after the AgentCoreRuntimeContextProvider in the pipeline, ensuring the SessionId is available in the StateBag. +6. WHEN using the source generator approach with [AgentCoreStartup] and ConfigureServices calling AddAgentCore, THE Memory_Provider SHALL be registered with the same behavior as the extension method approach. +7. WHEN using the source generator approach with [AgentCoreHandler] only (no [AgentCoreStartup]), THE generated code SHALL call AddAgentCore with default options, which registers the Memory_Provider in pass-through mode (activating via MEMORY_ID environment variable at runtime). + +### Requirement 6: Use Session ID from AgentCoreRuntimeContext + +**User Story:** As a .NET developer, I want the Memory provider to automatically use the session ID from AgentCore HTTP headers, so that conversation history is correctly scoped per session without manual session management. + +#### Acceptance Criteria + +1. WHEN the Memory_Provider executes, THE Memory_Provider SHALL retrieve the SessionId from the AgentCoreRuntimeContext stored in the session StateBag. +2. WHEN multiple concurrent requests arrive with different session IDs, THE Memory_Provider SHALL use the correct SessionId for each request's Memory operations. +3. THE Memory_Provider SHALL use the SessionId as the ActorId for CreateEvent operations. +4. WHEN the SessionId changes between invocations (new session), THE Memory_Provider SHALL load history for the new SessionId. + +### Requirement 7: MemoryId Configuration + +**User Story:** As a .NET developer, I want to configure the Memory ID via options or environment variable, so that I can use different memory stores for different environments. + +#### Acceptance Criteria + +1. WHEN the MEMORY_ID environment variable is set, THE Memory_Provider SHALL use that value as the MemoryId for all Memory operations. +2. WHEN a MemoryId is set in AgentCoreOptions, THE AgentCoreOptions value SHALL take precedence over the MEMORY_ID environment variable. +3. WHEN neither AgentCoreOptions.MemoryId nor the MEMORY_ID environment variable is set, THE Memory_Provider SHALL operate in pass-through mode without calling Memory APIs. +4. THE MemoryId configuration SHALL be readable at request time, allowing runtime configuration changes. + +### Requirement 8: Concurrent Request Isolation + +**User Story:** As a .NET developer deploying to AgentCore Runtime, I want concurrent requests with different sessions to have isolated memory operations, so that conversation histories do not leak between users. + +#### Acceptance Criteria + +1. WHEN two concurrent requests arrive with different SessionIds, THE Memory_Provider SHALL load independent conversation histories for each request. +2. WHEN two concurrent requests arrive with different SessionIds, THE Memory_Provider SHALL save messages to the correct session's history independently. +3. THE Memory_Provider SHALL not use shared mutable state between concurrent requests. +4. WHEN a Memory operation for one request fails, THE failure SHALL not affect Memory operations for other concurrent requests. + +### Requirement 9: Non-Interference with Existing Agents + +**User Story:** As an existing AWS.AgentCore user who does not use Memory, I want the Memory integration to have no impact on my agent's behavior or performance, so that I can upgrade without risk. + +#### Acceptance Criteria + +1. WHEN MemoryId is not configured, THE Memory_Provider SHALL add no measurable latency to agent invocations. +2. WHEN MemoryId is not configured, THE Memory_Provider SHALL not make any network calls. +3. THE Memory_Provider registration SHALL not require any new mandatory dependencies in the DI container. +4. WHEN the AWS SDK client for AgentCore Memory is not available and MemoryId is configured, THE Memory_Provider SHALL log an error and operate in pass-through mode. + +### Requirement 10: Pagination Handling for Long Conversations + +**User Story:** As a .NET developer building conversational agents, I want the Memory provider to handle paginated history correctly, so that agents with long conversation histories load all prior context. + +#### Acceptance Criteria + +1. WHEN ListEvents returns a NextToken in the response, THE Memory_Provider SHALL issue subsequent ListEvents calls with the NextToken until no NextToken is returned. +2. THE Memory_Provider SHALL assemble paginated results in chronological order. +3. WHEN pagination encounters an error on a subsequent page, THE Memory_Provider SHALL return the successfully loaded pages and log a warning about incomplete history. +4. THE Memory_Provider SHALL not impose an artificial limit on the number of pages fetched. + +### Requirement 11: NativeAOT Compatibility + +**User Story:** As a .NET developer targeting NativeAOT, I want the Memory provider to work without reflection or dynamic code generation, so that agents compiled ahead-of-time can use persistent conversation history with fast cold starts. + +#### Acceptance Criteria + +1. THE Memory_Provider SHALL not use reflection-based serialization or dynamic code generation for any Memory API operations. +2. WHEN compiled with PublishAot=true, THE Memory_Provider SHALL produce no trimming warnings. +3. THE Memory_Provider's DI registration SHALL not use reflection-based service resolution. +4. WHEN the NativeAotAnnotations sample is configured with a MemoryId, THE application SHALL compile and run correctly with PublishAot=true. +5. THE Memory_Provider SHALL use source-generated JSON serialization (JsonSerializerContext) for any custom types serialized to or deserialized from the Memory API. diff --git a/.kiro/specs/agentcore-memory/tasks.md b/.kiro/specs/agentcore-memory/tasks.md new file mode 100644 index 0000000..b22676c --- /dev/null +++ b/.kiro/specs/agentcore-memory/tasks.md @@ -0,0 +1,169 @@ +# Implementation Plan: AgentCore Memory Integration + +## Overview + +This plan implements the AgentCore Memory integration as a `ChatHistoryProvider` within the Microsoft Agent Framework pipeline. The work adds the `AWSSDK.BedrockAgentCore` package, creates `AgentCoreMemoryProvider`, modifies `AddAgentCore()` to register the Memory client and provider, and adds comprehensive unit and property-based tests. All code is C#/.NET 10. + +## Tasks + +- [x] 1. Add MemoryId property to AgentCoreOptions + - [x] 1.1 Update AgentCoreOptions with MemoryId property + - Add `public string? MemoryId { get; set; }` property to `AgentCoreOptions` + - Add XML doc comment explaining it enables persistent conversation history and falls back to `MEMORY_ID` environment variable + - _Requirements: 7.1, 7.2, 7.3_ + +- [x] 2. Add AWSSDK.BedrockAgentCore package reference + - [x] 2.1 Update AWS.AgentCore.csproj with new package reference + - Add `` to the ItemGroup + - Verify the project builds successfully with the new dependency + - _Requirements: 5.1, 9.3_ + +- [x] 3. Implement AgentCoreMemoryProvider class + - [x] 3.1 Create AgentCoreMemoryProvider with constructor and configuration resolution + - Create new file `src/AWS.AgentCore/AgentCoreMemoryProvider.cs` + - Inherit from `ChatHistoryProvider` (from `Microsoft.Agents.AI`) + - Accept `AgentCoreOptions`, `ILogger`, and optional `IAmazonBedrockAgentCore?` via constructor + - Implement `GetEffectiveMemoryId()` method: options takes precedence over `MEMORY_ID` environment variable + - Implement `GetSessionId(AgentSession)` method: retrieve `AgentCoreRuntimeContext` from session StateBag using `AgentCoreRuntimeContextProvider.ContextKey` + - _Requirements: 7.1, 7.2, 7.3, 7.4, 6.1_ + + - [x] 3.2 Implement ProvideChatHistoryAsync (load history) + - Override `ProvideChatHistoryAsync` to load conversation history from AgentCore Memory + - Return empty collection when MemoryId is not configured (pass-through mode) + - Return empty collection and log error when `IAmazonBedrockAgentCore` is null but MemoryId is configured + - Return empty collection and log warning when SessionId is not available in StateBag + - Call `ListEventsAsync` with memoryId, sessionId as actorId, sessionId, and includePayloads=true + - Handle pagination: loop until no NextToken is returned + - On partial pagination failure (error on page K > 1): log warning, return pages loaded so far + - On complete failure: log error, return empty collection + - _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 4.1, 4.2, 4.3, 10.1, 10.2, 10.3, 10.4_ + + - [x] 3.3 Implement event-to-ChatMessage conversion + - Implement `TryConvertEventToChatMessage` static method + - Map `ConversationRole.USER` to `ChatRole.User` and `ConversationRole.ASSISTANT` to `ChatRole.Assistant` + - Skip events with null/empty payload, non-conversational payloads, or empty text content + - Skip events with TOOL or OTHER roles + - _Requirements: 1.2, 3.3_ + + - [x] 3.4 Implement StoreChatHistoryAsync (save messages) + - Override `StoreChatHistoryAsync` to persist new messages to AgentCore Memory + - Return immediately (no-op) when MemoryId is not configured + - Return immediately when `IAmazonBedrockAgentCore` is null or SessionId is missing + - Filter messages: skip tool-call content (`FunctionCallContent`), tool-result content (`FunctionResultContent`), and empty/whitespace text + - Call `CreateEventAsync` for each valid message with memoryId, sessionId, actorId=sessionId, and conversational payload + - On failure for individual message: log error, continue with remaining messages + - _Requirements: 2.1, 2.2, 2.3, 2.4, 2.5, 3.1, 3.2, 3.4_ + + - [x] 3.5 Implement message filtering logic + - Implement `FilterMessagesForStorage` static method + - Implement `HasToolContent` static method to detect `FunctionCallContent` or `FunctionResultContent` + - Only persist messages where role is User or Assistant, no tool content, and text is non-null/non-whitespace with length ≥ 1 + - _Requirements: 3.1, 3.2, 3.3, 3.4_ + +- [x] 4. Modify AddAgentCore() to register Memory services + - [x] 4.1 Register IAmazonBedrockAgentCore and AgentCoreMemoryProvider in DI + - Add `builder.Services.TryAddAWSService()` to `AddAgentCore()` + - Add singleton registration for `AgentCoreMemoryProvider` + - Update the `AIAgent` factory to wire `AgentCoreMemoryProvider` as a `ChatHistoryProvider` on the agent options + - Ensure `AgentCoreMemoryProvider` executes after `AgentCoreRuntimeContextProvider` in the pipeline + - Preserve any user-registered `AIContextProviders` + - _Requirements: 5.1, 5.2, 5.3, 5.4, 5.5, 9.3, 9.4_ + +- [x] 5. Checkpoint - Verify core library compiles + - Ensure all tests pass, ask the user if questions arise. + +- [x] 6. Write unit tests for AgentCoreMemoryProvider + - [x] 6.1 Create unit tests for pass-through and configuration behavior + - Create new test file `test/AWS.AgentCore.UnitTests/AgentCoreMemoryProviderTests.cs` + - Add `AWSSDK.BedrockAgentCore` package reference to the unit test project + - Test: `ProvideChatHistory_NoMemoryId_ReturnsEmpty` — verify pass-through when MemoryId not configured + - Test: `ProvideChatHistory_NoSessionId_ReturnsEmptyAndLogsWarning` — verify warning logged + - Test: `ProvideChatHistory_NoMemoryClient_LogsErrorReturnsEmpty` — verify error logged when client missing + - Test: `StoreChatHistory_NoMemoryId_DoesNothing` — verify no API calls in pass-through mode + - Test: `GetEffectiveMemoryId_OptionsOverridesEnvVar` — verify options takes precedence + - Test: `GetEffectiveMemoryId_FallsBackToEnvVar` — verify environment variable fallback + - _Requirements: 4.1, 4.2, 4.3, 7.1, 7.2, 7.3, 9.1, 9.2_ + + - [x] 6.2 Create unit tests for message filtering and saving + - Test: `StoreChatHistory_SkipsToolCallMessages` — verify FunctionCallContent messages excluded + - Test: `StoreChatHistory_SkipsToolResultMessages` — verify FunctionResultContent messages excluded + - Test: `StoreChatHistory_SkipsEmptyTextMessages` — verify empty/whitespace text excluded + - Test: `StoreChatHistory_SavesUserAndAssistantMessages` — verify happy path save with correct roles + - Test: `StoreChatHistory_UsesSessionIdAsActorId` — verify actorId matches sessionId + - Test: `StoreChatHistory_ContinuesOnIndividualFailure` — verify one failure doesn't stop others + - _Requirements: 2.1, 2.2, 2.3, 2.4, 2.5, 3.1, 3.2, 3.4, 6.3_ + + - [x] 6.3 Create unit tests for DI registration + - Test: `AddAgentCore_RegistersMemoryProvider` — verify AgentCoreMemoryProvider is in DI + - Test: `AddAgentCore_RegistersIAmazonBedrockAgentCore` — verify TryAddAWSService registers client + - Test: `AddAgentCore_MemoryProviderAfterRuntimeContextProvider` — verify provider ordering + - Test: `AddAgentCore_PreservesUserContextProviders` — verify user providers not overwritten + - _Requirements: 5.1, 5.4, 5.5, 9.3, 9.4_ + +- [x] 7. Checkpoint - Ensure unit tests pass + - Ensure all tests pass, ask the user if questions arise. + +- [x] 8. Write property-based tests for correctness properties + - [x] 8.1 Write property test for event-to-ChatMessage conversion (Property 1) + - **Property 1: Event-to-ChatMessage Conversion Preserves Data** + - Generate arbitrary valid AgentCore Memory events with USER/ASSISTANT roles and non-empty text + - Verify conversion produces ChatMessage with corresponding ChatRole and identical text content + - Use FsCheck.Xunit with minimum 100 iterations + - **Validates: Requirements 1.2, 3.3** + + - [x] 8.2 Write property test for message filtering (Property 2) + - **Property 2: Message Filtering Excludes Invalid Messages** + - Generate arbitrary ChatMessages with various roles, content types, and text values + - Verify a message is persisted if and only if: (a) User or Assistant role, (b) no FunctionCallContent/FunctionResultContent, (c) non-null non-whitespace text with length ≥ 1 + - Use FsCheck.Xunit with minimum 100 iterations + - **Validates: Requirements 2.3, 3.1, 3.2, 3.4** + + - [x] 8.3 Write property test for pagination (Property 3) + - **Property 3: Pagination Fetches All Pages in Order** + - Generate arbitrary sequences of N paginated ListEvents responses (1..N-1 have NextToken, N does not) + - Verify provider makes exactly N API calls and returns all events concatenated in page order + - Use FsCheck.Xunit with minimum 100 iterations + - **Validates: Requirements 1.3, 10.1, 10.2** + + - [x] 8.4 Write property test for error handling (Property 4) + - **Property 4: Errors Never Propagate to Caller** + - Generate arbitrary exceptions thrown by the Memory client during ListEvents or CreateEvent + - Verify the provider catches the exception and returns gracefully (empty for load, no-throw for save) + - Use FsCheck.Xunit with minimum 100 iterations + - **Validates: Requirements 1.5, 2.4** + + - [x] 8.5 Write property test for partial pagination failure (Property 6) + - **Property 6: Partial Pagination Failure Returns Loaded Pages** + - Generate pagination sequences where an error occurs on page K (K > 1) + - Verify provider returns all events from pages 1 through K-1 and does not throw + - Use FsCheck.Xunit with minimum 100 iterations + - **Validates: Requirements 10.3** + +- [x] 9. Verify NativeAOT compatibility + - [x] 9.1 Verify NativeAotAnnotations sample compiles with Memory provider + - Build the `NativeAotAnnotations` sample with `dotnet publish -c Release` (it has PublishAot=true) + - Verify no new trimming warnings related to `AgentCoreMemoryProvider` or `IAmazonBedrockAgentCore` registration + - Verify the Memory provider's DI registration does not use reflection-based service resolution + - If warnings appear, add appropriate attributes or use source-generated JSON serialization + - _Requirements: 11.1, 11.2, 11.3, 11.4_ + +- [x] 10. Verify source generator compatibility + - [x] 10.1 Verify source generator works with Memory provider registration + - Build the `AnnotationsSample` project and verify generated code still compiles + - Verify that `AgentCoreMemoryProvider` is available in DI when using `[AgentCoreStartup]` approach + - Run existing source generator snapshot tests to confirm no regressions + - Verify that `[AgentCoreHandler]`-only approach (no `[AgentCoreStartup]`) registers Memory provider in pass-through mode + - _Requirements: 5.6, 5.7_ + +- [x] 11. Final checkpoint - Ensure all tests pass + - Ensure all tests pass, ask the user if questions arise. + +## Notes + +- Tasks marked with `*` are optional and can be skipped for faster MVP +- Each task references specific requirements for traceability +- Property tests use FsCheck.Xunit and validate universal correctness properties from the design +- The Memory provider uses `TryAddAWSService()` so it doesn't fail if the client is already registered or not resolvable +- The provider accepts `IAmazonBedrockAgentCore?` as optional — gracefully degrades when null +- Concurrent request isolation is achieved through per-request session state (SessionId from StateBag), not shared mutable state +- The `ChatHistoryProvider` base class provides the pipeline integration points (`ProvideChatHistoryAsync` and `StoreChatHistoryAsync`) diff --git a/src/AWS.AgentCore/AWS.AgentCore.csproj b/src/AWS.AgentCore/AWS.AgentCore.csproj index ba6f925..e0b3a2b 100644 --- a/src/AWS.AgentCore/AWS.AgentCore.csproj +++ b/src/AWS.AgentCore/AWS.AgentCore.csproj @@ -9,6 +9,7 @@ + diff --git a/src/AWS.AgentCore/AgentCoreMemoryProvider.cs b/src/AWS.AgentCore/AgentCoreMemoryProvider.cs new file mode 100644 index 0000000..fabf01d --- /dev/null +++ b/src/AWS.AgentCore/AgentCoreMemoryProvider.cs @@ -0,0 +1,262 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using Amazon.BedrockAgentCore; +using Amazon.BedrockAgentCore.Model; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; + +namespace AWS.AgentCore; + +/// +/// A that persists conversation history to +/// Amazon Bedrock AgentCore Memory. Loads history before each agent run via ListEvents +/// and saves new messages after via CreateEvent. +/// +/// Registered automatically by . +/// Operates in pass-through mode (no-op) when MemoryId is not configured. +/// +/// +internal sealed class AgentCoreMemoryProvider( + AgentCoreOptions options, + ILogger logger, + IAmazonBedrockAgentCore? memoryClient = null) + : ChatHistoryProvider +{ + /// + public override IReadOnlyList StateKeys => ["AgentCore.Memory"]; + + /// + /// Resolves the effective MemoryId from options or environment variable. + /// Options take precedence over environment variable. + /// + internal string? GetEffectiveMemoryId() + { + if (!string.IsNullOrWhiteSpace(options.MemoryId)) + return options.MemoryId; + + var envValue = Environment.GetEnvironmentVariable(Constants.MemoryIdEnvironmentVariable); + return string.IsNullOrWhiteSpace(envValue) ? null : envValue; + } + + /// + /// Retrieves the SessionId from the AgentCoreRuntimeContext stored in the session StateBag. + /// Falls back to the ambient AsyncLocal context set by the endpoint handlers. + /// + internal static string? GetSessionId(AgentSession? session) + { + // First try the session StateBag (explicit storage by user) + if (session is not null) + { + var context = session.StateBag.GetValue(AgentCoreRuntimeContextProvider.ContextKey); + if (context?.SessionId is not null) + return context.SessionId; + } + + // Fall back to the ambient AsyncLocal context (set automatically by MapAgentCore endpoints) + return AgentCoreRuntimeContextProvider.CurrentContext?.SessionId; + } + + /// + protected override async ValueTask> ProvideChatHistoryAsync( + InvokingContext context, + CancellationToken cancellationToken = default) + { + var memoryId = GetEffectiveMemoryId(); + if (memoryId is null) + return []; + + if (memoryClient is null) + { + logger.LogError("MemoryId is configured but IAmazonBedrockAgentCore is not registered in DI. Memory operations will be skipped."); + return []; + } + + var sessionId = GetSessionId(context.Session); + if (sessionId is null) + { + logger.LogWarning("SessionId not available. Ensure the request is handled by a MapAgentCore endpoint. Skipping memory load."); + return []; + } + + try + { + return await LoadHistoryAsync(memoryId, sessionId, cancellationToken); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to load conversation history from AgentCore Memory. Proceeding without history."); + return []; + } + } + + /// + protected override async ValueTask StoreChatHistoryAsync( + InvokedContext context, + CancellationToken cancellationToken = default) + { + var memoryId = GetEffectiveMemoryId(); + if (memoryId is null) + return; + + if (memoryClient is null) + return; + + var sessionId = GetSessionId(context.Session); + if (sessionId is null) + return; + + var messagesToSave = FilterMessagesForStorage(context.RequestMessages, context.ResponseMessages); + + foreach (var (role, text) in messagesToSave) + { + try + { + await SaveEventAsync(memoryId, sessionId, role, text, cancellationToken); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to save message to AgentCore Memory. Continuing."); + } + } + } + + private async Task> LoadHistoryAsync( + string memoryId, string sessionId, CancellationToken cancellationToken) + { + if (memoryClient is null) + return []; + + var messages = new List(); + + var request = new ListEventsRequest + { + MemoryId = memoryId, + // ActorId = sessionId: see SaveEventAsync comment for rationale + ActorId = sessionId, + SessionId = sessionId, + IncludePayloads = true + }; + + try + { + await foreach (var evt in memoryClient.Paginators.ListEvents(request).Events.WithCancellation(cancellationToken)) + { + if (TryConvertEventToChatMessage(evt, out var chatMessage)) + { + messages.Add(chatMessage); + } + } + } + catch (Exception ex) when (messages.Count > 0) + { + // Partial pagination failure — return what we have + logger.LogWarning(ex, "Error during pagination. Returning {Count} messages loaded so far.", messages.Count); + } + + return messages; + } + + internal static bool TryConvertEventToChatMessage(Event evt, out ChatMessage message) + { + message = default!; + + if (evt.Payload is null || evt.Payload.Count == 0) + return false; + + foreach (var payload in evt.Payload) + { + if (payload.Conversational is { } conversational + && conversational.Content?.Text is { Length: > 0 } text) + { + ChatRole? role = null; + if (conversational.Role == Role.USER) + role = ChatRole.User; + else if (conversational.Role == Role.ASSISTANT) + role = ChatRole.Assistant; + + if (role is not null) + { + message = new ChatMessage(role.Value, text); + return true; + } + } + } + + return false; + } + + private async Task SaveEventAsync( + string memoryId, string sessionId, Role role, string text, + CancellationToken cancellationToken) + { + if (memoryClient is null) + return; + + await memoryClient.CreateEventAsync(new CreateEventRequest + { + MemoryId = memoryId, + SessionId = sessionId, + // NOTE: ActorId is set to sessionId intentionally. In this session-scoped short-term + // memory implementation, the session IS the actor scope. A future long-term memory + // feature may introduce a separate ActorId/UserId concept. + ActorId = sessionId, + EventTimestamp = DateTime.UtcNow, + Payload = [ + new PayloadType + { + Conversational = new Conversational + { + Role = role, + Content = new Content { Text = text } + } + } + ] + }, cancellationToken); + } + + internal static IEnumerable<(Role Role, string Text)> FilterMessagesForStorage( + IEnumerable? requestMessages, + IEnumerable? responseMessages) + { + var allMessages = (requestMessages ?? []).Concat(responseMessages ?? []); + + foreach (var message in allMessages) + { + // Skip messages with tool-call or tool-result content + if (HasToolContent(message)) + continue; + + // Only persist User and Assistant messages — skip System, Tool, and any other roles + Role role; + if (message.Role == ChatRole.User) + role = Role.USER; + else if (message.Role == ChatRole.Assistant) + role = Role.ASSISTANT; + else + continue; + + // Extract text content + var text = message.Text; + if (string.IsNullOrWhiteSpace(text)) + continue; + + yield return (role, text); + } + } + + internal static bool HasToolContent(ChatMessage message) + { + if (message.Contents is null) + return false; + + foreach (var content in message.Contents) + { + if (content is FunctionCallContent or FunctionResultContent) + return true; + } + + return false; + } +} diff --git a/src/AWS.AgentCore/AgentCoreOptions.cs b/src/AWS.AgentCore/AgentCoreOptions.cs index e0fb393..61702de 100644 --- a/src/AWS.AgentCore/AgentCoreOptions.cs +++ b/src/AWS.AgentCore/AgentCoreOptions.cs @@ -40,4 +40,13 @@ public class AgentCoreOptions /// (which may be decorated with middleware). /// public Func? ConfigureAgent { get; set; } + + /// + /// The AgentCore Memory ID for persistent conversation history. + /// When set, the Memory provider actively loads and saves conversation history + /// across invocations and container restarts. + /// Falls back to the environment variable when not set. + /// When neither is configured, the agent operates statelessly. + /// + public string? MemoryId { get; set; } } diff --git a/src/AWS.AgentCore/AgentCoreRuntimeContextProvider.cs b/src/AWS.AgentCore/AgentCoreRuntimeContextProvider.cs index 90167b7..88d9d82 100644 --- a/src/AWS.AgentCore/AgentCoreRuntimeContextProvider.cs +++ b/src/AWS.AgentCore/AgentCoreRuntimeContextProvider.cs @@ -11,7 +11,8 @@ namespace AWS.AgentCore; /// /// Registered automatically by . /// Downstream middleware and context providers can access the runtime context (session ID, -/// request ID, access tokens, custom headers) via the session's state bag using . +/// request ID, access tokens, custom headers) via the session's state bag using , +/// or via the ambient property which is set automatically by the endpoint handlers. /// /// public class AgentCoreRuntimeContextProvider : AIContextProvider @@ -20,4 +21,18 @@ public class AgentCoreRuntimeContextProvider : AIContextProvider /// Key used to store/retrieve in the agent session state. /// public const string ContextKey = "AgentCore.RuntimeContext"; + + private static readonly AsyncLocal _currentContext = new(); + + /// + /// Gets or sets the for the current async execution context. + /// This is set automatically by the AgentCore endpoint handlers (MapAgentCore) and + /// flows through async calls, making it available to the Memory provider without requiring + /// manual session StateBag population. + /// + public static AgentCoreRuntimeContext? CurrentContext + { + get => _currentContext.Value; + set => _currentContext.Value = value; + } } diff --git a/src/AWS.AgentCore/Constants.cs b/src/AWS.AgentCore/Constants.cs new file mode 100644 index 0000000..bf0d82a --- /dev/null +++ b/src/AWS.AgentCore/Constants.cs @@ -0,0 +1,17 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +namespace AWS.AgentCore; + +/// +/// Constants used throughout the AWS.AgentCore library. +/// +internal static class Constants +{ + /// + /// Environment variable name for the AgentCore Memory ID. + /// When set, the Memory provider uses this value as the MemoryId for all Memory operations + /// (unless overridden by ). + /// + internal const string MemoryIdEnvironmentVariable = "AWS_AGENTCORE_MEMORY_ID"; +} diff --git a/src/AWS.AgentCore/Extensions/AgentCoreBuilderExtensions.cs b/src/AWS.AgentCore/Extensions/AgentCoreBuilderExtensions.cs index 236d52f..bfd62c4 100644 --- a/src/AWS.AgentCore/Extensions/AgentCoreBuilderExtensions.cs +++ b/src/AWS.AgentCore/Extensions/AgentCoreBuilderExtensions.cs @@ -1,6 +1,7 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +using Amazon.BedrockAgentCore; using Amazon.BedrockRuntime; using Microsoft.Agents.AI; using Microsoft.AspNetCore.Builder; @@ -114,6 +115,12 @@ public static WebApplicationBuilder AddAgentCore(this WebApplicationBuilder buil // Register AgentCoreRuntimeContextProvider (AIContextProvider) builder.Services.AddSingleton(); + // Register IAmazonBedrockAgentCore for Memory operations + builder.Services.TryAddAWSService(); + + // Register AgentCoreMemoryProvider + builder.Services.AddSingleton(); + // Register AIAgent (may be a ChatClientAgent or a middleware-decorated agent) builder.Services.AddSingleton(sp => { @@ -128,6 +135,12 @@ public static WebApplicationBuilder AddAgentCore(this WebApplicationBuilder buil } var agentOptions = options.AgentOptions ?? new ChatClientAgentOptions(); + + // Wire the Memory provider as the ChatHistoryProvider + var memoryProvider = sp.GetRequiredService(); + // Only set the memory provider if the user hasn't configured their own ChatHistoryProvider + agentOptions.ChatHistoryProvider ??= memoryProvider; + var agent = new ChatClientAgent(chatClient, agentOptions); if (options.ConfigureAgent is not null) diff --git a/src/AWS.AgentCore/Extensions/AgentCoreEndpointExtensions.cs b/src/AWS.AgentCore/Extensions/AgentCoreEndpointExtensions.cs index 8a30c8e..bfb1c01 100644 --- a/src/AWS.AgentCore/Extensions/AgentCoreEndpointExtensions.cs +++ b/src/AWS.AgentCore/Extensions/AgentCoreEndpointExtensions.cs @@ -209,6 +209,7 @@ await httpContext.Response.WriteAsJsonAsync( } var context = AgentCoreRuntimeContext.FromHttpContext(httpContext); + AgentCoreRuntimeContextProvider.CurrentContext = context; var result = await handler(request, context, httpContext.RequestServices, httpContext.RequestAborted); await httpContext.Response.WriteAsJsonAsync( new JsonMessageResponse(result, DateTime.UtcNow), @@ -269,6 +270,7 @@ await httpContext.Response.WriteAsJsonAsync( } var context = AgentCoreRuntimeContext.FromHttpContext(httpContext); + AgentCoreRuntimeContextProvider.CurrentContext = context; var result = await handler(request, context, httpContext.RequestServices, httpContext.RequestAborted); await httpContext.Response.WriteAsJsonAsync( new JsonMessageResponse(result, DateTime.UtcNow), @@ -312,6 +314,7 @@ await httpContext.Response.WriteAsJsonAsync( } var context = AgentCoreRuntimeContext.FromHttpContext(httpContext); + AgentCoreRuntimeContextProvider.CurrentContext = context; var stream = handler(request, context, httpContext.RequestServices, httpContext.RequestAborted); await StreamingResponseWriter.WriteStreamingResponseAsync(httpContext, stream); }); @@ -370,6 +373,7 @@ await httpContext.Response.WriteAsJsonAsync( } var context = AgentCoreRuntimeContext.FromHttpContext(httpContext); + AgentCoreRuntimeContextProvider.CurrentContext = context; var stream = handler(request, context, httpContext.RequestServices, httpContext.RequestAborted); await StreamingResponseWriter.WriteStreamingResponseAsync(httpContext, stream); }); diff --git a/src/AWS.AgentCore/Internal/ParameterBindingPlan.cs b/src/AWS.AgentCore/Internal/ParameterBindingPlan.cs index 2fe10b9..2e491e2 100644 --- a/src/AWS.AgentCore/Internal/ParameterBindingPlan.cs +++ b/src/AWS.AgentCore/Internal/ParameterBindingPlan.cs @@ -108,6 +108,12 @@ internal static ParameterBindingPlan Create(Delegate handler) }; } + // Set the ambient AsyncLocal so downstream code (e.g. AgentCoreMemoryProvider) + // can access the runtime context without manual StateBag population. + var runtimeContext = args.OfType().FirstOrDefault() + ?? AgentCoreRuntimeContext.FromHttpContext(httpContext); + AgentCoreRuntimeContextProvider.CurrentContext = runtimeContext; + return args; } diff --git a/test/AWS.AgentCore.IntegrationTests/Infrastructure/AgentCoreInvoker.cs b/test/AWS.AgentCore.IntegrationTests/Infrastructure/AgentCoreInvoker.cs index 9f9787e..dd246bb 100644 --- a/test/AWS.AgentCore.IntegrationTests/Infrastructure/AgentCoreInvoker.cs +++ b/test/AWS.AgentCore.IntegrationTests/Infrastructure/AgentCoreInvoker.cs @@ -27,7 +27,7 @@ public AgentCoreInvoker(string region) /// /// Invokes a non-streaming agent and returns the parsed message from the JSON response. /// - public async Task InvokeAsync(string runtimeArn, string prompt, CancellationToken ct = default) + public async Task InvokeAsync(string runtimeArn, string prompt, CancellationToken ct = default, string? sessionId = null) { var payload = JsonSerializer.Serialize(new { prompt }); @@ -45,6 +45,11 @@ public async Task InvokeAsync(string runtimeArn, string prompt Accept = "application/json", }; + if (!string.IsNullOrEmpty(sessionId)) + { + request.RuntimeSessionId = sessionId; + } + var response = await _client.InvokeAgentRuntimeAsync(request, ct); using var reader = new StreamReader(response.Response); @@ -99,7 +104,7 @@ public async Task InvokeAsync(string runtimeArn, string prompt /// Invokes a streaming agent and collects all SSE chunks into a result. /// public async Task InvokeStreamingAsync( - string runtimeArn, string prompt, CancellationToken ct = default) + string runtimeArn, string prompt, CancellationToken ct = default, string? sessionId = null) { var payload = JsonSerializer.Serialize(new { prompt }); @@ -117,6 +122,11 @@ public async Task InvokeStreamingAsync( Accept = "text/event-stream", }; + if (!string.IsNullOrEmpty(sessionId)) + { + request.RuntimeSessionId = sessionId; + } + var response = await _client.InvokeAgentRuntimeAsync(request, ct); var chunks = new List(); diff --git a/test/AWS.AgentCore.IntegrationTests/Infrastructure/TestResourceManager.cs b/test/AWS.AgentCore.IntegrationTests/Infrastructure/TestResourceManager.cs index a5ead1a..8cea6a9 100644 --- a/test/AWS.AgentCore.IntegrationTests/Infrastructure/TestResourceManager.cs +++ b/test/AWS.AgentCore.IntegrationTests/Infrastructure/TestResourceManager.cs @@ -33,6 +33,7 @@ public sealed class TestResourceManager : IAsyncDisposable private string? _roleArn; private string? _ecrRepositoryUri; private string? _ecrRepositoryName; + private string? _memoryId; /// Runtime ARNs keyed by sample app name (e.g. "MicrosoftAgentFrameworkSample"). private readonly Dictionary _runtimeArns = new(); @@ -81,6 +82,13 @@ public async Task GetEcrRepositoryUriAsync(CancellationToken ct = defaul return _ecrRepositoryUri!; } + /// Gets the AgentCore Memory ID. + public async Task GetMemoryIdAsync(CancellationToken ct = default) + { + await EnsureInitializedAsync(ct); + return _memoryId!; + } + public string Region => _region; private async Task EnsureInitializedAsync(CancellationToken ct) @@ -152,6 +160,7 @@ await _cfnClient.CreateStackAsync(new CreateStackRequest _roleArn = outputs["RoleArn"]; _ecrRepositoryUri = outputs["EcrRepositoryUri"]; _ecrRepositoryName = outputs["EcrRepositoryName"]; + _memoryId = outputs["MemoryId"]; Console.Error.WriteLine($"[Resources] Base stack ready. Role={_roleArn}, ECR={_ecrRepositoryUri}"); } @@ -197,6 +206,7 @@ private async Task CreateRuntimesStackAsync(Dictionary imageUris { new() { ParameterKey = "TestRunId", ParameterValue = _testRunId }, new() { ParameterKey = "RoleArn", ParameterValue = _roleArn! }, + new() { ParameterKey = "MemoryId", ParameterValue = _memoryId! }, }; foreach (var (appName, imageUri) in imageUris) diff --git a/test/AWS.AgentCore.IntegrationTests/Infrastructure/inttest-runtimes.template.json b/test/AWS.AgentCore.IntegrationTests/Infrastructure/inttest-runtimes.template.json index af80131..b471adb 100644 --- a/test/AWS.AgentCore.IntegrationTests/Infrastructure/inttest-runtimes.template.json +++ b/test/AWS.AgentCore.IntegrationTests/Infrastructure/inttest-runtimes.template.json @@ -25,6 +25,10 @@ }, "NativeAotAnnotationsImageUri": { "Type": "String" + }, + "MemoryId": { + "Type": "String", + "Description": "AgentCore Memory ID for session history." } }, "Resources": { @@ -40,6 +44,9 @@ "ContainerUri": { "Ref": "MicrosoftAgentFrameworkSampleImageUri" } } }, + "EnvironmentVariables": { + "AWS_AGENTCORE_MEMORY_ID": { "Ref": "MemoryId" } + }, "NetworkConfiguration": { "NetworkMode": "PUBLIC" }, @@ -59,6 +66,9 @@ "ContainerUri": { "Ref": "AnnotationsSampleImageUri" } } }, + "EnvironmentVariables": { + "AWS_AGENTCORE_MEMORY_ID": { "Ref": "MemoryId" } + }, "NetworkConfiguration": { "NetworkMode": "PUBLIC" }, @@ -78,6 +88,9 @@ "ContainerUri": { "Ref": "StreamingAgentImageUri" } } }, + "EnvironmentVariables": { + "AWS_AGENTCORE_MEMORY_ID": { "Ref": "MemoryId" } + }, "NetworkConfiguration": { "NetworkMode": "PUBLIC" }, @@ -99,6 +112,9 @@ "ContainerUri": { "Ref": "AnnotationsStreamingAgentImageUri" } } }, + "EnvironmentVariables": { + "AWS_AGENTCORE_MEMORY_ID": { "Ref": "MemoryId" } + }, "NetworkConfiguration": { "NetworkMode": "PUBLIC" }, @@ -118,6 +134,9 @@ "ContainerUri": { "Ref": "NativeAotExtensionsImageUri" } } }, + "EnvironmentVariables": { + "AWS_AGENTCORE_MEMORY_ID": { "Ref": "MemoryId" } + }, "NetworkConfiguration": { "NetworkMode": "PUBLIC" }, @@ -137,6 +156,9 @@ "ContainerUri": { "Ref": "NativeAotAnnotationsImageUri" } } }, + "EnvironmentVariables": { + "AWS_AGENTCORE_MEMORY_ID": { "Ref": "MemoryId" } + }, "NetworkConfiguration": { "NetworkMode": "PUBLIC" }, diff --git a/test/AWS.AgentCore.IntegrationTests/Infrastructure/inttest-stack.template.json b/test/AWS.AgentCore.IntegrationTests/Infrastructure/inttest-stack.template.json index 05f594c..0420e82 100644 --- a/test/AWS.AgentCore.IntegrationTests/Infrastructure/inttest-stack.template.json +++ b/test/AWS.AgentCore.IntegrationTests/Infrastructure/inttest-stack.template.json @@ -1,6 +1,6 @@ { "AWSTemplateFormatVersion": "2010-09-09", - "Description": "Integration test resources for AWS.AgentCore — IAM role and ECR repository.", + "Description": "Integration test resources for AWS.AgentCore — IAM role, ECR repository, and Memory.", "Parameters": { "TestRunId": { "Type": "String", @@ -124,6 +124,18 @@ "Effect": "Allow", "Action": ["s3:ListAllMyBuckets"], "Resource": "*" + }, + { + "Sid": "AgentCoreMemory", + "Effect": "Allow", + "Action": [ + "bedrock-agentcore:CreateEvent", + "bedrock-agentcore:ListEvents", + "bedrock-agentcore:GetMemory" + ], + "Resource": { + "Fn::Sub": "arn:aws:bedrock-agentcore:${AWS::Region}:${AWS::AccountId}:memory/*" + } } ] } @@ -146,6 +158,17 @@ { "Key": "TestRunId", "Value": { "Ref": "TestRunId" } } ] } + }, + "AgentCoreMemory": { + "Type": "AWS::BedrockAgentCore::Memory", + "Properties": { + "Name": { "Fn::Sub": "inttest_${TestRunId}" }, + "EventExpiryDuration": 3, + "Tags": { + "CreatedBy": "AgentCoreIntegrationTests", + "TestRunId": { "Ref": "TestRunId" } + } + } } }, "Outputs": { @@ -160,6 +183,10 @@ "EcrRepositoryName": { "Description": "Name of the ECR repository.", "Value": { "Ref": "EcrRepository" } + }, + "MemoryId": { + "Description": "ID of the AgentCore Memory for integration tests.", + "Value": { "Fn::GetAtt": ["AgentCoreMemory", "MemoryId"] } } } } diff --git a/test/AWS.AgentCore.IntegrationTests/MemoryIntegrationTests.cs b/test/AWS.AgentCore.IntegrationTests/MemoryIntegrationTests.cs new file mode 100644 index 0000000..4cb798a --- /dev/null +++ b/test/AWS.AgentCore.IntegrationTests/MemoryIntegrationTests.cs @@ -0,0 +1,249 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using AWS.AgentCore.IntegrationTests.Infrastructure; + +namespace AWS.AgentCore.IntegrationTests; + +/// +/// Memory integration tests for non-streaming sample apps. +/// Each test tells the agent a unique piece of information, then asks about it +/// in a separate invocation using the same session ID to verify memory persistence. +/// +public class MicrosoftAgentFrameworkMemoryTests : IClassFixture, IDisposable +{ + private readonly MicrosoftAgentFrameworkFixture _fixture; + private readonly AgentCoreInvoker _invoker; + + public MicrosoftAgentFrameworkMemoryTests(MicrosoftAgentFrameworkFixture fixture) + { + _fixture = fixture; + _invoker = new AgentCoreInvoker(_fixture.Region); + } + + [Fact] + public async Task Memory_RemembersInformationAcrossInvocations() + { + var ct = TestContext.Current.CancellationToken; + var sessionId = $"memory-test-msaf-{Guid.NewGuid():N}"; + var secretCode = $"ALPHA-{Random.Shared.Next(1000, 9999)}"; + + // First invocation: tell the agent a unique piece of information + await _invoker.InvokeAsync( + _fixture.RuntimeArn, + $"Remember this secret code: {secretCode}. Just confirm you've noted it.", + ct, + sessionId: sessionId); + + // Brief delay to allow memory persistence + await Task.Delay(TimeSpan.FromSeconds(2), ct); + + // Second invocation: ask the agent to recall the information + var result = await _invoker.InvokeAsync( + _fixture.RuntimeArn, + "What was the secret code I told you earlier? Reply with ONLY the code, nothing else.", + ct, + sessionId: sessionId); + + Assert.Equal(200, result.HttpStatusCode); + Assert.Contains(secretCode, result.Message); + } + + public void Dispose() => _invoker.Dispose(); +} + +public class AnnotationsSampleMemoryTests : IClassFixture, IDisposable +{ + private readonly AnnotationsSampleFixture _fixture; + private readonly AgentCoreInvoker _invoker; + + public AnnotationsSampleMemoryTests(AnnotationsSampleFixture fixture) + { + _fixture = fixture; + _invoker = new AgentCoreInvoker(_fixture.Region); + } + + [Fact] + public async Task Memory_RemembersInformationAcrossInvocations() + { + var ct = TestContext.Current.CancellationToken; + var sessionId = $"memory-test-annotations-{Guid.NewGuid():N}"; + var secretCode = $"BRAVO-{Random.Shared.Next(1000, 9999)}"; + + await _invoker.InvokeAsync( + _fixture.RuntimeArn, + $"Remember this secret code: {secretCode}. Just confirm you've noted it.", + ct, + sessionId: sessionId); + + await Task.Delay(TimeSpan.FromSeconds(2), ct); + + var result = await _invoker.InvokeAsync( + _fixture.RuntimeArn, + "What was the secret code I told you earlier? Reply with ONLY the code, nothing else.", + ct, + sessionId: sessionId); + + Assert.Equal(200, result.HttpStatusCode); + Assert.Contains(secretCode, result.Message); + } + + public void Dispose() => _invoker.Dispose(); +} + +public class NativeAotExtensionsMemoryTests : IClassFixture, IDisposable +{ + private readonly NativeAotExtensionsFixture _fixture; + private readonly AgentCoreInvoker _invoker; + + public NativeAotExtensionsMemoryTests(NativeAotExtensionsFixture fixture) + { + _fixture = fixture; + _invoker = new AgentCoreInvoker(_fixture.Region); + } + + [Fact] + public async Task Memory_RemembersInformationAcrossInvocations() + { + var ct = TestContext.Current.CancellationToken; + var sessionId = $"memory-test-aot-ext-{Guid.NewGuid():N}"; + var secretCode = $"CHARLIE-{Random.Shared.Next(1000, 9999)}"; + + await _invoker.InvokeAsync( + _fixture.RuntimeArn, + $"Remember this secret code: {secretCode}. Just confirm you've noted it.", + ct, + sessionId: sessionId); + + await Task.Delay(TimeSpan.FromSeconds(2), ct); + + var result = await _invoker.InvokeAsync( + _fixture.RuntimeArn, + "What was the secret code I told you earlier? Reply with ONLY the code, nothing else.", + ct, + sessionId: sessionId); + + Assert.Equal(200, result.HttpStatusCode); + Assert.Contains(secretCode, result.Message); + } + + public void Dispose() => _invoker.Dispose(); +} + +public class NativeAotAnnotationsMemoryTests : IClassFixture, IDisposable +{ + private readonly NativeAotAnnotationsFixture _fixture; + private readonly AgentCoreInvoker _invoker; + + public NativeAotAnnotationsMemoryTests(NativeAotAnnotationsFixture fixture) + { + _fixture = fixture; + _invoker = new AgentCoreInvoker(_fixture.Region); + } + + [Fact] + public async Task Memory_RemembersInformationAcrossInvocations() + { + var ct = TestContext.Current.CancellationToken; + var sessionId = $"memory-test-aot-ann-{Guid.NewGuid():N}"; + var secretCode = $"DELTA-{Random.Shared.Next(1000, 9999)}"; + + await _invoker.InvokeAsync( + _fixture.RuntimeArn, + $"Remember this secret code: {secretCode}. Just confirm you've noted it.", + ct, + sessionId: sessionId); + + await Task.Delay(TimeSpan.FromSeconds(2), ct); + + var result = await _invoker.InvokeAsync( + _fixture.RuntimeArn, + "What was the secret code I told you earlier? Reply with ONLY the code, nothing else.", + ct, + sessionId: sessionId); + + Assert.Equal(200, result.HttpStatusCode); + Assert.Contains(secretCode, result.Message); + } + + public void Dispose() => _invoker.Dispose(); +} + +public class StreamingAgentMemoryTests : IClassFixture, IDisposable +{ + private readonly StreamingAgentFixture _fixture; + private readonly AgentCoreInvoker _invoker; + + public StreamingAgentMemoryTests(StreamingAgentFixture fixture) + { + _fixture = fixture; + _invoker = new AgentCoreInvoker(_fixture.Region); + } + + [Fact] + public async Task Memory_RemembersInformationAcrossInvocations() + { + var ct = TestContext.Current.CancellationToken; + var sessionId = $"memory-test-streaming-{Guid.NewGuid():N}"; + var secretCode = $"ECHO-{Random.Shared.Next(1000, 9999)}"; + + // Use streaming invocation for both calls + await _invoker.InvokeStreamingAsync( + _fixture.RuntimeArn, + $"Remember this secret code: {secretCode}. Just confirm you've noted it.", + ct, + sessionId: sessionId); + + await Task.Delay(TimeSpan.FromSeconds(2), ct); + + var result = await _invoker.InvokeStreamingAsync( + _fixture.RuntimeArn, + "What was the secret code I told you earlier? Reply with ONLY the code, nothing else.", + ct, + sessionId: sessionId); + + Assert.Equal(200, result.HttpStatusCode); + Assert.Contains(secretCode, result.FinalMessage); + } + + public void Dispose() => _invoker.Dispose(); +} + +public class AnnotationsStreamingAgentMemoryTests : IClassFixture, IDisposable +{ + private readonly AnnotationsStreamingAgentFixture _fixture; + private readonly AgentCoreInvoker _invoker; + + public AnnotationsStreamingAgentMemoryTests(AnnotationsStreamingAgentFixture fixture) + { + _fixture = fixture; + _invoker = new AgentCoreInvoker(_fixture.Region); + } + + [Fact] + public async Task Memory_RemembersInformationAcrossInvocations() + { + var ct = TestContext.Current.CancellationToken; + var sessionId = $"memory-test-ann-stream-{Guid.NewGuid():N}"; + var secretCode = $"FOXTROT-{Random.Shared.Next(1000, 9999)}"; + + await _invoker.InvokeStreamingAsync( + _fixture.RuntimeArn, + $"Remember this secret code: {secretCode}. Just confirm you've noted it.", + ct, + sessionId: sessionId); + + await Task.Delay(TimeSpan.FromSeconds(2), ct); + + var result = await _invoker.InvokeStreamingAsync( + _fixture.RuntimeArn, + "What was the secret code I told you earlier? Reply with ONLY the code, nothing else.", + ct, + sessionId: sessionId); + + Assert.Equal(200, result.HttpStatusCode); + Assert.Contains(secretCode, result.FinalMessage); + } + + public void Dispose() => _invoker.Dispose(); +} diff --git a/test/AWS.AgentCore.UnitTests/AWS.AgentCore.UnitTests.csproj b/test/AWS.AgentCore.UnitTests/AWS.AgentCore.UnitTests.csproj index e8257a3..465c468 100644 --- a/test/AWS.AgentCore.UnitTests/AWS.AgentCore.UnitTests.csproj +++ b/test/AWS.AgentCore.UnitTests/AWS.AgentCore.UnitTests.csproj @@ -9,6 +9,7 @@ + diff --git a/test/AWS.AgentCore.UnitTests/AgentCoreMemoryProviderPropertyTests.cs b/test/AWS.AgentCore.UnitTests/AgentCoreMemoryProviderPropertyTests.cs new file mode 100644 index 0000000..c0afafe --- /dev/null +++ b/test/AWS.AgentCore.UnitTests/AgentCoreMemoryProviderPropertyTests.cs @@ -0,0 +1,449 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +#pragma warning disable MAAI001 // Experimental API usage required for testing + +using Amazon.BedrockAgentCore; +using Amazon.BedrockAgentCore.Model; +using AWS.AgentCore; +using FsCheck; +using FsCheck.Xunit; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +namespace AWS.AgentCore.UnitTests; + +/// +/// Property-based tests for AgentCoreMemoryProvider correctness properties. +/// Uses FsCheck to generate arbitrary inputs and verify universal properties. +/// Tag format: Feature: agentcore-memory, Property {number}: {property_text} +/// +public class AgentCoreMemoryProviderPropertyTests +{ + // ────────────────────────────────────────────────────────────────── + // Property 1: Event-to-ChatMessage Conversion Preserves Data + // For any valid AgentCore Memory event with USER/ASSISTANT role and + // non-empty text, conversion produces a ChatMessage with the + // corresponding ChatRole and identical text content. + // Validates: Requirements 1.2, 3.3 + // ────────────────────────────────────────────────────────────────── + + [Property(MaxTest = 100)] + public bool EventToMessageConversion_PreservesRoleAndText(NonEmptyString textWrapper, bool isUser) + { + var text = textWrapper.Get; + // Skip whitespace-only strings since the implementation requires Length > 0 on Content.Text + if (string.IsNullOrWhiteSpace(text)) + return true; // vacuously true for invalid inputs + + var role = isUser ? Role.USER : Role.ASSISTANT; + var expectedChatRole = isUser ? ChatRole.User : ChatRole.Assistant; + + var evt = new Event + { + Payload = + [ + new PayloadType + { + Conversational = new Conversational + { + Role = role, + Content = new Content { Text = text } + } + } + ] + }; + + var success = AgentCoreMemoryProvider.TryConvertEventToChatMessage(evt, out var message); + + return success + && message.Role == expectedChatRole + && message.Text == text; + } + + [Property(MaxTest = 100)] + public bool EventToMessageConversion_ToolAndOtherRoles_ReturnFalse(NonEmptyString textWrapper) + { + var text = textWrapper.Get; + if (string.IsNullOrWhiteSpace(text)) + return true; + + var toolRoles = new[] { Role.TOOL, Role.OTHER }; + + foreach (var role in toolRoles) + { + var evt = new Event + { + Payload = + [ + new PayloadType + { + Conversational = new Conversational + { + Role = role, + Content = new Content { Text = text } + } + } + ] + }; + + if (AgentCoreMemoryProvider.TryConvertEventToChatMessage(evt, out _)) + return false; + } + + return true; + } + + // ────────────────────────────────────────────────────────────────── + // Property 2: Message Filtering Excludes Invalid Messages + // A message is persisted if and only if: (a) User or Assistant role, + // (b) no FunctionCallContent/FunctionResultContent, (c) non-null + // non-whitespace text with length >= 1. + // Validates: Requirements 2.3, 3.1, 3.2, 3.4 + // ────────────────────────────────────────────────────────────────── + + [Property(MaxTest = 100)] + public bool MessageFiltering_UserTextMessages_AreIncluded(NonEmptyString textWrapper, bool isUser) + { + var text = textWrapper.Get; + if (string.IsNullOrWhiteSpace(text)) + return true; // vacuously true — whitespace messages should be excluded + + var chatRole = isUser ? ChatRole.User : ChatRole.Assistant; + var message = new ChatMessage(chatRole, text); + + var result = AgentCoreMemoryProvider.FilterMessagesForStorage( + new[] { message }, null).ToList(); + + var expectedRole = isUser ? Role.USER : Role.ASSISTANT; + + return result.Count == 1 + && result[0].Role == expectedRole + && result[0].Text == text; + } + + [Property(MaxTest = 100)] + public bool MessageFiltering_EmptyOrWhitespaceText_IsExcluded(byte whitespaceCount) + { + // Generate whitespace-only strings of various lengths + var text = new string(' ', whitespaceCount); + var message = new ChatMessage(ChatRole.User, text); + + var result = AgentCoreMemoryProvider.FilterMessagesForStorage( + new[] { message }, null).ToList(); + + return result.Count == 0; + } + + [Property(MaxTest = 100)] + public bool MessageFiltering_ToolCallContent_IsExcluded(NonEmptyString textWrapper) + { + var text = textWrapper.Get; + + // Message with FunctionCallContent should always be excluded + var message = new ChatMessage(ChatRole.Assistant, + [new FunctionCallContent("call-id", "FunctionName", new Dictionary { ["arg"] = text })]); + + var result = AgentCoreMemoryProvider.FilterMessagesForStorage( + new[] { message }, null).ToList(); + + return result.Count == 0; + } + + [Property(MaxTest = 100)] + public bool MessageFiltering_ToolResultContent_IsExcluded(NonEmptyString textWrapper) + { + var text = textWrapper.Get; + + // Message with FunctionResultContent should always be excluded + var message = new ChatMessage(ChatRole.Tool, + [new FunctionResultContent("call-id", text)]); + + var result = AgentCoreMemoryProvider.FilterMessagesForStorage( + new[] { message }, null).ToList(); + + return result.Count == 0; + } + + // ────────────────────────────────────────────────────────────────── + // Property 3: Pagination Fetches All Pages in Order + // For any sequence of N paginated ListEvents responses (1..N-1 have + // NextToken, N does not), the provider makes exactly N API calls and + // returns all events concatenated in page order. + // Validates: Requirements 1.3, 10.1, 10.2 + // ────────────────────────────────────────────────────────────────── + + [Property(MaxTest = 100)] + public async Task Pagination_FetchesAllEventsInOrder(PositiveInt eventCountWrapper) + { + var eventCount = Math.Min(eventCountWrapper.Get, 20); // Cap for test performance + + var events = Enumerable.Range(0, eventCount).Select(i => new Event + { + Payload = + [ + new PayloadType + { + Conversational = new Conversational + { + Role = Role.USER, + Content = new Content { Text = $"message-{i}" } + } + } + ] + }).ToList(); + + var mockPaginator = new Mock(); + mockPaginator.Setup(p => p.Events).Returns(new TestPaginatedEnumerable(events)); + + var mockPaginatorFactory = new Mock(); + mockPaginatorFactory.Setup(f => f.ListEvents(It.IsAny())).Returns(mockPaginator.Object); + + var mockClient = new Mock(); + mockClient.Setup(c => c.Paginators).Returns(mockPaginatorFactory.Object); + + var options = new AgentCoreOptions { MemoryId = "test-memory" }; + var provider = new AgentCoreMemoryProvider(options, NullLogger.Instance, mockClient.Object); + + var session = CreateSessionWithRuntimeContext("test-session"); + var context = CreateInvokingContext(provider, session); + + var result = await InvokeProvideChatHistoryAsync(provider, context); + var messages = result.ToList(); + + Assert.Equal(eventCount, messages.Count); + + for (int i = 0; i < eventCount; i++) + { + Assert.Equal($"message-{i}", messages[i].Text); + } + } + + // ────────────────────────────────────────────────────────────────── + // Property 4: Errors Never Propagate to Caller + // For any exception thrown by the Memory client during ListEvents or + // CreateEvent, the provider catches it and returns gracefully. + // Validates: Requirements 1.5, 2.4 + // ────────────────────────────────────────────────────────────────── + + [Property(MaxTest = 100)] + public async Task Errors_NeverPropagate_OnLoad(NonEmptyString exceptionMessage) + { + var mockPaginator = new Mock(); + mockPaginator.Setup(p => p.Events).Returns(new ThrowingPaginatedEnumerable(new InvalidOperationException(exceptionMessage.Get))); + + var mockPaginatorFactory = new Mock(); + mockPaginatorFactory.Setup(f => f.ListEvents(It.IsAny())).Returns(mockPaginator.Object); + + var mockClient = new Mock(); + mockClient.Setup(c => c.Paginators).Returns(mockPaginatorFactory.Object); + + var options = new AgentCoreOptions { MemoryId = "test-memory" }; + var provider = new AgentCoreMemoryProvider(options, NullLogger.Instance, mockClient.Object); + + var session = CreateSessionWithRuntimeContext("test-session"); + var context = CreateInvokingContext(provider, session); + + // Should not throw — returns empty collection + var result = await InvokeProvideChatHistoryAsync(provider, context); + Assert.Empty(result); + } + + [Property(MaxTest = 100)] + public async Task Errors_NeverPropagate_OnSave(NonEmptyString exceptionMessage, NonEmptyString messageText) + { + if (string.IsNullOrWhiteSpace(messageText.Get)) + return; + + var mockClient = new Mock(); + mockClient + .Setup(c => c.CreateEventAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException(exceptionMessage.Get)); + + var options = new AgentCoreOptions { MemoryId = "test-memory" }; + var provider = new AgentCoreMemoryProvider(options, NullLogger.Instance, mockClient.Object); + + var session = CreateSessionWithRuntimeContext("test-session"); + var context = CreateInvokedContext( + provider, + session, + new[] { new ChatMessage(ChatRole.User, messageText.Get) }, + new[] { new ChatMessage(ChatRole.Assistant, "response") }); + + // Should not throw + await InvokeStoreChatHistoryAsync(provider, context); + } + + // ────────────────────────────────────────────────────────────────── + // Property 6: Partial Pagination Failure Returns Loaded Pages + // When an error occurs after some events have been loaded, the + // provider returns what was loaded and does not throw. + // Validates: Requirements 10.3 + // ────────────────────────────────────────────────────────────────── + + [Property(MaxTest = 100)] + public async Task PartialPaginationFailure_ReturnsLoadedEvents(PositiveInt successfulEventsWrapper) + { + var successfulEvents = Math.Min(successfulEventsWrapper.Get, 20); // Cap for performance + + var mockPaginator = new Mock(); + mockPaginator.Setup(p => p.Events).Returns(new EventsThenThrowPaginatedEnumerable(successfulEvents)); + + var mockPaginatorFactory = new Mock(); + mockPaginatorFactory.Setup(f => f.ListEvents(It.IsAny())).Returns(mockPaginator.Object); + + var mockClient = new Mock(); + mockClient.Setup(c => c.Paginators).Returns(mockPaginatorFactory.Object); + + var options = new AgentCoreOptions { MemoryId = "test-memory" }; + var provider = new AgentCoreMemoryProvider(options, NullLogger.Instance, mockClient.Object); + + var session = CreateSessionWithRuntimeContext("test-session"); + var context = CreateInvokingContext(provider, session); + + // Should not throw — returns partial results + var result = await InvokeProvideChatHistoryAsync(provider, context); + var messages = result.ToList(); + + // Should have exactly the messages from successful events + Assert.Equal(successfulEvents, messages.Count); + for (int i = 0; i < successfulEvents; i++) + { + Assert.Equal($"event-{i}", messages[i].Text); + } + } + + // ────────────────────────────────────────────────────────────────── + // Helper methods + // ────────────────────────────────────────────────────────────────── + + private static AgentSession CreateSessionWithRuntimeContext(string sessionId) + { + var session = new Mock() { CallBase = true }.Object; + var runtimeContext = new AgentCoreRuntimeContext + { + SessionId = sessionId, + RequestId = "test-request" + }; + session.StateBag.SetValue(AgentCoreRuntimeContextProvider.ContextKey, runtimeContext); + return session; + } + + private static ChatHistoryProvider.InvokingContext CreateInvokingContext( + AgentCoreMemoryProvider provider, + AgentSession session) + { + // The InvokingContext constructor requires (AIAgent, AgentSession, IEnumerable) + // We use a mock AIAgent since we only need the session for our tests + var mockAgent = new Mock() { CallBase = false }; + return new ChatHistoryProvider.InvokingContext( + mockAgent.Object, + session, + new List()); + } + + private static ChatHistoryProvider.InvokedContext CreateInvokedContext( + AgentCoreMemoryProvider provider, + AgentSession session, + IEnumerable requestMessages, + IEnumerable responseMessages) + { + var mockAgent = new Mock() { CallBase = false }; + return new ChatHistoryProvider.InvokedContext( + mockAgent.Object, + session, + requestMessages, + responseMessages); + } + + private static async Task> InvokeProvideChatHistoryAsync( + AgentCoreMemoryProvider provider, + ChatHistoryProvider.InvokingContext context) + { + // ProvideChatHistoryAsync is protected, invoke via reflection + var method = typeof(AgentCoreMemoryProvider).GetMethod( + "ProvideChatHistoryAsync", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + + var task = (ValueTask>)method!.Invoke( + provider, new object[] { context, CancellationToken.None })!; + + return await task; + } + + private static async Task InvokeStoreChatHistoryAsync( + AgentCoreMemoryProvider provider, + ChatHistoryProvider.InvokedContext context) + { + var method = typeof(AgentCoreMemoryProvider).GetMethod( + "StoreChatHistoryAsync", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + + var task = (ValueTask)method!.Invoke( + provider, new object[] { context, CancellationToken.None })!; + + await task; + } + + /// Helper: wraps a list as IPaginatedEnumerable for mocking paginator.Events + private sealed class TestPaginatedEnumerable : Amazon.Runtime.IPaginatedEnumerable + { + private readonly IEnumerable _items; + public TestPaginatedEnumerable(IEnumerable items) => _items = items; + public async IAsyncEnumerator GetAsyncEnumerator(CancellationToken ct = default) + { + foreach (var item in _items) + { + await Task.CompletedTask; + yield return item; + } + } + } + + /// Helper: IPaginatedEnumerable that throws immediately + private sealed class ThrowingPaginatedEnumerable : Amazon.Runtime.IPaginatedEnumerable + { + private readonly Exception _ex; + public ThrowingPaginatedEnumerable(Exception ex) => _ex = ex; +#pragma warning disable CS0162 + public async IAsyncEnumerator GetAsyncEnumerator(CancellationToken ct = default) + { + await Task.CompletedTask; + throw _ex; + yield break; + } +#pragma warning restore CS0162 + } + + /// Helper: yields N events then throws + private sealed class EventsThenThrowPaginatedEnumerable : Amazon.Runtime.IPaginatedEnumerable + { + private readonly int _successfulCount; + public EventsThenThrowPaginatedEnumerable(int successfulCount) => _successfulCount = successfulCount; + public async IAsyncEnumerator GetAsyncEnumerator(CancellationToken ct = default) + { + for (int i = 0; i < _successfulCount; i++) + { + await Task.CompletedTask; + yield return new Event + { + Payload = + [ + new PayloadType + { + Conversational = new Conversational + { + Role = Role.USER, + Content = new Content { Text = $"event-{i}" } + } + } + ] + }; + } + + throw new AmazonBedrockAgentCoreException("Simulated pagination failure"); + } + } +} diff --git a/test/AWS.AgentCore.UnitTests/AgentCoreMemoryProviderTests.cs b/test/AWS.AgentCore.UnitTests/AgentCoreMemoryProviderTests.cs new file mode 100644 index 0000000..b249be2 --- /dev/null +++ b/test/AWS.AgentCore.UnitTests/AgentCoreMemoryProviderTests.cs @@ -0,0 +1,354 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using Amazon.BedrockAgentCore; +using Amazon.BedrockAgentCore.Model; +using AWS.AgentCore.Extensions; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +namespace AWS.AgentCore.UnitTests; + +[Collection("EnvironmentVariableTests")] +public class AgentCoreMemoryProviderTests +{ + private static AgentCoreMemoryProvider CreateProvider( + AgentCoreOptions? options = null, + IAmazonBedrockAgentCore? memoryClient = null, + ILogger? logger = null) + { + return new AgentCoreMemoryProvider( + options ?? new AgentCoreOptions(), + logger ?? NullLogger.Instance, + memoryClient); + } + + // ────────────────────────────────────────────────────────────────── + // Configuration tests + // ────────────────────────────────────────────────────────────────── + + [Fact] + public void GetEffectiveMemoryId_WhenOptionsSet_ReturnsOptionsValue() + { + var provider = CreateProvider(new AgentCoreOptions { MemoryId = "mem-from-options" }); + + var result = provider.GetEffectiveMemoryId(); + + Assert.Equal("mem-from-options", result); + } + + [Fact] + public void GetEffectiveMemoryId_WhenOptionsNotSet_FallsBackToEnvVar() + { + Environment.SetEnvironmentVariable(Constants.MemoryIdEnvironmentVariable, "mem-from-env"); + try + { + var provider = CreateProvider(new AgentCoreOptions { MemoryId = null }); + + var result = provider.GetEffectiveMemoryId(); + + Assert.Equal("mem-from-env", result); + } + finally + { + Environment.SetEnvironmentVariable(Constants.MemoryIdEnvironmentVariable, null); + } + } + + [Fact] + public void GetEffectiveMemoryId_WhenBothSet_OptionsWins() + { + Environment.SetEnvironmentVariable(Constants.MemoryIdEnvironmentVariable, "mem-from-env"); + try + { + var provider = CreateProvider(new AgentCoreOptions { MemoryId = "mem-from-options" }); + + var result = provider.GetEffectiveMemoryId(); + + Assert.Equal("mem-from-options", result); + } + finally + { + Environment.SetEnvironmentVariable(Constants.MemoryIdEnvironmentVariable, null); + } + } + + [Fact] + public void GetEffectiveMemoryId_WhenNeitherSet_ReturnsNull() + { + Environment.SetEnvironmentVariable(Constants.MemoryIdEnvironmentVariable, null); + var provider = CreateProvider(new AgentCoreOptions { MemoryId = null }); + + var result = provider.GetEffectiveMemoryId(); + + Assert.Null(result); + } + + // ────────────────────────────────────────────────────────────────── + // Message filtering tests + // ────────────────────────────────────────────────────────────────── + + [Fact] + public void FilterMessagesForStorage_SkipsToolCallMessages() + { + var messages = new List + { + new(ChatRole.Assistant, [new FunctionCallContent("call-1", "GetWeather", new Dictionary { ["location"] = "Seattle" })]), + }; + + var result = AgentCoreMemoryProvider.FilterMessagesForStorage(messages, null).ToList(); + + Assert.Empty(result); + } + + [Fact] + public void FilterMessagesForStorage_SkipsToolResultMessages() + { + var messages = new List + { + new(ChatRole.Tool, [new FunctionResultContent("call-1", "result data")]), + }; + + var result = AgentCoreMemoryProvider.FilterMessagesForStorage(messages, null).ToList(); + + Assert.Empty(result); + } + + [Fact] + public void FilterMessagesForStorage_SkipsEmptyTextMessages() + { + var messages = new List + { + new(ChatRole.User, ""), + new(ChatRole.User, " "), + }; + + var result = AgentCoreMemoryProvider.FilterMessagesForStorage(messages, null).ToList(); + + Assert.Empty(result); + } + + [Fact] + public void FilterMessagesForStorage_SavesUserAndAssistantMessages() + { + var requestMessages = new List + { + new(ChatRole.User, "Hello"), + }; + var responseMessages = new List + { + new(ChatRole.Assistant, "Hi there!"), + }; + + var result = AgentCoreMemoryProvider.FilterMessagesForStorage(requestMessages, responseMessages).ToList(); + + Assert.Equal(2, result.Count); + Assert.Equal(Amazon.BedrockAgentCore.Role.USER, result[0].Role); + Assert.Equal("Hello", result[0].Text); + Assert.Equal(Amazon.BedrockAgentCore.Role.ASSISTANT, result[1].Role); + Assert.Equal("Hi there!", result[1].Text); + } + + // ────────────────────────────────────────────────────────────────── + // Event conversion tests + // ────────────────────────────────────────────────────────────────── + + [Fact] + public void TryConvertEventToChatMessage_ValidUserEvent_ReturnsUserMessage() + { + var evt = new Event + { + Payload = + [ + new PayloadType + { + Conversational = new Conversational + { + Role = Amazon.BedrockAgentCore.Role.USER, + Content = new Content { Text = "Hello" } + } + } + ] + }; + + var success = AgentCoreMemoryProvider.TryConvertEventToChatMessage(evt, out var message); + + Assert.True(success); + Assert.Equal(ChatRole.User, message.Role); + Assert.Equal("Hello", message.Text); + } + + [Fact] + public void TryConvertEventToChatMessage_ValidAssistantEvent_ReturnsAssistantMessage() + { + var evt = new Event + { + Payload = + [ + new PayloadType + { + Conversational = new Conversational + { + Role = Amazon.BedrockAgentCore.Role.ASSISTANT, + Content = new Content { Text = "Hi there!" } + } + } + ] + }; + + var success = AgentCoreMemoryProvider.TryConvertEventToChatMessage(evt, out var message); + + Assert.True(success); + Assert.Equal(ChatRole.Assistant, message.Role); + Assert.Equal("Hi there!", message.Text); + } + + [Fact] + public void TryConvertEventToChatMessage_ToolRoleEvent_ReturnsFalse() + { + var evt = new Event + { + Payload = + [ + new PayloadType + { + Conversational = new Conversational + { + Role = Amazon.BedrockAgentCore.Role.TOOL, + Content = new Content { Text = "tool result" } + } + } + ] + }; + + var success = AgentCoreMemoryProvider.TryConvertEventToChatMessage(evt, out _); + + Assert.False(success); + } + + [Fact] + public void TryConvertEventToChatMessage_EmptyPayload_ReturnsFalse() + { + var evt = new Event { Payload = [] }; + + var success = AgentCoreMemoryProvider.TryConvertEventToChatMessage(evt, out _); + + Assert.False(success); + } + + [Fact] + public void TryConvertEventToChatMessage_EmptyText_ReturnsFalse() + { + #pragma warning disable BedrockAgentCore1000 // SDK validation warning for intentionally invalid test input + var evt = new Event + { + Payload = + [ + new PayloadType + { + Conversational = new Conversational + { + Role = Amazon.BedrockAgentCore.Role.USER, + Content = new Content { Text = "" } + } + } + ] + }; + #pragma warning restore BedrockAgentCore1000 + + var success = AgentCoreMemoryProvider.TryConvertEventToChatMessage(evt, out _); + + Assert.False(success); + } + + // ────────────────────────────────────────────────────────────────── + // HasToolContent tests + // ────────────────────────────────────────────────────────────────── + + [Fact] + public void HasToolContent_WithFunctionCallContent_ReturnsTrue() + { + var message = new ChatMessage(ChatRole.Assistant, + [new FunctionCallContent("call-1", "GetWeather", new Dictionary { ["loc"] = "NYC" })]); + + Assert.True(AgentCoreMemoryProvider.HasToolContent(message)); + } + + [Fact] + public void HasToolContent_WithFunctionResultContent_ReturnsTrue() + { + var message = new ChatMessage(ChatRole.Tool, + [new FunctionResultContent("call-1", "sunny")]); + + Assert.True(AgentCoreMemoryProvider.HasToolContent(message)); + } + + [Fact] + public void HasToolContent_WithTextOnly_ReturnsFalse() + { + var message = new ChatMessage(ChatRole.User, "Hello"); + + Assert.False(AgentCoreMemoryProvider.HasToolContent(message)); + } +} + +public class AgentCoreMemoryDIRegistrationTests +{ + [Fact] + public void AddAgentCore_RegistersMemoryProvider() + { + var builder = WebApplication.CreateBuilder(); + var mockClient = new Mock(); + + builder.AddAgentCore(options => + { + options.ChatClient = mockClient.Object; + }); + + var sp = builder.Build().Services; + var memoryProvider = sp.GetService(); + + Assert.NotNull(memoryProvider); + } + + [Fact] + public void AddAgentCore_RegistersIAmazonBedrockAgentCore() + { + var builder = WebApplication.CreateBuilder(); + var mockClient = new Mock(); + + builder.AddAgentCore(options => + { + options.ChatClient = mockClient.Object; + }); + + var sp = builder.Build().Services; + // TryAddAWSService registers it — it should be resolvable (may fail at runtime without credentials, but the registration exists) + var descriptor = builder.Services.FirstOrDefault(d => d.ServiceType == typeof(IAmazonBedrockAgentCore)); + Assert.NotNull(descriptor); + } + + [Fact] + public void AddAgentCore_WiresMemoryProviderAsChatHistoryProvider() + { + var builder = WebApplication.CreateBuilder(); + var mockClient = new Mock(); + + builder.AddAgentCore(options => + { + options.ChatClient = mockClient.Object; + }); + + var sp = builder.Build().Services; + var agent = sp.GetRequiredService(); + + // The agent should be a ChatClientAgent with the memory provider wired + Assert.IsType(agent); + var chatAgent = (Microsoft.Agents.AI.ChatClientAgent)agent; + Assert.IsType(chatAgent.ChatHistoryProvider); + } +}