diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/AIContext.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/AIContext.cs index c0b418b625..b05992d93e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/AIContext.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/AIContext.cs @@ -11,7 +11,7 @@ namespace Microsoft.Agents.AI; /// /// /// serves as a container for contextual information that instances -/// can supply to enhance AI model interactions. This context is combined across multiple providers and merged with +/// can supply to enhance AI model interactions. This context is merged with /// the agent's base configuration before being passed to the underlying AI model. /// /// @@ -24,7 +24,7 @@ namespace Microsoft.Agents.AI; /// /// /// -/// Context information is transient by default and applies only to the current invocation, though messages +/// Context information is transient by default and applies only to the current invocation, however messages /// added through the property will be permanently incorporated into the conversation history. /// /// diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/AIContextProvider.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/AIContextProvider.cs index 337cfae2e2..a4b3f5d956 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/AIContextProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/AIContextProvider.cs @@ -18,25 +18,25 @@ namespace Microsoft.Agents.AI; /// An AI context provider is a component that participates in the agent invocation lifecycle by: /// /// Listening to changes in conversations -/// Providing additional context to AI models or agents before invocation +/// Providing additional context to agents during invocation /// Supplying additional function tools for enhanced capabilities /// Processing invocation results for state management or learning /// /// /// -/// Context providers operate through a two-phase lifecycle: they are called before invocation via -/// to provide context, and optionally called after invocation via +/// Context providers operate through a two-phase lifecycle: they are called at the start of invocation via +/// to provide context, and optionally called at the end of invocation via /// to process results. /// /// public abstract class AIContextProvider { /// - /// Called immediately before an AI model or agent is invoked to provide additional context. + /// Called at the start of agent invocation to provide additional context. /// - /// Contains the request context including the messages that will be sent to the AI model or agent. + /// Contains the request context including the caller provided messages that will be used by the agent for this invocation. /// The to monitor for cancellation requests. The default is . - /// A task that represents the asynchronous operation. The task result contains the with additional context to be provided to the AI model or agent. + /// A task that represents the asynchronous operation. The task result contains the with additional context to be used by the agent during this invocation. /// /// /// Implementers can load any additional context required at this time, such as: @@ -47,14 +47,11 @@ public abstract class AIContextProvider /// Injecting contextual messages from conversation history /// /// - /// - /// The returned context will be combined with context from other providers before being passed to the AI model or agent. - /// /// public abstract ValueTask InvokingAsync(InvokingContext context, CancellationToken cancellationToken = default); /// - /// Called immediately after an AI model or agent has been invoked to process the results. + /// Called at the end of the agent invocation to process the invocation results. /// /// Contains the invocation context including request messages, response messages, and any exception that occurred. /// The to monitor for cancellation requests. The default is . @@ -123,8 +120,8 @@ public abstract class AIContextProvider /// Contains the context information provided to . /// /// - /// This class provides context about the upcoming AI model or agent invocation, including the messages - /// that will be sent. Context providers can use this information to determine what additional context + /// This class provides context about the invocation before the underlying AI model is invoked, including the messages + /// that will be used. Context providers can use this information to determine what additional context /// should be provided for the invocation. /// public class InvokingContext @@ -132,7 +129,7 @@ public abstract class AIContextProvider /// /// Initializes a new instance of the class with the specified request messages. /// - /// The messages to be sent to the AI model or agent for this invocation. + /// The messages to be used by the agent for this invocation. /// is . public InvokingContext(IEnumerable requestMessages) { @@ -140,11 +137,10 @@ public abstract class AIContextProvider } /// - /// Gets the messages that will be sent to the AI model or agent for this invocation. + /// Gets the caller provided messages that will be used by the agent for this invocation. /// /// - /// A collection of instances representing the conversation history - /// and new messages that will be processed by the AI model or agent. + /// A collection of instances representing new messages that were provided by the caller. /// public IEnumerable RequestMessages { get; } } @@ -153,8 +149,8 @@ public abstract class AIContextProvider /// Contains the context information provided to . /// /// - /// This class provides context about a completed AI model or agent invocation, including both the - /// request messages that were sent and the response messages that were generated. It also indicates + /// This class provides context about a completed agent invocation, including both the + /// request messages that were used and the response messages that were generated. It also indicates /// whether the invocation succeeded or failed. /// public class InvokedContext @@ -162,30 +158,41 @@ public abstract class AIContextProvider /// /// Initializes a new instance of the class with the specified request messages. /// - /// The messages that were sent to the AI model or agent for this invocation. + /// The caller provided messages that were used by the agent for this invocation. + /// The messages provided by the for this invocation, if any. /// is . - public InvokedContext(IEnumerable requestMessages) + public InvokedContext(IEnumerable requestMessages, IEnumerable? aiContextProviderMessages) { this.RequestMessages = requestMessages ?? throw new ArgumentNullException(nameof(requestMessages)); + this.AIContextProviderMessages = aiContextProviderMessages; } /// - /// Gets the messages that were sent to the AI model or agent for this invocation. + /// Gets the caller provided messages that were used by the agent for this invocation. /// /// - /// A collection of instances representing the conversation history - /// and new messages that were processed by the AI model or agent. + /// A collection of instances representing new messages that were provided by the caller. + /// This does not include any supplied messages. /// public IEnumerable RequestMessages { get; } /// - /// Gets the collection of response messages generated by the AI model or agent if the invocation succeeded. + /// Gets the messages provided by the for this invocation, if any. /// /// - /// A collection of instances representing the response from the AI model or agent, + /// A collection of instances that were provided by the , + /// and were used by the agent as part of the invocation. + /// + public IEnumerable? AIContextProviderMessages { get; } + + /// + /// Gets the collection of response messages generated during this invocation if the invocation succeeded. + /// + /// + /// A collection of instances representing the response, /// or if the invocation failed or did not produce response messages. /// - public IEnumerable? ResponseMessages { get; init; } + public IEnumerable? ResponseMessages { get; set; } /// /// Gets the that was thrown during the invocation, if the invocation failed. @@ -193,6 +200,6 @@ public abstract class AIContextProvider /// /// The exception that caused the invocation to fail, or if the invocation succeeded. /// - public Exception? InvokeException { get; init; } + public Exception? InvokeException { get; set; } } } diff --git a/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0Provider.cs b/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0Provider.cs index 9f5ab29b82..c68b6313d5 100644 --- a/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0Provider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0Provider.cs @@ -161,6 +161,11 @@ public sealed class Mem0Provider : AIContextProvider /// public override async ValueTask InvokedAsync(InvokedContext context, CancellationToken cancellationToken = default) { + if (context.InvokeException is not null) + { + return; // Do not update memory on failed invocations. + } + // Persist request and response messages after invocation. await this.PersistMessagesAsync(context.RequestMessages, cancellationToken).ConfigureAwait(false); diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs index 27a9a9ffd3..5ae6e59904 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs @@ -201,11 +201,9 @@ public sealed partial class ChatClientAgent : AIAgent { var inputMessages = Throw.IfNull(messages) as IReadOnlyCollection ?? messages.ToList(); - (ChatClientAgentThread safeThread, ChatOptions? chatOptions, List threadMessages) = + (ChatClientAgentThread safeThread, ChatOptions? chatOptions, List inputMessagesForChatClient, IList? aiContextProviderMessages) = await this.PrepareThreadAndMessagesAsync(thread, inputMessages, options, cancellationToken).ConfigureAwait(false); - int messageCount = threadMessages.Count; - var chatClient = this.ChatClient; chatClient = ApplyRunOptionsTransformations(options, chatClient); @@ -221,11 +219,11 @@ public sealed partial class ChatClientAgent : AIAgent try { // Using the enumerator to ensure we consider the case where no updates are returned for notification. - responseUpdatesEnumerator = chatClient.GetStreamingResponseAsync(threadMessages, chatOptions, cancellationToken).GetAsyncEnumerator(cancellationToken); + responseUpdatesEnumerator = chatClient.GetStreamingResponseAsync(inputMessagesForChatClient, chatOptions, cancellationToken).GetAsyncEnumerator(cancellationToken); } catch (Exception ex) { - await NotifyAIContextProviderOfFailureAsync(safeThread, ex, inputMessages, cancellationToken).ConfigureAwait(false); + await NotifyAIContextProviderOfFailureAsync(safeThread, ex, inputMessages, aiContextProviderMessages, cancellationToken).ConfigureAwait(false); throw; } @@ -239,7 +237,7 @@ public sealed partial class ChatClientAgent : AIAgent } catch (Exception ex) { - await NotifyAIContextProviderOfFailureAsync(safeThread, ex, inputMessages, cancellationToken).ConfigureAwait(false); + await NotifyAIContextProviderOfFailureAsync(safeThread, ex, inputMessages, aiContextProviderMessages, cancellationToken).ConfigureAwait(false); throw; } @@ -260,7 +258,7 @@ public sealed partial class ChatClientAgent : AIAgent } catch (Exception ex) { - await NotifyAIContextProviderOfFailureAsync(safeThread, ex, inputMessages, cancellationToken).ConfigureAwait(false); + await NotifyAIContextProviderOfFailureAsync(safeThread, ex, inputMessages, aiContextProviderMessages, cancellationToken).ConfigureAwait(false); throw; } } @@ -272,10 +270,10 @@ public sealed partial class ChatClientAgent : AIAgent this.UpdateThreadWithTypeAndConversationId(safeThread, chatResponse.ConversationId); // To avoid inconsistent state we only notify the thread of the input messages if no error occurs after the initial request. - await NotifyThreadOfNewMessagesAsync(safeThread, inputMessages.Concat(chatResponse.Messages), cancellationToken).ConfigureAwait(false); + await NotifyThreadOfNewMessagesAsync(safeThread, inputMessages.Concat(aiContextProviderMessages ?? []).Concat(chatResponse.Messages), cancellationToken).ConfigureAwait(false); // Notify the AIContextProvider of all new messages. - await NotifyAIContextProviderOfSuccessAsync(safeThread, inputMessages, chatResponse.Messages, cancellationToken).ConfigureAwait(false); + await NotifyAIContextProviderOfSuccessAsync(safeThread, inputMessages, aiContextProviderMessages, chatResponse.Messages, cancellationToken).ConfigureAwait(false); } /// @@ -348,7 +346,7 @@ public sealed partial class ChatClientAgent : AIAgent { var inputMessages = Throw.IfNull(messages) as IReadOnlyCollection ?? messages.ToList(); - (ChatClientAgentThread safeThread, ChatOptions? chatOptions, List threadMessages) = + (ChatClientAgentThread safeThread, ChatOptions? chatOptions, List inputMessagesForChatClient, IList? aiContextProviderMessages) = await this.PrepareThreadAndMessagesAsync(thread, inputMessages, options, cancellationToken).ConfigureAwait(false); var chatClient = this.ChatClient; @@ -363,11 +361,11 @@ public sealed partial class ChatClientAgent : AIAgent TChatClientResponse chatResponse; try { - chatResponse = await chatClientRunFunc.Invoke(chatClient, threadMessages, chatOptions, cancellationToken).ConfigureAwait(false); + chatResponse = await chatClientRunFunc.Invoke(chatClient, inputMessagesForChatClient, chatOptions, cancellationToken).ConfigureAwait(false); } catch (Exception ex) { - await NotifyAIContextProviderOfFailureAsync(safeThread, ex, inputMessages, cancellationToken).ConfigureAwait(false); + await NotifyAIContextProviderOfFailureAsync(safeThread, ex, inputMessages, aiContextProviderMessages, cancellationToken).ConfigureAwait(false); throw; } @@ -384,10 +382,10 @@ public sealed partial class ChatClientAgent : AIAgent } // Only notify the thread of new messages if the chatResponse was successful to avoid inconsistent message state in the thread. - await NotifyThreadOfNewMessagesAsync(safeThread, inputMessages.Concat(chatResponse.Messages), cancellationToken).ConfigureAwait(false); + await NotifyThreadOfNewMessagesAsync(safeThread, inputMessages.Concat(aiContextProviderMessages ?? []).Concat(chatResponse.Messages), cancellationToken).ConfigureAwait(false); // Notify the AIContextProvider of all new messages. - await NotifyAIContextProviderOfSuccessAsync(safeThread, inputMessages, chatResponse.Messages, cancellationToken).ConfigureAwait(false); + await NotifyAIContextProviderOfSuccessAsync(safeThread, inputMessages, aiContextProviderMessages, chatResponse.Messages, cancellationToken).ConfigureAwait(false); var agentResponse = agentResponseFactoryFunc(chatResponse); @@ -399,11 +397,16 @@ public sealed partial class ChatClientAgent : AIAgent /// /// Notify the when an agent run succeeded, if there is an . /// - private static async Task NotifyAIContextProviderOfSuccessAsync(ChatClientAgentThread thread, IEnumerable inputMessages, IEnumerable responseMessages, CancellationToken cancellationToken) + private static async Task NotifyAIContextProviderOfSuccessAsync( + ChatClientAgentThread thread, + IEnumerable inputMessages, + IList? aiContextProviderMessages, + IEnumerable responseMessages, + CancellationToken cancellationToken) { if (thread.AIContextProvider is not null) { - await thread.AIContextProvider.InvokedAsync(new(inputMessages) { ResponseMessages = responseMessages }, + await thread.AIContextProvider.InvokedAsync(new(inputMessages, aiContextProviderMessages) { ResponseMessages = responseMessages }, cancellationToken).ConfigureAwait(false); } } @@ -411,11 +414,16 @@ public sealed partial class ChatClientAgent : AIAgent /// /// Notify the of any failure during an agent run, if there is an . /// - private static async Task NotifyAIContextProviderOfFailureAsync(ChatClientAgentThread thread, Exception ex, IEnumerable inputMessages, CancellationToken cancellationToken) + private static async Task NotifyAIContextProviderOfFailureAsync( + ChatClientAgentThread thread, + Exception ex, + IEnumerable inputMessages, + IList? aiContextProviderMessages, + CancellationToken cancellationToken) { if (thread.AIContextProvider is not null) { - await thread.AIContextProvider.InvokedAsync(new(inputMessages) { InvokeException = ex }, + await thread.AIContextProvider.InvokedAsync(new(inputMessages, aiContextProviderMessages) { InvokeException = ex }, cancellationToken).ConfigureAwait(false); } } @@ -556,7 +564,7 @@ public sealed partial class ChatClientAgent : AIAgent /// Optional parameters for agent invocation. /// The to monitor for cancellation requests. The default is . /// A tuple containing the thread, chat options, and thread messages. - private async Task<(ChatClientAgentThread AgentThread, ChatOptions? ChatOptions, List ThreadMessages)> PrepareThreadAndMessagesAsync( + private async Task<(ChatClientAgentThread AgentThread, ChatOptions? ChatOptions, List InputMessagesForChatClient, IList? AIContextProviderMessages)> PrepareThreadAndMessagesAsync( AgentThread? thread, IEnumerable inputMessages, AgentRunOptions? runOptions, @@ -582,7 +590,8 @@ public sealed partial class ChatClientAgent : AIAgent { throw new InvalidOperationException("Input messages are not allowed when continuing a background response using a continuation token."); } - List threadMessages = []; + List inputMessagesForChatClient = []; + IList? aiContextProviderMessages = null; // Populate the thread messages only if we are not continuing an existing response as it's not allowed if (chatOptions?.ContinuationToken is null) @@ -590,7 +599,7 @@ public sealed partial class ChatClientAgent : AIAgent // Add any existing messages from the thread to the messages to be sent to the chat client. if (typedThread.MessageStore is not null) { - threadMessages.AddRange(await typedThread.MessageStore.GetMessagesAsync(cancellationToken).ConfigureAwait(false)); + inputMessagesForChatClient.AddRange(await typedThread.MessageStore.GetMessagesAsync(cancellationToken).ConfigureAwait(false)); } // If we have an AIContextProvider, we should get context from it, and update our @@ -601,7 +610,8 @@ public sealed partial class ChatClientAgent : AIAgent var aiContext = await typedThread.AIContextProvider.InvokingAsync(invokingContext, cancellationToken).ConfigureAwait(false); if (aiContext.Messages is { Count: > 0 }) { - threadMessages.AddRange(aiContext.Messages); + inputMessagesForChatClient.AddRange(aiContext.Messages); + aiContextProviderMessages = aiContext.Messages; } if (aiContext.Tools is { Count: > 0 }) @@ -622,7 +632,7 @@ public sealed partial class ChatClientAgent : AIAgent } // Add the input messages to the end of thread messages. - threadMessages.AddRange(inputMessages); + inputMessagesForChatClient.AddRange(inputMessages); } // If a user provided two different thread ids, via the thread object and options, we should throw @@ -649,7 +659,7 @@ public sealed partial class ChatClientAgent : AIAgent chatOptions.ConversationId = typedThread.ConversationId; } - return (typedThread, chatOptions, threadMessages); + return (typedThread, chatOptions, inputMessagesForChatClient, aiContextProviderMessages); } private void UpdateThreadWithTypeAndConversationId(ChatClientAgentThread thread, string? responseConversationId) diff --git a/dotnet/src/Microsoft.Agents.AI/Data/TextSearchProvider.cs b/dotnet/src/Microsoft.Agents.AI/Data/TextSearchProvider.cs index 7c6e05828f..c7cffd7c04 100644 --- a/dotnet/src/Microsoft.Agents.AI/Data/TextSearchProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Data/TextSearchProvider.cs @@ -176,6 +176,11 @@ public sealed class TextSearchProvider : AIContextProvider return default; // Memory disabled. } + if (context.InvokeException is not null) + { + return default; // Do not update memory on failed invocations. + } + var messagesText = context.RequestMessages .Concat(context.ResponseMessages ?? []) .Where(m => (m.Role == ChatRole.User || m.Role == ChatRole.Assistant) && !string.IsNullOrWhiteSpace(m.Text)) diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AIContextProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AIContextProviderTests.cs index 666f1ec95c..0b8f41f1bb 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AIContextProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AIContextProviderTests.cs @@ -16,7 +16,7 @@ public class AIContextProviderTests { var provider = new TestAIContextProvider(); var messages = new ReadOnlyCollection([]); - var task = provider.InvokedAsync(new(messages)); + var task = provider.InvokedAsync(new(messages, aiContextProviderMessages: null)); Assert.Equal(default, task); } @@ -37,7 +37,7 @@ public class AIContextProviderTests [Fact] public void InvokedContext_Constructor_ThrowsForNullMessages() { - Assert.Throws(() => new AIContextProvider.InvokedContext(null!)); + Assert.Throws(() => new AIContextProvider.InvokedContext(null!, aiContextProviderMessages: null)); } #region GetService Method Tests diff --git a/dotnet/tests/Microsoft.Agents.AI.Mem0.IntegrationTests/Mem0ProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.Mem0.IntegrationTests/Mem0ProviderTests.cs index 0a1c192428..0b9594845a 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Mem0.IntegrationTests/Mem0ProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Mem0.IntegrationTests/Mem0ProviderTests.cs @@ -53,7 +53,7 @@ public sealed class Mem0ProviderTests : IDisposable Assert.DoesNotContain("Caoimhe", ctxBefore.Messages?[0].Text ?? string.Empty); // Act - await sut.InvokedAsync(new AIContextProvider.InvokedContext(new[] { input })); + await sut.InvokedAsync(new AIContextProvider.InvokedContext(new[] { input }, aiContextProviderMessages: null)); var ctxAfterAdding = await GetContextWithRetryAsync(sut, question); await sut.ClearStoredMemoriesAsync(); var ctxAfterClearing = await sut.InvokingAsync(new AIContextProvider.InvokingContext(new[] { question })); @@ -77,7 +77,7 @@ public sealed class Mem0ProviderTests : IDisposable Assert.DoesNotContain("Caoimhe", ctxBefore.Messages?[0].Text ?? string.Empty); // Act - await sut.InvokedAsync(new AIContextProvider.InvokedContext(new[] { assistantIntro })); + await sut.InvokedAsync(new AIContextProvider.InvokedContext(new[] { assistantIntro }, aiContextProviderMessages: null)); var ctxAfterAdding = await GetContextWithRetryAsync(sut, question); await sut.ClearStoredMemoriesAsync(); var ctxAfterClearing = await sut.InvokingAsync(new AIContextProvider.InvokingContext(new[] { question })); @@ -105,7 +105,7 @@ public sealed class Mem0ProviderTests : IDisposable Assert.DoesNotContain("Caoimhe", ctxBefore2.Messages?[0].Text ?? string.Empty); // Act - await sut1.InvokedAsync(new AIContextProvider.InvokedContext(new[] { assistantIntro })); + await sut1.InvokedAsync(new AIContextProvider.InvokedContext(new[] { assistantIntro }, aiContextProviderMessages: null)); var ctxAfterAdding1 = await GetContextWithRetryAsync(sut1, question); var ctxAfterAdding2 = await GetContextWithRetryAsync(sut2, question); diff --git a/dotnet/tests/Microsoft.Agents.AI.Mem0.UnitTests/Mem0ProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.Mem0.UnitTests/Mem0ProviderTests.cs index b6968e407c..985150671d 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Mem0.UnitTests/Mem0ProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Mem0.UnitTests/Mem0ProviderTests.cs @@ -123,8 +123,7 @@ public sealed class Mem0ProviderTests : IDisposable }; // Act - await sut.InvokedAsync(new AIContextProvider.InvokedContext(requestMessages)); // persists request messages - await sut.InvokedAsync(new AIContextProvider.InvokedContext(responseMessages)); // persists assistant + await sut.InvokedAsync(new AIContextProvider.InvokedContext(requestMessages, aiContextProviderMessages: null) { ResponseMessages = responseMessages }); // Assert var memoryPosts = this._handler.Requests.Where(r => r.RequestMessage.RequestUri!.AbsolutePath == "/v1/memories/" && r.RequestMessage.Method == HttpMethod.Post).ToList(); @@ -136,6 +135,27 @@ public sealed class Mem0ProviderTests : IDisposable Assert.DoesNotContain(memoryPosts, r => ContainsOrdinal(r.RequestBody, "Tool text")); } + [Fact] + public async Task InvokedAsync_PersistsNothingForFailedRequestAsync() + { + // Arrange + var options = new Mem0ProviderOptions { ApplicationId = "a", AgentId = "b", ThreadId = "c", UserId = "d" }; + var sut = new Mem0Provider(this._httpClient, options); + + var requestMessages = new List + { + new(ChatRole.User, "User text"), + new(ChatRole.System, "System text"), + new(ChatRole.Tool, "Tool text should be ignored") + }; + + // Act + await sut.InvokedAsync(new AIContextProvider.InvokedContext(requestMessages, aiContextProviderMessages: null) { ResponseMessages = null, InvokeException = new InvalidOperationException("Request Failed") }); + + // Assert + Assert.Empty(this._handler.Requests); + } + [Fact] public async Task ClearStoredMemoriesAsync_SendsDeleteWithQueryAsync() { diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs index 9debdd3ec0..7c90e1a13e 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs @@ -220,10 +220,10 @@ public partial class ChatClientAgentTests } /// - /// Verify that RunAsync works with existing thread and retrieves messages from IMessagesRetrievableThread. + /// Verify that RunAsync works with existing thread and can retreive messages if the thread has a MessageStore. /// [Fact] - public async Task RunAsyncRetrievesMessagesFromThreadWhenThreadImplementsIMessagesRetrievableThreadAsync() + public async Task RunAsyncRetrievesMessagesFromThreadWhenThreadStoresMessagesThreadAsync() { // Arrange Mock mockService = new(); @@ -449,7 +449,10 @@ public partial class ChatClientAgentTests await agent.RunAsync([new(ChatRole.User, "test")], thread); // Assert - Assert.IsType(thread!.MessageStore); + var messageStore = Assert.IsType(thread!.MessageStore); + Assert.Equal(2, messageStore.Count); + Assert.Equal("test", messageStore[0].Text); + Assert.Equal("response", messageStore[1].Text); mockFactory.Verify(f => f(It.IsAny()), Times.Once); } @@ -492,6 +495,7 @@ public partial class ChatClientAgentTests // Arrange ChatMessage[] requestMessages = [new(ChatRole.User, "user message")]; ChatMessage[] responseMessages = [new(ChatRole.Assistant, "response")]; + ChatMessage[] aiContextProviderMessages = [new(ChatRole.System, "context provider message")]; Mock mockService = new(); List capturedMessages = []; string capturedInstructions = string.Empty; @@ -517,7 +521,7 @@ public partial class ChatClientAgentTests .Setup(p => p.InvokingAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new AIContext { - Messages = [new(ChatRole.System, "context provider message")], + Messages = aiContextProviderMessages, Instructions = "context provider instructions", Tools = [AIFunctionFactory.Create(() => { }, "context provider function")] }); @@ -528,7 +532,8 @@ public partial class ChatClientAgentTests ChatClientAgent agent = new(mockService.Object, options: new() { Instructions = "base instructions", AIContextProviderFactory = _ => mockProvider.Object, ChatOptions = new() { Tools = [AIFunctionFactory.Create(() => { }, "base function")] } }); // Act - await agent.RunAsync(requestMessages); + var thread = agent.GetNewThread() as ChatClientAgentThread; + await agent.RunAsync(requestMessages, thread); // Assert // Should contain: base instructions, context message, user message, base function, context function @@ -541,8 +546,20 @@ public partial class ChatClientAgentTests Assert.Equal(2, capturedTools.Count); Assert.Contains(capturedTools, t => t.Name == "base function"); Assert.Contains(capturedTools, t => t.Name == "context provider function"); + + // Verify that the thread was updated with the input, ai context and response messages + var messageStore = Assert.IsType(thread!.MessageStore); + Assert.Equal(3, messageStore.Count); + Assert.Equal("user message", messageStore[0].Text); + Assert.Equal("context provider message", messageStore[1].Text); + Assert.Equal("response", messageStore[2].Text); + mockProvider.Verify(p => p.InvokingAsync(It.IsAny(), It.IsAny()), Times.Once); - mockProvider.Verify(p => p.InvokedAsync(It.Is(x => x.RequestMessages == requestMessages && x.ResponseMessages == responseMessages && x.InvokeException == null), It.IsAny()), Times.Once); + mockProvider.Verify(p => p.InvokedAsync(It.Is(x => + x.RequestMessages == requestMessages && + x.AIContextProviderMessages == aiContextProviderMessages && + x.ResponseMessages == responseMessages && + x.InvokeException == null), It.IsAny()), Times.Once); } /// @@ -554,6 +571,7 @@ public partial class ChatClientAgentTests // Arrange ChatMessage[] requestMessages = [new(ChatRole.User, "user message")]; ChatMessage[] responseMessages = [new(ChatRole.Assistant, "response")]; + ChatMessage[] aiContextProviderMessages = [new(ChatRole.System, "context provider message")]; Mock mockService = new(); mockService .Setup(s => s.GetResponseAsync( @@ -565,7 +583,10 @@ public partial class ChatClientAgentTests var mockProvider = new Mock(); mockProvider .Setup(p => p.InvokingAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(new AIContext()); + .ReturnsAsync(new AIContext + { + Messages = aiContextProviderMessages, + }); mockProvider .Setup(p => p.InvokedAsync(It.IsAny(), It.IsAny())) .Returns(new ValueTask()); @@ -577,7 +598,11 @@ public partial class ChatClientAgentTests // Assert mockProvider.Verify(p => p.InvokingAsync(It.IsAny(), It.IsAny()), Times.Once); - mockProvider.Verify(p => p.InvokedAsync(It.Is(x => x.RequestMessages == requestMessages && x.ResponseMessages == null && x.InvokeException is InvalidOperationException), It.IsAny()), Times.Once); + mockProvider.Verify(p => p.InvokedAsync(It.Is(x => + x.RequestMessages == requestMessages && + x.AIContextProviderMessages == aiContextProviderMessages && + x.ResponseMessages == null && + x.InvokeException is InvalidOperationException), It.IsAny()), Times.Once); } /// @@ -1879,7 +1904,10 @@ public partial class ChatClientAgentTests await agent.RunStreamingAsync([new(ChatRole.User, "test")], thread).ToListAsync(); // Assert - Assert.IsType(thread!.MessageStore); + var messageStore = Assert.IsType(thread!.MessageStore); + Assert.Equal(2, messageStore.Count); + Assert.Equal("test", messageStore[0].Text); + Assert.Equal("what?", messageStore[1].Text); mockFactory.Verify(f => f(It.IsAny()), Times.Once); } @@ -1918,6 +1946,130 @@ public partial class ChatClientAgentTests mockFactory.Verify(f => f(It.IsAny()), Times.Never); } + /// + /// Verify that RunStreamingAsync invokes any provided AIContextProvider and uses the result. + /// + [Fact] + public async Task RunStreamingAsyncInvokesAIContextProviderAndUsesResultAsync() + { + // Arrange + ChatMessage[] requestMessages = [new(ChatRole.User, "user message")]; + ChatResponseUpdate[] responseUpdates = [new(ChatRole.Assistant, "response")]; + ChatMessage[] aiContextProviderMessages = [new(ChatRole.System, "context provider message")]; + Mock mockService = new(); + List capturedMessages = []; + string capturedInstructions = string.Empty; + List capturedTools = []; + mockService + .Setup(s => s.GetStreamingResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Callback, ChatOptions, CancellationToken>((msgs, opts, ct) => + { + capturedMessages.AddRange(msgs); + capturedInstructions = opts.Instructions ?? string.Empty; + if (opts.Tools is not null) + { + capturedTools.AddRange(opts.Tools); + } + }) + .Returns(ToAsyncEnumerableAsync(responseUpdates)); + + var mockProvider = new Mock(); + mockProvider + .Setup(p => p.InvokingAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new AIContext + { + Messages = aiContextProviderMessages, + Instructions = "context provider instructions", + Tools = [AIFunctionFactory.Create(() => { }, "context provider function")] + }); + mockProvider + .Setup(p => p.InvokedAsync(It.IsAny(), It.IsAny())) + .Returns(new ValueTask()); + + ChatClientAgent agent = new(mockService.Object, options: new() { Instructions = "base instructions", AIContextProviderFactory = _ => mockProvider.Object, ChatOptions = new() { Tools = [AIFunctionFactory.Create(() => { }, "base function")] } }); + + // Act + var thread = agent.GetNewThread() as ChatClientAgentThread; + var updates = agent.RunStreamingAsync(requestMessages, thread); + _ = await updates.ToAgentRunResponseAsync(); + + // Assert + // Should contain: base instructions, context message, user message, base function, context function + Assert.Equal(2, capturedMessages.Count); + Assert.Equal("base instructions\ncontext provider instructions", capturedInstructions); + Assert.Equal("context provider message", capturedMessages[0].Text); + Assert.Equal(ChatRole.System, capturedMessages[0].Role); + Assert.Equal("user message", capturedMessages[1].Text); + Assert.Equal(ChatRole.User, capturedMessages[1].Role); + Assert.Equal(2, capturedTools.Count); + Assert.Contains(capturedTools, t => t.Name == "base function"); + Assert.Contains(capturedTools, t => t.Name == "context provider function"); + + // Verify that the thread was updated with the input, ai context and response messages + var messageStore = Assert.IsType(thread!.MessageStore); + Assert.Equal(3, messageStore.Count); + Assert.Equal("user message", messageStore[0].Text); + Assert.Equal("context provider message", messageStore[1].Text); + Assert.Equal("response", messageStore[2].Text); + + mockProvider.Verify(p => p.InvokingAsync(It.IsAny(), It.IsAny()), Times.Once); + mockProvider.Verify(p => p.InvokedAsync(It.Is(x => + x.RequestMessages == requestMessages && + x.AIContextProviderMessages == aiContextProviderMessages && + x.ResponseMessages!.Count() == 1 && + x.ResponseMessages!.ElementAt(0).Text == "response" && + x.InvokeException == null), It.IsAny()), Times.Once); + } + + /// + /// Verify that RunStreamingAsync invokes any provided AIContextProvider when the downstream GetStreamingResponse call fails. + /// + [Fact] + public async Task RunStreamingAsyncInvokesAIContextProviderWhenGetResponseFailsAsync() + { + // Arrange + ChatMessage[] requestMessages = [new(ChatRole.User, "user message")]; + ChatMessage[] aiContextProviderMessages = [new(ChatRole.System, "context provider message")]; + Mock mockService = new(); + mockService + .Setup(s => s.GetStreamingResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Throws(new InvalidOperationException("downstream failure")); + + var mockProvider = new Mock(); + mockProvider + .Setup(p => p.InvokingAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new AIContext + { + Messages = aiContextProviderMessages, + }); + mockProvider + .Setup(p => p.InvokedAsync(It.IsAny(), It.IsAny())) + .Returns(new ValueTask()); + + ChatClientAgent agent = new(mockService.Object, options: new() { Instructions = "base instructions", AIContextProviderFactory = _ => mockProvider.Object, ChatOptions = new() { Tools = [AIFunctionFactory.Create(() => { }, "base function")] } }); + + // Act + await Assert.ThrowsAsync(async () => + { + var updates = agent.RunStreamingAsync(requestMessages); + await updates.ToAgentRunResponseAsync(); + }); + + // Assert + mockProvider.Verify(p => p.InvokingAsync(It.IsAny(), It.IsAny()), Times.Once); + mockProvider.Verify(p => p.InvokedAsync(It.Is(x => + x.RequestMessages == requestMessages && + x.AIContextProviderMessages == aiContextProviderMessages && + x.ResponseMessages == null && + x.InvokeException is InvalidOperationException), It.IsAny()), Times.Once); + } + #endregion #region GetNewThread Tests diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Data/TextSearchProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Data/TextSearchProviderTests.cs index 8b019ab8c6..44423695eb 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Data/TextSearchProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Data/TextSearchProviderTests.cs @@ -289,6 +289,45 @@ public sealed class TextSearchProviderTests #region Recent Message Memory Tests + [Fact] + public async Task InvokingAsync_WithPreviousFailedRequest_ShouldNotIncludeFailedRequestInputInSearchInputAsync() + { + // Arrange + var options = new TextSearchProviderOptions + { + SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke, + RecentMessageMemoryLimit = 3 + }; + string? capturedInput = null; + Task> SearchDelegateAsync(string input, CancellationToken ct) + { + capturedInput = input; + return Task.FromResult>([]); // No results needed. + } + var provider = new TextSearchProvider(SearchDelegateAsync, options); + + // Populate memory with more messages than the limit (A,B,C,D) -> should retain B,C,D + var initialMessages = new[] + { + new ChatMessage(ChatRole.User, "A"), + new ChatMessage(ChatRole.Assistant, "B"), + new ChatMessage(ChatRole.User, "C"), + new ChatMessage(ChatRole.Assistant, "D"), + }; + await provider.InvokedAsync(new(initialMessages, aiContextProviderMessages: null) { InvokeException = new InvalidOperationException("Request Failed") }); + + var invokingContext = new AIContextProvider.InvokingContext(new[] + { + new ChatMessage(ChatRole.User, "E") + }); + + // Act + await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert + Assert.Equal("E", capturedInput); // Only the messages from the current request, since previous failed request should not be stored. + } + [Fact] public async Task InvokingAsync_WithRecentMessageMemory_ShouldIncludeStoredMessagesInSearchInputAsync() { @@ -314,7 +353,7 @@ public sealed class TextSearchProviderTests new ChatMessage(ChatRole.User, "C"), new ChatMessage(ChatRole.Assistant, "D"), }; - await provider.InvokedAsync(new(initialMessages)); + await provider.InvokedAsync(new(initialMessages, aiContextProviderMessages: null)); var invokingContext = new AIContextProvider.InvokingContext(new[] { @@ -350,7 +389,7 @@ public sealed class TextSearchProviderTests { new ChatMessage(ChatRole.User, "A"), new ChatMessage(ChatRole.Assistant, "B"), - })); + }, aiContextProviderMessages: null)); // Second memory update (C,D,E) await provider.InvokedAsync(new(new[] @@ -358,7 +397,7 @@ public sealed class TextSearchProviderTests new ChatMessage(ChatRole.User, "C"), new ChatMessage(ChatRole.Assistant, "D"), new ChatMessage(ChatRole.User, "E"), - })); + }, aiContextProviderMessages: null)); var invokingContext = new AIContextProvider.InvokingContext(new[] { new ChatMessage(ChatRole.User, "F") }); @@ -410,7 +449,7 @@ public sealed class TextSearchProviderTests }; // Act - await provider.InvokedAsync(new(messages)); // Populate recent memory. + await provider.InvokedAsync(new(messages, aiContextProviderMessages: null)); // Populate recent memory. var state = provider.Serialize(); // Assert @@ -438,7 +477,7 @@ public sealed class TextSearchProviderTests new ChatMessage(ChatRole.User, "C"), new ChatMessage(ChatRole.Assistant, "D"), }; - await provider.InvokedAsync(new(messages)); + await provider.InvokedAsync(new(messages, aiContextProviderMessages: null)); // Act var state = provider.Serialize(); @@ -478,7 +517,7 @@ public sealed class TextSearchProviderTests new ChatMessage(ChatRole.Assistant, "L4"), new ChatMessage(ChatRole.User, "L5"), }; - await initialProvider.InvokedAsync(new(messages)); + await initialProvider.InvokedAsync(new(messages, aiContextProviderMessages: null)); var state = initialProvider.Serialize(); string? capturedInput = null;