Skip to content

Commit f7e4143

Browse files
westey-mCopilot
andauthored
.NET: Fix filter combine logic for ChatHistoryMemoryProvider (#4501)
* Fix filter combine logic for ChatHistoryMemoryProvider * Replace var with explicit types in filter building code and test Address PR review nit: use explicit types instead of var for better readability in the filter-building logic and the new combined filter compilation test. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix style issues --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent d8d6ac1 commit f7e4143

2 files changed

Lines changed: 108 additions & 14 deletions

File tree

dotnet/src/Microsoft.Agents.AI/Memory/ChatHistoryMemoryProvider.cs

Lines changed: 37 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -350,36 +350,38 @@ private async Task<string> SearchTextAsync(string userQuestion, ChatHistoryMemor
350350
string? userId = searchScope.UserId;
351351
string? sessionId = searchScope.SessionId;
352352

353-
Expression<Func<Dictionary<string, object?>, bool>>? filter = null;
353+
// Build a combined filter using a single shared parameter to avoid expression tree
354+
// scoping issues when multiple filters are combined with AndAlso.
355+
ParameterExpression parameter = Expression.Parameter(typeof(Dictionary<string, object?>), "x");
356+
Expression? filterBody = null;
357+
354358
if (applicationId != null)
355359
{
356-
filter = x => (string?)x[ApplicationIdField] == applicationId;
360+
filterBody = RebindFilterBody(x => (string?)x[ApplicationIdField] == applicationId, parameter);
357361
}
358362

359363
if (agentId != null)
360364
{
361-
Expression<Func<Dictionary<string, object?>, bool>> agentIdFilter = x => (string?)x[AgentIdField] == agentId;
362-
filter = filter == null ? agentIdFilter : Expression.Lambda<Func<Dictionary<string, object?>, bool>>(
363-
Expression.AndAlso(filter.Body, agentIdFilter.Body),
364-
filter.Parameters);
365+
Expression body = RebindFilterBody(x => (string?)x[AgentIdField] == agentId, parameter);
366+
filterBody = filterBody == null ? body : Expression.AndAlso(filterBody, body);
365367
}
366368

367369
if (userId != null)
368370
{
369-
Expression<Func<Dictionary<string, object?>, bool>> userIdFilter = x => (string?)x[UserIdField] == userId;
370-
filter = filter == null ? userIdFilter : Expression.Lambda<Func<Dictionary<string, object?>, bool>>(
371-
Expression.AndAlso(filter.Body, userIdFilter.Body),
372-
filter.Parameters);
371+
Expression body = RebindFilterBody(x => (string?)x[UserIdField] == userId, parameter);
372+
filterBody = filterBody == null ? body : Expression.AndAlso(filterBody, body);
373373
}
374374

375375
if (sessionId != null)
376376
{
377-
Expression<Func<Dictionary<string, object?>, bool>> sessionIdFilter = x => (string?)x[SessionIdField] == sessionId;
378-
filter = filter == null ? sessionIdFilter : Expression.Lambda<Func<Dictionary<string, object?>, bool>>(
379-
Expression.AndAlso(filter.Body, sessionIdFilter.Body),
380-
filter.Parameters);
377+
Expression body = RebindFilterBody(x => (string?)x[SessionIdField] == sessionId, parameter);
378+
filterBody = filterBody == null ? body : Expression.AndAlso(filterBody, body);
381379
}
382380

381+
Expression<Func<Dictionary<string, object?>, bool>>? filter = filterBody != null
382+
? Expression.Lambda<Func<Dictionary<string, object?>, bool>>(filterBody, parameter)
383+
: null;
384+
383385
// Use search to find relevant messages
384386
var searchResults = collection.SearchAsync(
385387
queryText,
@@ -467,6 +469,27 @@ public void Dispose()
467469

468470
private string? SanitizeLogData(string? data) => this._enableSensitiveTelemetryData ? data : "<redacted>";
469471

472+
/// <summary>
473+
/// Rebinds a filter expression's body to use the specified shared parameter,
474+
/// replacing the original lambda parameter so that multiple filters can be safely
475+
/// combined with <see cref="Expression.AndAlso(Expression, Expression)"/>.
476+
/// </summary>
477+
private static Expression RebindFilterBody(
478+
Expression<Func<Dictionary<string, object?>, bool>> filter,
479+
ParameterExpression sharedParameter)
480+
{
481+
return new ParameterReplacer(filter.Parameters[0], sharedParameter).Visit(filter.Body);
482+
}
483+
484+
/// <summary>
485+
/// An <see cref="ExpressionVisitor"/> that replaces one <see cref="ParameterExpression"/> with another.
486+
/// </summary>
487+
private sealed class ParameterReplacer(ParameterExpression original, ParameterExpression replacement) : ExpressionVisitor
488+
{
489+
protected override Expression VisitParameter(ParameterExpression node)
490+
=> node == original ? replacement : base.VisitParameter(node);
491+
}
492+
470493
/// <summary>
471494
/// Represents the state of a <see cref="ChatHistoryMemoryProvider"/> stored in the <see cref="AgentSession.StateBag"/>.
472495
/// </summary>

dotnet/tests/Microsoft.Agents.AI.UnitTests/Memory/ChatHistoryMemoryProviderTests.cs

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -454,6 +454,77 @@ public async Task InvokedAsync_CreatesFilter_WhenSearchScopeProvidedAsync()
454454
Times.Once);
455455
}
456456

457+
[Fact]
458+
public async Task InvokedAsync_CombinedFilterCanBeCompiled_WhenMultipleScopeFiltersProvidedAsync()
459+
{
460+
// Arrange
461+
// This test reproduces a bug where combining multiple scope filters
462+
// (e.g. userId + sessionId) produces an expression tree with dangling
463+
// ParameterExpression references that fails at compile time.
464+
ChatHistoryMemoryProviderOptions providerOptions = new()
465+
{
466+
SearchTime = ChatHistoryMemoryProviderOptions.SearchBehavior.BeforeAIInvoke,
467+
MaxResults = 2,
468+
ContextPrompt = "Here is the relevant chat history:\n"
469+
};
470+
471+
ChatHistoryMemoryProviderScope searchScope = new()
472+
{
473+
ApplicationId = "app1",
474+
AgentId = "agent1",
475+
SessionId = "session1",
476+
UserId = "user1"
477+
};
478+
479+
System.Linq.Expressions.Expression<Func<Dictionary<string, object?>, bool>>? capturedFilter = null;
480+
481+
this._vectorStoreCollectionMock
482+
.Setup(c => c.SearchAsync(
483+
It.IsAny<string>(),
484+
It.IsAny<int>(),
485+
It.IsAny<VectorSearchOptions<Dictionary<string, object?>>>(),
486+
It.IsAny<CancellationToken>()))
487+
.Callback((string query, int maxResults, VectorSearchOptions<Dictionary<string, object?>> options, CancellationToken ct) =>
488+
capturedFilter = options.Filter)
489+
.Returns(ToAsyncEnumerableAsync(new List<VectorSearchResult<Dictionary<string, object?>>>()));
490+
491+
ChatHistoryMemoryProvider provider = new(
492+
this._vectorStoreMock.Object,
493+
TestCollectionName,
494+
1,
495+
_ => new ChatHistoryMemoryProvider.State(searchScope, searchScope),
496+
options: providerOptions);
497+
498+
ChatMessage requestMsg = new(ChatRole.User, "requesting relevant history");
499+
AIContextProvider.InvokingContext invokingContext = new(s_mockAgent, new TestAgentSession(), new AIContext { Messages = new List<ChatMessage> { requestMsg } });
500+
501+
// Act
502+
await provider.InvokingAsync(invokingContext, CancellationToken.None);
503+
504+
// Assert - The filter must be compilable and executable without expression tree scoping errors
505+
Assert.NotNull(capturedFilter);
506+
Func<Dictionary<string, object?>, bool> compiledFilter = capturedFilter!.Compile();
507+
508+
Dictionary<string, object?> matchingRecord = new()
509+
{
510+
["ApplicationId"] = "app1",
511+
["AgentId"] = "agent1",
512+
["SessionId"] = "session1",
513+
["UserId"] = "user1"
514+
};
515+
516+
Dictionary<string, object?> nonMatchingRecord = new()
517+
{
518+
["ApplicationId"] = "app1",
519+
["AgentId"] = "agent1",
520+
["SessionId"] = "other-session",
521+
["UserId"] = "user1"
522+
};
523+
524+
Assert.True(compiledFilter(matchingRecord));
525+
Assert.False(compiledFilter(nonMatchingRecord));
526+
}
527+
457528
[Theory]
458529
[InlineData(false, false, 2)]
459530
[InlineData(true, false, 2)]

0 commit comments

Comments
 (0)