diff --git a/dotnet/src/Microsoft.Agents.AI/Memory/ChatHistoryMemoryProvider.cs b/dotnet/src/Microsoft.Agents.AI/Memory/ChatHistoryMemoryProvider.cs index 80d5e1144f..0cc35fe85e 100644 --- a/dotnet/src/Microsoft.Agents.AI/Memory/ChatHistoryMemoryProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Memory/ChatHistoryMemoryProvider.cs @@ -350,36 +350,38 @@ private async Task SearchTextAsync(string userQuestion, ChatHistoryMemor string? userId = searchScope.UserId; string? sessionId = searchScope.SessionId; - Expression, bool>>? filter = null; + // Build a combined filter using a single shared parameter to avoid expression tree + // scoping issues when multiple filters are combined with AndAlso. + ParameterExpression parameter = Expression.Parameter(typeof(Dictionary), "x"); + Expression? filterBody = null; + if (applicationId != null) { - filter = x => (string?)x[ApplicationIdField] == applicationId; + filterBody = RebindFilterBody(x => (string?)x[ApplicationIdField] == applicationId, parameter); } if (agentId != null) { - Expression, bool>> agentIdFilter = x => (string?)x[AgentIdField] == agentId; - filter = filter == null ? agentIdFilter : Expression.Lambda, bool>>( - Expression.AndAlso(filter.Body, agentIdFilter.Body), - filter.Parameters); + Expression body = RebindFilterBody(x => (string?)x[AgentIdField] == agentId, parameter); + filterBody = filterBody == null ? body : Expression.AndAlso(filterBody, body); } if (userId != null) { - Expression, bool>> userIdFilter = x => (string?)x[UserIdField] == userId; - filter = filter == null ? userIdFilter : Expression.Lambda, bool>>( - Expression.AndAlso(filter.Body, userIdFilter.Body), - filter.Parameters); + Expression body = RebindFilterBody(x => (string?)x[UserIdField] == userId, parameter); + filterBody = filterBody == null ? body : Expression.AndAlso(filterBody, body); } if (sessionId != null) { - Expression, bool>> sessionIdFilter = x => (string?)x[SessionIdField] == sessionId; - filter = filter == null ? sessionIdFilter : Expression.Lambda, bool>>( - Expression.AndAlso(filter.Body, sessionIdFilter.Body), - filter.Parameters); + Expression body = RebindFilterBody(x => (string?)x[SessionIdField] == sessionId, parameter); + filterBody = filterBody == null ? body : Expression.AndAlso(filterBody, body); } + Expression, bool>>? filter = filterBody != null + ? Expression.Lambda, bool>>(filterBody, parameter) + : null; + // Use search to find relevant messages var searchResults = collection.SearchAsync( queryText, @@ -467,6 +469,27 @@ public void Dispose() private string? SanitizeLogData(string? data) => this._enableSensitiveTelemetryData ? data : ""; + /// + /// Rebinds a filter expression's body to use the specified shared parameter, + /// replacing the original lambda parameter so that multiple filters can be safely + /// combined with . + /// + private static Expression RebindFilterBody( + Expression, bool>> filter, + ParameterExpression sharedParameter) + { + return new ParameterReplacer(filter.Parameters[0], sharedParameter).Visit(filter.Body); + } + + /// + /// An that replaces one with another. + /// + private sealed class ParameterReplacer(ParameterExpression original, ParameterExpression replacement) : ExpressionVisitor + { + protected override Expression VisitParameter(ParameterExpression node) + => node == original ? replacement : base.VisitParameter(node); + } + /// /// Represents the state of a stored in the . /// diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Memory/ChatHistoryMemoryProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Memory/ChatHistoryMemoryProviderTests.cs index 5211fa0956..35c7f780b4 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Memory/ChatHistoryMemoryProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Memory/ChatHistoryMemoryProviderTests.cs @@ -454,6 +454,77 @@ public async Task InvokedAsync_CreatesFilter_WhenSearchScopeProvidedAsync() Times.Once); } + [Fact] + public async Task InvokedAsync_CombinedFilterCanBeCompiled_WhenMultipleScopeFiltersProvidedAsync() + { + // Arrange + // This test reproduces a bug where combining multiple scope filters + // (e.g. userId + sessionId) produces an expression tree with dangling + // ParameterExpression references that fails at compile time. + ChatHistoryMemoryProviderOptions providerOptions = new() + { + SearchTime = ChatHistoryMemoryProviderOptions.SearchBehavior.BeforeAIInvoke, + MaxResults = 2, + ContextPrompt = "Here is the relevant chat history:\n" + }; + + ChatHistoryMemoryProviderScope searchScope = new() + { + ApplicationId = "app1", + AgentId = "agent1", + SessionId = "session1", + UserId = "user1" + }; + + System.Linq.Expressions.Expression, bool>>? capturedFilter = null; + + this._vectorStoreCollectionMock + .Setup(c => c.SearchAsync( + It.IsAny(), + It.IsAny(), + It.IsAny>>(), + It.IsAny())) + .Callback((string query, int maxResults, VectorSearchOptions> options, CancellationToken ct) => + capturedFilter = options.Filter) + .Returns(ToAsyncEnumerableAsync(new List>>())); + + ChatHistoryMemoryProvider provider = new( + this._vectorStoreMock.Object, + TestCollectionName, + 1, + _ => new ChatHistoryMemoryProvider.State(searchScope, searchScope), + options: providerOptions); + + ChatMessage requestMsg = new(ChatRole.User, "requesting relevant history"); + AIContextProvider.InvokingContext invokingContext = new(s_mockAgent, new TestAgentSession(), new AIContext { Messages = new List { requestMsg } }); + + // Act + await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert - The filter must be compilable and executable without expression tree scoping errors + Assert.NotNull(capturedFilter); + Func, bool> compiledFilter = capturedFilter!.Compile(); + + Dictionary matchingRecord = new() + { + ["ApplicationId"] = "app1", + ["AgentId"] = "agent1", + ["SessionId"] = "session1", + ["UserId"] = "user1" + }; + + Dictionary nonMatchingRecord = new() + { + ["ApplicationId"] = "app1", + ["AgentId"] = "agent1", + ["SessionId"] = "other-session", + ["UserId"] = "user1" + }; + + Assert.True(compiledFilter(matchingRecord)); + Assert.False(compiledFilter(nonMatchingRecord)); + } + [Theory] [InlineData(false, false, 2)] [InlineData(true, false, 2)]