From 8b4f7d5e298868ee945900eb7a3bd887c89afff9 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 30 Dec 2025 12:24:09 +0000 Subject: [PATCH 01/13] .NET: [Breaking] Introduce RunCoreAsync/RunCoreStreamingAsync delegation pattern in AIAgent (#2749) * Initial plan * Refactor AIAgent: Make RunAsync and RunStreamingAsync non-abstract, add RunCoreAsync and RunCoreStreamingAsync Co-authored-by: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> * Fix infinite recursion in test implementations Co-authored-by: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> * Make RunAsync and RunStreamingAsync non-virtual as requested Co-authored-by: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> * Fix DelegatingAIAgent subclasses to use RunCoreAsync/RunCoreStreamingAsync Co-authored-by: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> * Fix XML documentation references in AnonymousDelegatingAIAgent Co-authored-by: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> * Restore tags with proper qualified signatures in AnonymousDelegatingAIAgent Co-authored-by: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> * Rollback unnecessary XML documentation changes in AnonymousDelegatingAIAgent Co-authored-by: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> * Remove pragma and update crefs to RunCoreAsync/RunCoreStreamingAsync Co-authored-by: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> * Fix EntityAgentWrapper to call base.RunCoreAsync/RunCoreStreamingAsync Co-authored-by: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> * fix compilation issues * fix compilatio issue * fix tests * fix unit tests * fix unit test --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Co-authored-by: SergeyMenshykh Co-authored-by: Chris <66376200+crickman@users.noreply.github.com> --- .../AgenticUI/AgenticUIAgent.cs | 6 +- .../PredictiveStateUpdatesAgent.cs | 6 +- .../SharedState/SharedStateAgent.cs | 6 +- .../ServerFunctionApprovalClientAgent.cs | 6 +- .../ServerFunctionApprovalServerAgent.cs | 6 +- .../Client/StatefulAgent.cs | 6 +- .../Server/SharedStateAgent.cs | 6 +- .../Program.cs | 4 +- .../OpenAIChatClientAgent.cs | 8 +- .../OpenAIResponseClientAgent.cs | 8 +- .../M365Agent/Agents/WeatherForecastAgent.cs | 4 +- .../src/Microsoft.Agents.AI.A2A/A2AAgent.cs | 4 +- .../AIAgent.cs | 61 ++++++++- .../DelegatingAIAgent.cs | 4 +- .../CopilotStudioAgent.cs | 4 +- .../DurableAIAgent.cs | 4 +- .../DurableAIAgentProxy.cs | 4 +- .../EntityAgentWrapper.cs | 8 +- .../PurviewAgent.cs | 4 +- .../WorkflowHostAgent.cs | 8 +- .../AnonymousDelegatingAIAgent.cs | 24 ++-- .../ChatClient/ChatClientAgent.cs | 4 +- .../FunctionInvocationDelegatingAgent.cs | 4 +- .../src/Microsoft.Agents.AI/LoggingAgent.cs | 8 +- .../Microsoft.Agents.AI/OpenTelemetryAgent.cs | 4 +- .../AIAgentTests.cs | 117 ++++++++++-------- .../DelegatingAIAgentTests.cs | 36 ++++-- .../AggregatorPromptAgentFactoryTests.cs | 4 +- .../BasicStreamingTests.cs | 8 +- .../ForwardedPropertiesTests.cs | 6 +- .../SharedStateTests.cs | 6 +- ...AGUIEndpointRouteBuilderExtensionsTests.cs | 8 +- .../TestAgent.cs | 4 +- .../AIAgentWithOpenAIExtensionsTests.cs | 37 ++++-- .../PurviewWrapperTests.cs | 79 ++++++------ .../AgentExtensionsTests.cs | 4 +- .../AnonymousDelegatingAIAgentTests.cs | 75 ++++++----- .../TestAIAgent.cs | 4 +- .../AgentWorkflowBuilderTests.cs | 8 +- .../InProcessExecutionTests.cs | 4 +- .../RepresentationTests.cs | 4 +- .../Sample/06_GroupChat_Workflow.cs | 6 +- .../SpecializedExecutorSmokeTests.cs | 4 +- .../TestEchoAgent.cs | 4 +- 44 files changed, 372 insertions(+), 257 deletions(-) diff --git a/dotnet/samples/AGUIClientServer/AGUIDojoServer/AgenticUI/AgenticUIAgent.cs b/dotnet/samples/AGUIClientServer/AGUIDojoServer/AgenticUI/AgenticUIAgent.cs index 05a7d86f15..d79787d260 100644 --- a/dotnet/samples/AGUIClientServer/AGUIDojoServer/AgenticUI/AgenticUIAgent.cs +++ b/dotnet/samples/AGUIClientServer/AGUIDojoServer/AgenticUI/AgenticUIAgent.cs @@ -19,12 +19,12 @@ public AgenticUIAgent(AIAgent innerAgent, JsonSerializerOptions jsonSerializerOp this._jsonSerializerOptions = jsonSerializerOptions; } - public override Task RunAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) + protected override Task RunCoreAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) { - return this.RunStreamingAsync(messages, thread, options, cancellationToken).ToAgentRunResponseAsync(cancellationToken); + return this.RunCoreStreamingAsync(messages, thread, options, cancellationToken).ToAgentRunResponseAsync(cancellationToken); } - public override async IAsyncEnumerable RunStreamingAsync( + protected override async IAsyncEnumerable RunCoreStreamingAsync( IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, diff --git a/dotnet/samples/AGUIClientServer/AGUIDojoServer/PredictiveStateUpdates/PredictiveStateUpdatesAgent.cs b/dotnet/samples/AGUIClientServer/AGUIDojoServer/PredictiveStateUpdates/PredictiveStateUpdatesAgent.cs index 8ac9928fbe..ab9ca2fca3 100644 --- a/dotnet/samples/AGUIClientServer/AGUIDojoServer/PredictiveStateUpdates/PredictiveStateUpdatesAgent.cs +++ b/dotnet/samples/AGUIClientServer/AGUIDojoServer/PredictiveStateUpdates/PredictiveStateUpdatesAgent.cs @@ -20,12 +20,12 @@ public PredictiveStateUpdatesAgent(AIAgent innerAgent, JsonSerializerOptions jso this._jsonSerializerOptions = jsonSerializerOptions; } - public override Task RunAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) + protected override Task RunCoreAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) { - return this.RunStreamingAsync(messages, thread, options, cancellationToken).ToAgentRunResponseAsync(cancellationToken); + return this.RunCoreStreamingAsync(messages, thread, options, cancellationToken).ToAgentRunResponseAsync(cancellationToken); } - public override async IAsyncEnumerable RunStreamingAsync( + protected override async IAsyncEnumerable RunCoreStreamingAsync( IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, diff --git a/dotnet/samples/AGUIClientServer/AGUIDojoServer/SharedState/SharedStateAgent.cs b/dotnet/samples/AGUIClientServer/AGUIDojoServer/SharedState/SharedStateAgent.cs index c10450fcfb..1a1e58860a 100644 --- a/dotnet/samples/AGUIClientServer/AGUIDojoServer/SharedState/SharedStateAgent.cs +++ b/dotnet/samples/AGUIClientServer/AGUIDojoServer/SharedState/SharedStateAgent.cs @@ -19,12 +19,12 @@ public SharedStateAgent(AIAgent innerAgent, JsonSerializerOptions jsonSerializer this._jsonSerializerOptions = jsonSerializerOptions; } - public override Task RunAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) + protected override Task RunCoreAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) { - return this.RunStreamingAsync(messages, thread, options, cancellationToken).ToAgentRunResponseAsync(cancellationToken); + return this.RunCoreStreamingAsync(messages, thread, options, cancellationToken).ToAgentRunResponseAsync(cancellationToken); } - public override async IAsyncEnumerable RunStreamingAsync( + protected override async IAsyncEnumerable RunCoreStreamingAsync( IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, diff --git a/dotnet/samples/GettingStarted/AGUI/Step04_HumanInLoop/Client/ServerFunctionApprovalClientAgent.cs b/dotnet/samples/GettingStarted/AGUI/Step04_HumanInLoop/Client/ServerFunctionApprovalClientAgent.cs index 41538085db..9f7812cc50 100644 --- a/dotnet/samples/GettingStarted/AGUI/Step04_HumanInLoop/Client/ServerFunctionApprovalClientAgent.cs +++ b/dotnet/samples/GettingStarted/AGUI/Step04_HumanInLoop/Client/ServerFunctionApprovalClientAgent.cs @@ -22,17 +22,17 @@ public ServerFunctionApprovalClientAgent(AIAgent innerAgent, JsonSerializerOptio this._jsonSerializerOptions = jsonSerializerOptions; } - public override Task RunAsync( + protected override Task RunCoreAsync( IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) { - return this.RunStreamingAsync(messages, thread, options, cancellationToken) + return this.RunCoreStreamingAsync(messages, thread, options, cancellationToken) .ToAgentRunResponseAsync(cancellationToken); } - public override async IAsyncEnumerable RunStreamingAsync( + protected override async IAsyncEnumerable RunCoreStreamingAsync( IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, diff --git a/dotnet/samples/GettingStarted/AGUI/Step04_HumanInLoop/Server/ServerFunctionApprovalServerAgent.cs b/dotnet/samples/GettingStarted/AGUI/Step04_HumanInLoop/Server/ServerFunctionApprovalServerAgent.cs index f515e97531..69e3db58c7 100644 --- a/dotnet/samples/GettingStarted/AGUI/Step04_HumanInLoop/Server/ServerFunctionApprovalServerAgent.cs +++ b/dotnet/samples/GettingStarted/AGUI/Step04_HumanInLoop/Server/ServerFunctionApprovalServerAgent.cs @@ -22,17 +22,17 @@ public ServerFunctionApprovalAgent(AIAgent innerAgent, JsonSerializerOptions jso this._jsonSerializerOptions = jsonSerializerOptions; } - public override Task RunAsync( + protected override Task RunCoreAsync( IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) { - return this.RunStreamingAsync(messages, thread, options, cancellationToken) + return this.RunCoreStreamingAsync(messages, thread, options, cancellationToken) .ToAgentRunResponseAsync(cancellationToken); } - public override async IAsyncEnumerable RunStreamingAsync( + protected override async IAsyncEnumerable RunCoreStreamingAsync( IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, diff --git a/dotnet/samples/GettingStarted/AGUI/Step05_StateManagement/Client/StatefulAgent.cs b/dotnet/samples/GettingStarted/AGUI/Step05_StateManagement/Client/StatefulAgent.cs index 8321efaa73..d5fd9f187b 100644 --- a/dotnet/samples/GettingStarted/AGUI/Step05_StateManagement/Client/StatefulAgent.cs +++ b/dotnet/samples/GettingStarted/AGUI/Step05_StateManagement/Client/StatefulAgent.cs @@ -35,18 +35,18 @@ public StatefulAgent(AIAgent innerAgent, JsonSerializerOptions jsonSerializerOpt } /// - public override Task RunAsync( + protected override Task RunCoreAsync( IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) { - return this.RunStreamingAsync(messages, thread, options, cancellationToken) + return this.RunCoreStreamingAsync(messages, thread, options, cancellationToken) .ToAgentRunResponseAsync(cancellationToken); } /// - public override async IAsyncEnumerable RunStreamingAsync( + protected override async IAsyncEnumerable RunCoreStreamingAsync( IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, diff --git a/dotnet/samples/GettingStarted/AGUI/Step05_StateManagement/Server/SharedStateAgent.cs b/dotnet/samples/GettingStarted/AGUI/Step05_StateManagement/Server/SharedStateAgent.cs index 4588c7bd60..603698b579 100644 --- a/dotnet/samples/GettingStarted/AGUI/Step05_StateManagement/Server/SharedStateAgent.cs +++ b/dotnet/samples/GettingStarted/AGUI/Step05_StateManagement/Server/SharedStateAgent.cs @@ -17,17 +17,17 @@ public SharedStateAgent(AIAgent innerAgent, JsonSerializerOptions jsonSerializer this._jsonSerializerOptions = jsonSerializerOptions; } - public override Task RunAsync( + protected override Task RunCoreAsync( IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) { - return this.RunStreamingAsync(messages, thread, options, cancellationToken) + return this.RunCoreStreamingAsync(messages, thread, options, cancellationToken) .ToAgentRunResponseAsync(cancellationToken); } - public override async IAsyncEnumerable RunStreamingAsync( + protected override async IAsyncEnumerable RunCoreStreamingAsync( IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, diff --git a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_CustomImplementation/Program.cs b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_CustomImplementation/Program.cs index a7dafbfc50..a4e588f347 100644 --- a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_CustomImplementation/Program.cs +++ b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_CustomImplementation/Program.cs @@ -34,7 +34,7 @@ public override AgentThread GetNewThread() public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null) => new CustomAgentThread(serializedThread, jsonSerializerOptions); - public override async Task RunAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) + protected override async Task RunCoreAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) { // Create a thread if the user didn't supply one. thread ??= this.GetNewThread(); @@ -58,7 +58,7 @@ public override async Task RunAsync(IEnumerable m }; } - public override async IAsyncEnumerable RunStreamingAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + protected override async IAsyncEnumerable RunCoreStreamingAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { // Create a thread if the user didn't supply one. thread ??= this.GetNewThread(); diff --git a/dotnet/samples/GettingStarted/AgentWithOpenAI/Agent_OpenAI_Step03_CreateFromChatClient/OpenAIChatClientAgent.cs b/dotnet/samples/GettingStarted/AgentWithOpenAI/Agent_OpenAI_Step03_CreateFromChatClient/OpenAIChatClientAgent.cs index b295bfecea..a0b59d1053 100644 --- a/dotnet/samples/GettingStarted/AgentWithOpenAI/Agent_OpenAI_Step03_CreateFromChatClient/OpenAIChatClientAgent.cs +++ b/dotnet/samples/GettingStarted/AgentWithOpenAI/Agent_OpenAI_Step03_CreateFromChatClient/OpenAIChatClientAgent.cs @@ -87,10 +87,10 @@ public virtual IAsyncEnumerable RunStreamingAsync } /// - public sealed override Task RunAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) => - base.RunAsync(messages, thread, options, cancellationToken); + protected sealed override Task RunCoreAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) => + base.RunCoreAsync(messages, thread, options, cancellationToken); /// - public override IAsyncEnumerable RunStreamingAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) => - base.RunStreamingAsync(messages, thread, options, cancellationToken); + protected override IAsyncEnumerable RunCoreStreamingAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) => + base.RunCoreStreamingAsync(messages, thread, options, cancellationToken); } diff --git a/dotnet/samples/GettingStarted/AgentWithOpenAI/Agent_OpenAI_Step04_CreateFromOpenAIResponseClient/OpenAIResponseClientAgent.cs b/dotnet/samples/GettingStarted/AgentWithOpenAI/Agent_OpenAI_Step04_CreateFromOpenAIResponseClient/OpenAIResponseClientAgent.cs index 622223307c..f894a5434c 100644 --- a/dotnet/samples/GettingStarted/AgentWithOpenAI/Agent_OpenAI_Step04_CreateFromOpenAIResponseClient/OpenAIResponseClientAgent.cs +++ b/dotnet/samples/GettingStarted/AgentWithOpenAI/Agent_OpenAI_Step04_CreateFromOpenAIResponseClient/OpenAIResponseClientAgent.cs @@ -105,10 +105,10 @@ public virtual async IAsyncEnumerable RunStreamingAsync } /// - public sealed override Task RunAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) => - base.RunAsync(messages, thread, options, cancellationToken); + protected sealed override Task RunCoreAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) => + base.RunCoreAsync(messages, thread, options, cancellationToken); /// - public sealed override IAsyncEnumerable RunStreamingAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) => - base.RunStreamingAsync(messages, thread, options, cancellationToken); + protected sealed override IAsyncEnumerable RunCoreStreamingAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) => + base.RunCoreStreamingAsync(messages, thread, options, cancellationToken); } diff --git a/dotnet/samples/M365Agent/Agents/WeatherForecastAgent.cs b/dotnet/samples/M365Agent/Agents/WeatherForecastAgent.cs index 740b959a7a..ff7af20ba9 100644 --- a/dotnet/samples/M365Agent/Agents/WeatherForecastAgent.cs +++ b/dotnet/samples/M365Agent/Agents/WeatherForecastAgent.cs @@ -48,9 +48,9 @@ public WeatherForecastAgent(IChatClient chatClient) { } - public override async Task RunAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) + protected override async Task RunCoreAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) { - var response = await base.RunAsync(messages, thread, options, cancellationToken); + var response = await base.RunCoreAsync(messages, thread, options, cancellationToken); // If the agent returned a valid structured output response // we might be able to enhance the response with an adaptive card. diff --git a/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs b/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs index e326151b13..cf88a89177 100644 --- a/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs @@ -68,7 +68,7 @@ public override AgentThread DeserializeThread(JsonElement serializedThread, Json => new A2AAgentThread(serializedThread, jsonSerializerOptions); /// - public override async Task RunAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) + protected override async Task RunCoreAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) { _ = Throw.IfNull(messages); @@ -131,7 +131,7 @@ public override async Task RunAsync(IEnumerable m } /// - public override async IAsyncEnumerable RunStreamingAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + protected override async IAsyncEnumerable RunCoreStreamingAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { _ = Throw.IfNull(messages); diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgent.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgent.cs index 1f39a2758f..afed5d1518 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgent.cs @@ -218,6 +218,35 @@ public Task RunAsync( /// A task that represents the asynchronous operation. The task result contains an with the agent's output. /// /// + /// This method delegates to to perform the actual agent invocation. It handles collections of messages, + /// allowing for complex conversational scenarios including multi-turn interactions, function calls, and + /// context-rich conversations. + /// + /// + /// The messages are processed in the order provided and become part of the conversation history. + /// The agent's response will also be added to if one is provided. + /// + /// + public Task RunAsync( + IEnumerable messages, + AgentThread? thread = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) => + this.RunCoreAsync(messages, thread, options, cancellationToken); + + /// + /// Core implementation of the agent invocation logic with a collection of chat messages. + /// + /// The collection of messages to send to the agent for processing. + /// + /// The conversation thread to use for this invocation. If , a new thread will be created. + /// The thread will be updated with the input messages and any response messages generated during invocation. + /// + /// Optional configuration parameters for controlling the agent's invocation behavior. + /// The to monitor for cancellation requests. The default is . + /// A task that represents the asynchronous operation. The task result contains an with the agent's output. + /// + /// /// This is the primary invocation method that implementations must override. It handles collections of messages, /// allowing for complex conversational scenarios including multi-turn interactions, function calls, and /// context-rich conversations. @@ -227,7 +256,7 @@ public Task RunAsync( /// The agent's response will also be added to if one is provided. /// /// - public abstract Task RunAsync( + protected abstract Task RunCoreAsync( IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, @@ -312,6 +341,34 @@ public IAsyncEnumerable RunStreamingAsync( /// An asynchronous enumerable of instances representing the streaming response. /// /// + /// This method delegates to to perform the actual streaming invocation. It provides real-time + /// updates as the agent processes the input and generates its response, enabling more responsive user experiences. + /// + /// + /// Each represents a portion of the complete response, allowing consumers + /// to display partial results, implement progressive loading, or provide immediate feedback to users. + /// + /// + public IAsyncEnumerable RunStreamingAsync( + IEnumerable messages, + AgentThread? thread = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) => + this.RunCoreStreamingAsync(messages, thread, options, cancellationToken); + + /// + /// Core implementation of the agent streaming invocation logic with a collection of chat messages. + /// + /// The collection of messages to send to the agent for processing. + /// + /// The conversation thread to use for this invocation. If , a new thread will be created. + /// The thread will be updated with the input messages and any response updates generated during invocation. + /// + /// Optional configuration parameters for controlling the agent's invocation behavior. + /// The to monitor for cancellation requests. The default is . + /// An asynchronous enumerable of instances representing the streaming response. + /// + /// /// This is the primary streaming invocation method that implementations must override. It provides real-time /// updates as the agent processes the input and generates its response, enabling more responsive user experiences. /// @@ -320,7 +377,7 @@ public IAsyncEnumerable RunStreamingAsync( /// to display partial results, implement progressive loading, or provide immediate feedback to users. /// /// - public abstract IAsyncEnumerable RunStreamingAsync( + protected abstract IAsyncEnumerable RunCoreStreamingAsync( IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/DelegatingAIAgent.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/DelegatingAIAgent.cs index 4c0ff1a36d..e7bf58f39f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/DelegatingAIAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/DelegatingAIAgent.cs @@ -81,7 +81,7 @@ public override AgentThread DeserializeThread(JsonElement serializedThread, Json => this.InnerAgent.DeserializeThread(serializedThread, jsonSerializerOptions); /// - public override Task RunAsync( + protected override Task RunCoreAsync( IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, @@ -89,7 +89,7 @@ public override Task RunAsync( => this.InnerAgent.RunAsync(messages, thread, options, cancellationToken); /// - public override IAsyncEnumerable RunStreamingAsync( + protected override IAsyncEnumerable RunCoreStreamingAsync( IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, diff --git a/dotnet/src/Microsoft.Agents.AI.CopilotStudio/CopilotStudioAgent.cs b/dotnet/src/Microsoft.Agents.AI.CopilotStudio/CopilotStudioAgent.cs index 6ca2f38d3d..203bab21ed 100644 --- a/dotnet/src/Microsoft.Agents.AI.CopilotStudio/CopilotStudioAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.CopilotStudio/CopilotStudioAgent.cs @@ -58,7 +58,7 @@ public override AgentThread DeserializeThread(JsonElement serializedThread, Json => new CopilotStudioAgentThread(serializedThread, jsonSerializerOptions); /// - public override async Task RunAsync( + protected override async Task RunCoreAsync( IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, @@ -96,7 +96,7 @@ public override async Task RunAsync( } /// - public override async IAsyncEnumerable RunStreamingAsync( + protected override async IAsyncEnumerable RunCoreStreamingAsync( IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAIAgent.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAIAgent.cs index 2035b792fd..d841a80ddd 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAIAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAIAgent.cs @@ -63,7 +63,7 @@ public override AgentThread DeserializeThread( /// Thrown when the agent has not been registered. /// Thrown when the provided thread is not valid for a durable agent. /// Thrown when cancellation is requested (cancellation is not supported for durable agents). - public override async Task RunAsync( + protected override async Task RunCoreAsync( IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, @@ -128,7 +128,7 @@ public override async Task RunAsync( /// Optional run options. /// The cancellation token. /// A streaming response enumerable. - public override async IAsyncEnumerable RunStreamingAsync( + protected override async IAsyncEnumerable RunCoreStreamingAsync( IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAIAgentProxy.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAIAgentProxy.cs index 58f9598a7e..ecff2d5c90 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAIAgentProxy.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAIAgentProxy.cs @@ -23,7 +23,7 @@ public override AgentThread GetNewThread() return new DurableAgentThread(AgentSessionId.WithRandomKey(this.Name!)); } - public override async Task RunAsync( + protected override async Task RunCoreAsync( IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, @@ -70,7 +70,7 @@ public override async Task RunAsync( return await agentRunHandle.ReadAgentResponseAsync(cancellationToken); } - public override IAsyncEnumerable RunStreamingAsync( + protected override IAsyncEnumerable RunCoreStreamingAsync( IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/EntityAgentWrapper.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/EntityAgentWrapper.cs index 8822ebcc39..4a6074fcb6 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/EntityAgentWrapper.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/EntityAgentWrapper.cs @@ -21,13 +21,13 @@ internal sealed class EntityAgentWrapper( // The ID of the agent is always the entity ID. protected override string? IdCore => this._entityContext.Id.ToString(); - public override async Task RunAsync( + protected override async Task RunCoreAsync( IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) { - AgentRunResponse response = await base.RunAsync( + AgentRunResponse response = await base.RunCoreAsync( messages, thread, this.GetAgentEntityRunOptions(options), @@ -37,13 +37,13 @@ public override async Task RunAsync( return response; } - public override async IAsyncEnumerable RunStreamingAsync( + protected override async IAsyncEnumerable RunCoreStreamingAsync( IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { - await foreach (AgentRunResponseUpdate update in base.RunStreamingAsync( + await foreach (AgentRunResponseUpdate update in base.RunCoreStreamingAsync( messages, thread, this.GetAgentEntityRunOptions(options), diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/PurviewAgent.cs b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewAgent.cs index fd2a1950e9..6907fe8889 100644 --- a/dotnet/src/Microsoft.Agents.AI.Purview/PurviewAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewAgent.cs @@ -42,13 +42,13 @@ public override AgentThread GetNewThread() } /// - public override Task RunAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) + protected override Task RunCoreAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) { return this._purviewWrapper.ProcessAgentContentAsync(messages, thread, options, this._innerAgent, cancellationToken); } /// - public override async IAsyncEnumerable RunStreamingAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + protected override async IAsyncEnumerable RunCoreStreamingAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { var response = await this._purviewWrapper.ProcessAgentContentAsync(messages, thread, options, this._innerAgent, cancellationToken).ConfigureAwait(false); foreach (var update in response.ToAgentRunResponseUpdates()) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostAgent.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostAgent.cs index 70fcee15df..7c0479b85e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostAgent.cs @@ -79,8 +79,8 @@ private async ValueTask UpdateThreadAsync(IEnumerable RunAsync( + protected override async + Task RunCoreAsync( IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, @@ -101,8 +101,8 @@ Task RunAsync( return merger.ComputeMerged(workflowThread.LastResponseId!, this.Id, this.Name); } - public override async - IAsyncEnumerable RunStreamingAsync( + protected override async + IAsyncEnumerable RunCoreStreamingAsync( IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, diff --git a/dotnet/src/Microsoft.Agents.AI/AnonymousDelegatingAIAgent.cs b/dotnet/src/Microsoft.Agents.AI/AnonymousDelegatingAIAgent.cs index 21fbfda639..542bafdbf4 100644 --- a/dotnet/src/Microsoft.Agents.AI/AnonymousDelegatingAIAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI/AnonymousDelegatingAIAgent.cs @@ -17,18 +17,18 @@ namespace Microsoft.Agents.AI; /// internal sealed class AnonymousDelegatingAIAgent : DelegatingAIAgent { - /// The delegate to use as the implementation of . + /// The delegate to use as the implementation of . private readonly Func, AgentThread?, AgentRunOptions?, AIAgent, CancellationToken, Task>? _runFunc; - /// The delegate to use as the implementation of . + /// The delegate to use as the implementation of . /// - /// When non-, this delegate is used as the implementation of and + /// When non-, this delegate is used as the implementation of and /// will be invoked with the same arguments as the method itself. - /// When , will delegate directly to the inner agent. + /// When , will delegate directly to the inner agent. /// private readonly Func, AgentThread?, AgentRunOptions?, AIAgent, CancellationToken, IAsyncEnumerable>? _runStreamingFunc; - /// The delegate to use as the implementation of both and . + /// The delegate to use as the implementation of both and . private readonly Func, AgentThread?, AgentRunOptions?, Func, AgentThread?, AgentRunOptions?, CancellationToken, Task>, CancellationToken, Task>? _sharedFunc; /// @@ -36,7 +36,7 @@ internal sealed class AnonymousDelegatingAIAgent : DelegatingAIAgent /// /// The inner agent. /// - /// A delegate that provides the implementation for both and . + /// A delegate that provides the implementation for both and . /// In addition to the arguments for the operation, it's provided with a delegate to the inner agent that should be /// used to perform the operation on the inner agent. It will handle both the non-streaming and streaming cases. /// @@ -61,13 +61,13 @@ public AnonymousDelegatingAIAgent( /// /// The inner agent. /// - /// A delegate that provides the implementation for . When , - /// must be non-null, and the implementation of + /// A delegate that provides the implementation for . When , + /// must be non-null, and the implementation of /// will use for the implementation. /// /// - /// A delegate that provides the implementation for . When , - /// must be non-null, and the implementation of + /// A delegate that provides the implementation for . When , + /// must be non-null, and the implementation of /// will use for the implementation. /// /// is . @@ -85,7 +85,7 @@ public AnonymousDelegatingAIAgent( } /// - public override Task RunAsync( + protected override Task RunCoreAsync( IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, @@ -132,7 +132,7 @@ await this._sharedFunc( } /// - public override IAsyncEnumerable RunStreamingAsync( + protected override IAsyncEnumerable RunCoreStreamingAsync( IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs index a5a34d24a9..f4a7fcd9c2 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs @@ -149,7 +149,7 @@ public ChatClientAgent(IChatClient chatClient, ChatClientAgentOptions? options, internal ChatOptions? ChatOptions => this._agentOptions?.ChatOptions; /// - public override Task RunAsync( + protected override Task RunCoreAsync( IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, @@ -193,7 +193,7 @@ private static IChatClient ApplyRunOptionsTransformations(AgentRunOptions? optio } /// - public override async IAsyncEnumerable RunStreamingAsync( + protected override async IAsyncEnumerable RunCoreStreamingAsync( IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, diff --git a/dotnet/src/Microsoft.Agents.AI/FunctionInvocationDelegatingAgent.cs b/dotnet/src/Microsoft.Agents.AI/FunctionInvocationDelegatingAgent.cs index 7eefcebc55..2463b266c7 100644 --- a/dotnet/src/Microsoft.Agents.AI/FunctionInvocationDelegatingAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI/FunctionInvocationDelegatingAgent.cs @@ -21,10 +21,10 @@ internal FunctionInvocationDelegatingAgent(AIAgent innerAgent, Func RunAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) + protected override Task RunCoreAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) => this.InnerAgent.RunAsync(messages, thread, this.AgentRunOptionsWithFunctionMiddleware(options), cancellationToken); - public override IAsyncEnumerable RunStreamingAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) + protected override IAsyncEnumerable RunCoreStreamingAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) => this.InnerAgent.RunStreamingAsync(messages, thread, this.AgentRunOptionsWithFunctionMiddleware(options), cancellationToken); // Decorate options to add the middleware function diff --git a/dotnet/src/Microsoft.Agents.AI/LoggingAgent.cs b/dotnet/src/Microsoft.Agents.AI/LoggingAgent.cs index b986e58bae..03b85d1ef5 100644 --- a/dotnet/src/Microsoft.Agents.AI/LoggingAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI/LoggingAgent.cs @@ -55,7 +55,7 @@ public JsonSerializerOptions JsonSerializerOptions } /// - public override async Task RunAsync( + protected override async Task RunCoreAsync( IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) { if (this._logger.IsEnabled(LogLevel.Debug)) @@ -72,7 +72,7 @@ public override async Task RunAsync( try { - AgentRunResponse response = await base.RunAsync(messages, thread, options, cancellationToken).ConfigureAwait(false); + AgentRunResponse response = await base.RunCoreAsync(messages, thread, options, cancellationToken).ConfigureAwait(false); if (this._logger.IsEnabled(LogLevel.Debug)) { @@ -101,7 +101,7 @@ public override async Task RunAsync( } /// - public override async IAsyncEnumerable RunStreamingAsync( + protected override async IAsyncEnumerable RunCoreStreamingAsync( IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { if (this._logger.IsEnabled(LogLevel.Debug)) @@ -119,7 +119,7 @@ public override async IAsyncEnumerable RunStreamingAsync IAsyncEnumerator e; try { - e = base.RunStreamingAsync(messages, thread, options, cancellationToken).GetAsyncEnumerator(cancellationToken); + e = base.RunCoreStreamingAsync(messages, thread, options, cancellationToken).GetAsyncEnumerator(cancellationToken); } catch (OperationCanceledException) { diff --git a/dotnet/src/Microsoft.Agents.AI/OpenTelemetryAgent.cs b/dotnet/src/Microsoft.Agents.AI/OpenTelemetryAgent.cs index 22da0c99da..35d31371c3 100644 --- a/dotnet/src/Microsoft.Agents.AI/OpenTelemetryAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI/OpenTelemetryAgent.cs @@ -78,7 +78,7 @@ public bool EnableSensitiveData } /// - public override async Task RunAsync( + protected override async Task RunCoreAsync( IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) { ChatOptions co = new ForwardedOptions(options, thread, Activity.Current); @@ -89,7 +89,7 @@ public override async Task RunAsync( } /// - public override async IAsyncEnumerable RunStreamingAsync( + protected override async IAsyncEnumerable RunCoreStreamingAsync( IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { ChatOptions co = new ForwardedOptions(options, thread, Activity.Current); diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AIAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AIAgentTests.cs index e3bda2081a..a1c8cb32bf 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AIAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AIAgentTests.cs @@ -8,6 +8,7 @@ using System.Threading.Tasks; using Microsoft.Extensions.AI; using Moq; +using Moq.Protected; namespace Microsoft.Agents.AI.Abstractions.UnitTests; @@ -33,18 +34,20 @@ public AIAgentTests() this._agentMock = new Mock { CallBase = true }; this._agentMock - .Setup(x => x.RunAsync( - It.IsAny>(), - this._agentThreadMock.Object, - It.IsAny(), - It.IsAny())) + .Protected() + .Setup>("RunCoreAsync", + ItExpr.IsAny>(), + ItExpr.Is(t => t == this._agentThreadMock.Object), + ItExpr.IsAny(), + ItExpr.IsAny()) .ReturnsAsync(this._invokeResponse); this._agentMock - .Setup(x => x.RunStreamingAsync( - It.IsAny>(), - this._agentThreadMock.Object, - It.IsAny(), - It.IsAny())) + .Protected() + .Setup>("RunCoreStreamingAsync", + ItExpr.IsAny>(), + ItExpr.Is(t => t == this._agentThreadMock.Object), + ItExpr.IsAny(), + ItExpr.IsAny()) .Returns(ToAsyncEnumerableAsync(this._invokeStreamingResponses)); } @@ -64,13 +67,14 @@ public async Task InvokeWithoutMessageCallsMockedInvokeWithEmptyArrayAsync() Assert.Equal(this._invokeResponse, response); // Verify that the mocked method was called with the expected parameters - this._agentMock.Verify( - x => x.RunAsync( - It.Is>(messages => messages.Count == 0), - this._agentThreadMock.Object, - options, - cancellationToken), - Times.Once); + this._agentMock + .Protected() + .Verify>("RunCoreAsync", + Times.Once(), + ItExpr.Is>(messages => !messages.Any()), + ItExpr.Is(t => t == this._agentThreadMock.Object), + ItExpr.Is(o => o == options), + ItExpr.Is(ct => ct == cancellationToken)); } /// @@ -90,13 +94,14 @@ public async Task InvokeWithStringMessageCallsMockedInvokeWithMessageInCollectio Assert.Equal(this._invokeResponse, response); // Verify that the mocked method was called with the expected parameters - this._agentMock.Verify( - x => x.RunAsync( - It.Is>(messages => messages.Count == 1 && messages.First().Text == Message), - this._agentThreadMock.Object, - options, - cancellationToken), - Times.Once); + this._agentMock + .Protected() + .Verify>("RunCoreAsync", + Times.Once(), + ItExpr.Is>(messages => messages.Count() == 1 && messages.First().Text == Message), + ItExpr.Is(t => t == this._agentThreadMock.Object), + ItExpr.Is(o => o == options), + ItExpr.Is(ct => ct == cancellationToken)); } /// @@ -116,13 +121,14 @@ public async Task InvokeWithSingleMessageCallsMockedInvokeWithMessageInCollectio Assert.Equal(this._invokeResponse, response); // Verify that the mocked method was called with the expected parameters - this._agentMock.Verify( - x => x.RunAsync( - It.Is>(messages => messages.Count == 1 && messages.First() == message), - this._agentThreadMock.Object, - options, - cancellationToken), - Times.Once); + this._agentMock + .Protected() + .Verify>("RunCoreAsync", + Times.Once(), + ItExpr.Is>(messages => messages.Count() == 1 && messages.First() == message), + ItExpr.Is(t => t == this._agentThreadMock.Object), + ItExpr.Is(o => o == options), + ItExpr.Is(ct => ct == cancellationToken)); } /// @@ -144,13 +150,14 @@ public async Task InvokeStreamingWithoutMessageCallsMockedInvokeWithEmptyArrayAs } // Verify that the mocked method was called with the expected parameters - this._agentMock.Verify( - x => x.RunStreamingAsync( - It.Is>(messages => messages.Count == 0), - this._agentThreadMock.Object, - options, - cancellationToken), - Times.Once); + this._agentMock + .Protected() + .Verify>("RunCoreStreamingAsync", + Times.Once(), + ItExpr.Is>(messages => !messages.Any()), + ItExpr.Is(t => t == this._agentThreadMock.Object), + ItExpr.Is(o => o == options), + ItExpr.Is(ct => ct == cancellationToken)); } /// @@ -173,13 +180,14 @@ public async Task InvokeStreamingWithStringMessageCallsMockedInvokeWithMessageIn } // Verify that the mocked method was called with the expected parameters - this._agentMock.Verify( - x => x.RunStreamingAsync( - It.Is>(messages => messages.Count == 1 && messages.First().Text == Message), - this._agentThreadMock.Object, - options, - cancellationToken), - Times.Once); + this._agentMock + .Protected() + .Verify>("RunCoreStreamingAsync", + Times.Once(), + ItExpr.Is>(messages => messages.Count() == 1 && messages.First().Text == Message), + ItExpr.Is(t => t == this._agentThreadMock.Object), + ItExpr.Is(o => o == options), + ItExpr.Is(ct => ct == cancellationToken)); } /// @@ -202,13 +210,14 @@ public async Task InvokeStreamingWithSingleMessageCallsMockedInvokeWithMessageIn } // Verify that the mocked method was called with the expected parameters - this._agentMock.Verify( - x => x.RunStreamingAsync( - It.Is>(messages => messages.Count == 1 && messages.First() == message), - this._agentThreadMock.Object, - options, - cancellationToken), - Times.Once); + this._agentMock + .Protected() + .Verify>("RunCoreStreamingAsync", + Times.Once(), + ItExpr.Is>(messages => messages.Count() == 1 && messages.First() == message), + ItExpr.Is(t => t == this._agentThreadMock.Object), + ItExpr.Is(o => o == options), + ItExpr.Is(ct => ct == cancellationToken)); } [Fact] @@ -375,14 +384,14 @@ public override AgentThread GetNewThread() public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null) => throw new NotImplementedException(); - public override Task RunAsync( + protected override Task RunCoreAsync( IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) => throw new NotImplementedException(); - public override IAsyncEnumerable RunStreamingAsync( + protected override IAsyncEnumerable RunCoreStreamingAsync( IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/DelegatingAIAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/DelegatingAIAgentTests.cs index 50271b7eee..2a6cc7bb81 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/DelegatingAIAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/DelegatingAIAgentTests.cs @@ -38,19 +38,21 @@ public DelegatingAIAgentTests() this._innerAgentMock.Setup(x => x.GetNewThread()).Returns(this._testThread); this._innerAgentMock - .Setup(x => x.RunAsync( - It.IsAny>(), - It.IsAny(), - It.IsAny(), - It.IsAny())) + .Protected() + .Setup>("RunCoreAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) .ReturnsAsync(this._testResponse); this._innerAgentMock - .Setup(x => x.RunStreamingAsync( - It.IsAny>(), - It.IsAny(), - It.IsAny(), - It.IsAny())) + .Protected() + .Setup>("RunCoreStreamingAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) .Returns(ToAsyncEnumerableAsync(this._testStreamingResponses)); this._delegatingAgent = new TestDelegatingAIAgent(this._innerAgentMock.Object); @@ -159,7 +161,12 @@ public async Task RunAsyncDefaultsToInnerAgentAsync() var innerAgentMock = new Mock(); innerAgentMock - .Setup(x => x.RunAsync(expectedMessages, expectedThread, expectedOptions, expectedCancellationToken)) + .Protected() + .Setup>("RunCoreAsync", + ItExpr.Is>(m => m == expectedMessages), + ItExpr.Is(t => t == expectedThread), + ItExpr.Is(o => o == expectedOptions), + ItExpr.Is(ct => ct == expectedCancellationToken)) .Returns(expectedResult.Task); var delegatingAgent = new TestDelegatingAIAgent(innerAgentMock.Object); @@ -193,7 +200,12 @@ public async Task RunStreamingAsyncDefaultsToInnerAgentAsync() var innerAgentMock = new Mock(); innerAgentMock - .Setup(x => x.RunStreamingAsync(expectedMessages, expectedThread, expectedOptions, expectedCancellationToken)) + .Protected() + .Setup>("RunCoreStreamingAsync", + ItExpr.Is>(m => m == expectedMessages), + ItExpr.Is(t => t == expectedThread), + ItExpr.Is(o => o == expectedOptions), + ItExpr.Is(ct => ct == expectedCancellationToken)) .Returns(ToAsyncEnumerableAsync(expectedResults)); var delegatingAgent = new TestDelegatingAIAgent(innerAgentMock.Object); diff --git a/dotnet/tests/Microsoft.Agents.AI.Declarative.UnitTests/AggregatorPromptAgentFactoryTests.cs b/dotnet/tests/Microsoft.Agents.AI.Declarative.UnitTests/AggregatorPromptAgentFactoryTests.cs index d20bd9be00..09ee72504a 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Declarative.UnitTests/AggregatorPromptAgentFactoryTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Declarative.UnitTests/AggregatorPromptAgentFactoryTests.cs @@ -76,12 +76,12 @@ public override AgentThread GetNewThread() throw new NotImplementedException(); } - public override Task RunAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) + protected override Task RunCoreAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) { throw new NotImplementedException(); } - public override IAsyncEnumerable RunStreamingAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) + protected override IAsyncEnumerable RunCoreStreamingAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) { throw new NotImplementedException(); } diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/BasicStreamingTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/BasicStreamingTests.cs index 69560421cf..dfabaca64e 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/BasicStreamingTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/BasicStreamingTests.cs @@ -290,7 +290,7 @@ public override AgentThread DeserializeThread(JsonElement serializedThread, Json return new FakeInMemoryAgentThread(serializedThread, jsonSerializerOptions); } - public override async Task RunAsync( + protected override async Task RunCoreAsync( IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, @@ -305,7 +305,7 @@ public override async Task RunAsync( return updates.ToAgentRunResponse(); } - public override async IAsyncEnumerable RunStreamingAsync( + protected override async IAsyncEnumerable RunCoreStreamingAsync( IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, @@ -358,7 +358,7 @@ public override AgentThread DeserializeThread(JsonElement serializedThread, Json return new FakeInMemoryAgentThread(serializedThread, jsonSerializerOptions); } - public override async Task RunAsync( + protected override async Task RunCoreAsync( IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, @@ -373,7 +373,7 @@ public override async Task RunAsync( return updates.ToAgentRunResponse(); } - public override async IAsyncEnumerable RunStreamingAsync( + protected override async IAsyncEnumerable RunCoreStreamingAsync( IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/ForwardedPropertiesTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/ForwardedPropertiesTests.cs index df8caea214..1777ff456a 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/ForwardedPropertiesTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/ForwardedPropertiesTests.cs @@ -303,12 +303,12 @@ public FakeForwardedPropsAgent() public JsonElement ReceivedForwardedProperties { get; private set; } - public override Task RunAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) + protected override Task RunCoreAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) { - return this.RunStreamingAsync(messages, thread, options, cancellationToken).ToAgentRunResponseAsync(cancellationToken); + return this.RunCoreStreamingAsync(messages, thread, options, cancellationToken).ToAgentRunResponseAsync(cancellationToken); } - public override async IAsyncEnumerable RunStreamingAsync( + protected override async IAsyncEnumerable RunCoreStreamingAsync( IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/SharedStateTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/SharedStateTests.cs index c96f2d92d0..df51d1cbc4 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/SharedStateTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/SharedStateTests.cs @@ -342,12 +342,12 @@ internal sealed class FakeStateAgent : AIAgent { public override string? Description => "Agent for state testing"; - public override Task RunAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) + protected override Task RunCoreAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) { - return this.RunStreamingAsync(messages, thread, options, cancellationToken).ToAgentRunResponseAsync(cancellationToken); + return this.RunCoreStreamingAsync(messages, thread, options, cancellationToken).ToAgentRunResponseAsync(cancellationToken); } - public override async IAsyncEnumerable RunStreamingAsync( + protected override async IAsyncEnumerable RunCoreStreamingAsync( IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/AGUIEndpointRouteBuilderExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/AGUIEndpointRouteBuilderExtensionsTests.cs index 3e80a58369..402451b061 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/AGUIEndpointRouteBuilderExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/AGUIEndpointRouteBuilderExtensionsTests.cs @@ -430,12 +430,12 @@ private sealed class MultiResponseAgent : AIAgent public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null) => new TestInMemoryAgentThread(serializedThread, jsonSerializerOptions); - public override Task RunAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) + protected override Task RunCoreAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) { throw new NotImplementedException(); } - public override async IAsyncEnumerable RunStreamingAsync( + protected override async IAsyncEnumerable RunCoreStreamingAsync( IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, @@ -519,12 +519,12 @@ private sealed class TestAgent : AIAgent public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null) => new TestInMemoryAgentThread(serializedThread, jsonSerializerOptions); - public override Task RunAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) + protected override Task RunCoreAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) { throw new NotImplementedException(); } - public override async IAsyncEnumerable RunStreamingAsync( + protected override async IAsyncEnumerable RunCoreStreamingAsync( IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/TestAgent.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/TestAgent.cs index b0ad7ec0fe..e6824a2dd4 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/TestAgent.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/TestAgent.cs @@ -17,13 +17,13 @@ public override AgentThread DeserializeThread( JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null) => new DummyAgentThread(); - public override Task RunAsync( + protected override Task RunCoreAsync( IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) => Task.FromResult(new AgentRunResponse([.. messages])); - public override IAsyncEnumerable RunStreamingAsync( + protected override IAsyncEnumerable RunCoreStreamingAsync( IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, diff --git a/dotnet/tests/Microsoft.Agents.AI.OpenAI.UnitTests/Extensions/AIAgentWithOpenAIExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.OpenAI.UnitTests/Extensions/AIAgentWithOpenAIExtensionsTests.cs index de8c459be0..60c37c9b82 100644 --- a/dotnet/tests/Microsoft.Agents.AI.OpenAI.UnitTests/Extensions/AIAgentWithOpenAIExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.OpenAI.UnitTests/Extensions/AIAgentWithOpenAIExtensionsTests.cs @@ -6,6 +6,7 @@ using System.Threading; using System.Threading.Tasks; using Moq; +using Moq.Protected; using ChatMessage = Microsoft.Extensions.AI.ChatMessage; using ChatRole = Microsoft.Extensions.AI.ChatRole; using OpenAIChatMessage = OpenAI.Chat.ChatMessage; @@ -76,22 +77,28 @@ public async Task RunAsync_CallsUnderlyingAgentAsync() var responseMessage = new ChatMessage(ChatRole.Assistant, [new TextContent(ResponseText)]); mockAgent - .Setup(a => a.RunAsync(It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny())) + .Protected() + .Setup>("RunCoreAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) .ReturnsAsync(new AgentRunResponse([responseMessage])); // Act var result = await mockAgent.Object.RunAsync(openAiMessages, mockThread.Object, options, cancellationToken); // Assert - mockAgent.Verify( - a => a.RunAsync( - It.Is>(msgs => + mockAgent.Protected() + .Verify("RunCoreAsync", + Times.Once(), + ItExpr.Is>(msgs => msgs.ToList().Count == 1 && msgs.ToList()[0].Text == TestMessageText), mockThread.Object, options, - cancellationToken), - Times.Once); + cancellationToken + ); Assert.NotNull(result); Assert.NotEmpty(result.Content); @@ -160,7 +167,12 @@ public async Task RunStreamingAsync_CallsUnderlyingAgentAsync() }; mockAgent - .Setup(a => a.RunStreamingAsync(It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny())) + .Protected() + .Setup>("RunCoreStreamingAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) .Returns(ToAsyncEnumerableAsync(responseUpdates)); // Act @@ -172,15 +184,16 @@ public async Task RunStreamingAsync_CallsUnderlyingAgentAsync() } // Assert - mockAgent.Verify( - a => a.RunStreamingAsync( - It.Is>(msgs => + mockAgent.Protected() + .Verify("RunCoreStreamingAsync", + Times.Once(), + ItExpr.Is>(msgs => msgs.ToList().Count == 1 && msgs.ToList()[0].Text == TestMessageText), mockThread.Object, options, - cancellationToken), - Times.Once); + cancellationToken + ); Assert.True(updateCount > 0, "Expected at least one streaming update"); } diff --git a/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/PurviewWrapperTests.cs b/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/PurviewWrapperTests.cs index 22b729dda4..eafc67f7fc 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/PurviewWrapperTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/PurviewWrapperTests.cs @@ -8,6 +8,7 @@ using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging.Abstractions; using Moq; +using Moq.Protected; namespace Microsoft.Agents.AI.Purview.UnitTests; @@ -277,11 +278,13 @@ public async Task ProcessAgentContentAsync_WithBlockedPrompt_ReturnsBlockedMessa Assert.Single(result.Messages); Assert.Equal(ChatRole.System, result.Messages[0].Role); Assert.Equal("Prompt blocked by policy", result.Messages[0].Text); - mockAgent.Verify(x => x.RunAsync( - It.IsAny>(), - It.IsAny(), - It.IsAny(), - It.IsAny()), Times.Never); + + mockAgent.Protected().Verify("RunCoreAsync", + Times.Never(), + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()); } [Fact] @@ -295,11 +298,12 @@ public async Task ProcessAgentContentAsync_WithAllowedPromptAndBlockedResponse_R var mockAgent = new Mock(); var innerResponse = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "Sensitive response")); - mockAgent.Setup(x => x.RunAsync( - It.IsAny>(), - It.IsAny(), - It.IsAny(), - It.IsAny())) + mockAgent.Protected() + .Setup>("RunCoreAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) .ReturnsAsync(innerResponse); this._mockProcessor.SetupSequence(x => x.ProcessMessagesAsync( @@ -333,11 +337,12 @@ public async Task ProcessAgentContentAsync_WithAllowedPromptAndResponse_ReturnsI var mockAgent = new Mock(); var innerResponse = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "Safe response")); - mockAgent.Setup(x => x.RunAsync( - It.IsAny>(), - It.IsAny(), - It.IsAny(), - It.IsAny())) + mockAgent.Protected() + .Setup>("RunCoreAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) .ReturnsAsync(innerResponse); this._mockProcessor.Setup(x => x.ProcessMessagesAsync( @@ -375,11 +380,12 @@ public async Task ProcessAgentContentAsync_WithIgnoreExceptions_ContinuesOnError var expectedResponse = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "Response from inner agent")); var mockAgent = new Mock(); - mockAgent.Setup(x => x.RunAsync( - It.IsAny>(), - It.IsAny(), - It.IsAny(), - It.IsAny())) + mockAgent.Protected() + .Setup>("RunCoreAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) .ReturnsAsync(expectedResponse); this._mockProcessor.SetupSequence(x => x.ProcessMessagesAsync( @@ -441,11 +447,12 @@ public async Task ProcessAgentContentAsync_ExtractsThreadIdFromMessageAdditional var expectedResponse = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "Response")); var mockAgent = new Mock(); - mockAgent.Setup(x => x.RunAsync( - It.IsAny>(), - It.IsAny(), - It.IsAny(), - It.IsAny())) + mockAgent.Protected() + .Setup>("RunCoreAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) .ReturnsAsync(expectedResponse); this._mockProcessor.Setup(x => x.ProcessMessagesAsync( @@ -482,11 +489,12 @@ public async Task ProcessAgentContentAsync_GeneratesThreadId_WhenNotProvidedAsyn var expectedResponse = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "Response")); var mockAgent = new Mock(); - mockAgent.Setup(x => x.RunAsync( - It.IsAny>(), - It.IsAny(), - It.IsAny(), - It.IsAny())) + mockAgent.Protected() + .Setup>("RunCoreAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) .ReturnsAsync(expectedResponse); string? capturedThreadId = null; @@ -521,11 +529,12 @@ public async Task ProcessAgentContentAsync_PassesResolvedUserId_ToResponseProces var mockAgent = new Mock(); var innerResponse = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "Response")); - mockAgent.Setup(x => x.RunAsync( - It.IsAny>(), - It.IsAny(), - It.IsAny(), - It.IsAny())) + mockAgent.Protected() + .Setup>("RunCoreAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) .ReturnsAsync(innerResponse); var callCount = 0; diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentExtensionsTests.cs index f2b2bcfd6a..d039c95652 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentExtensionsTests.cs @@ -337,7 +337,7 @@ public override AgentThread DeserializeThread(JsonElement serializedThread, Json public CancellationToken LastCancellationToken { get; private set; } public int RunAsyncCallCount { get; private set; } - public override Task RunAsync( + protected override Task RunCoreAsync( IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, @@ -355,7 +355,7 @@ public override Task RunAsync( return Task.FromResult(this._responseToReturn!); } - public override async IAsyncEnumerable RunStreamingAsync( + protected override async IAsyncEnumerable RunCoreStreamingAsync( IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AnonymousDelegatingAIAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AnonymousDelegatingAIAgentTests.cs index 369ab1ad4f..4e91fc1430 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AnonymousDelegatingAIAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AnonymousDelegatingAIAgentTests.cs @@ -8,6 +8,7 @@ using System.Threading.Tasks; using Microsoft.Extensions.AI; using Moq; +using Moq.Protected; namespace Microsoft.Agents.AI.UnitTests; @@ -35,18 +36,22 @@ public AnonymousDelegatingAIAgentTests() new AgentRunResponseUpdate(ChatRole.Assistant, "Response 2") ]; - this._innerAgentMock.Setup(x => x.RunAsync( - It.IsAny>(), - It.IsAny(), - It.IsAny(), - It.IsAny())) + this._innerAgentMock + .Protected() + .Setup>("RunCoreAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) .ReturnsAsync(this._testResponse); - this._innerAgentMock.Setup(x => x.RunStreamingAsync( - It.IsAny>(), - It.IsAny(), - It.IsAny(), - It.IsAny())) + this._innerAgentMock + .Protected() + .Setup>("RunCoreStreamingAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) .Returns(ToAsyncEnumerableAsync(this._testStreamingResponses)); } @@ -184,11 +189,14 @@ public async Task RunAsync_WithSharedFunc_ContextPropagatedAsync() Assert.Same(this._testOptions, capturedOptions); Assert.Equal(expectedCancellationToken, capturedCancellationToken); - this._innerAgentMock.Verify(x => x.RunAsync( - this._testMessages, - this._testThread, - this._testOptions, - expectedCancellationToken), Times.Once); + this._innerAgentMock + .Protected() + .Verify>("RunCoreAsync", + Times.Once(), + ItExpr.Is>(m => m == this._testMessages), + ItExpr.Is(t => t == this._testThread), + ItExpr.Is(o => o == this._testOptions), + ItExpr.Is(ct => ct == expectedCancellationToken)); } /// @@ -458,11 +466,13 @@ public async Task AsyncLocalContext_MaintainedAcrossDelegatesAsync() capturedValue = asyncLocal.Value; }); - this._innerAgentMock.Setup(x => x.RunAsync( - It.IsAny>(), - It.IsAny(), - It.IsAny(), - It.IsAny())) + this._innerAgentMock + .Protected() + .Setup>("RunCoreAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) .Returns(() => { // Verify AsyncLocal value is available in inner agent call @@ -926,11 +936,13 @@ public async Task AIAgentBuilder_Use_CancellationTokenPropagation_WorksCorrectly var capturedTokens = new List(); // Setup mock to throw OperationCanceledException when cancelled token is used - this._innerAgentMock.Setup(x => x.RunAsync( - It.IsAny>(), - It.IsAny(), - It.IsAny(), - It.Is(ct => ct.IsCancellationRequested))) + this._innerAgentMock + .Protected() + .Setup>("RunCoreAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.Is(ct => ct.IsCancellationRequested)) .ThrowsAsync(new OperationCanceledException()); var agent = new AIAgentBuilder(this._innerAgentMock.Object) @@ -993,11 +1005,14 @@ public async Task AIAgentBuilder_Use_MiddlewareShortCircuits_InnerAgentNotCalled Assert.Equal(expectedOrder, executionOrder); // Verify inner agent was never called - this._innerAgentMock.Verify(x => x.RunAsync( - It.IsAny>(), - It.IsAny(), - It.IsAny(), - It.IsAny()), Times.Never); + this._innerAgentMock + .Protected() + .Verify>("RunCoreAsync", + Times.Never(), + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()); } #endregion diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/TestAIAgent.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/TestAIAgent.cs index fb00973c78..3d2cdff868 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/TestAIAgent.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/TestAIAgent.cs @@ -30,10 +30,10 @@ public override AgentThread DeserializeThread(JsonElement serializedThread, Json public override AgentThread GetNewThread() => this.GetNewThreadFunc(); - public override Task RunAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) => + protected override Task RunCoreAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) => this.RunAsyncFunc(messages, thread, options, cancellationToken); - public override IAsyncEnumerable RunStreamingAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) => + protected override IAsyncEnumerable RunCoreStreamingAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) => this.RunStreamingAsyncFunc(messages, thread, options, cancellationToken); public override object? GetService(Type serviceType, object? serviceKey = null) => diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilderTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilderTests.cs index 0437fc7695..c45ef8726e 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilderTests.cs @@ -141,11 +141,11 @@ public override AgentThread GetNewThread() public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null) => new DoubleEchoAgentThread(); - public override Task RunAsync( + protected override Task RunCoreAsync( IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) => throw new NotImplementedException(); - public override async IAsyncEnumerable RunStreamingAsync( + protected override async IAsyncEnumerable RunCoreStreamingAsync( IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { await Task.Yield(); @@ -409,7 +409,7 @@ public async Task BuildGroupChat_AgentsRunInOrderAsync(int maxIterations) private sealed class DoubleEchoAgentWithBarrier(string name, StrongBox> barrier, StrongBox remaining) : DoubleEchoAgent(name) { - public override async IAsyncEnumerable RunStreamingAsync( + protected override async IAsyncEnumerable RunCoreStreamingAsync( IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { if (Interlocked.Decrement(ref remaining.Value) == 0) @@ -419,7 +419,7 @@ public override async IAsyncEnumerable RunStreamingAsync await barrier.Value!.Task.ConfigureAwait(false); - await foreach (var update in base.RunStreamingAsync(messages, thread, options, cancellationToken)) + await foreach (var update in base.RunCoreStreamingAsync(messages, thread, options, cancellationToken)) { await Task.Yield(); yield return update; diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/InProcessExecutionTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/InProcessExecutionTests.cs index e134f10aa7..b3e53da6f8 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/InProcessExecutionTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/InProcessExecutionTests.cs @@ -149,7 +149,7 @@ public SimpleTestAgent(string name) public override AgentThread DeserializeThread(System.Text.Json.JsonElement serializedThread, System.Text.Json.JsonSerializerOptions? jsonSerializerOptions = null) => new SimpleTestAgentThread(); - public override Task RunAsync( + protected override Task RunCoreAsync( IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, @@ -160,7 +160,7 @@ public override Task RunAsync( return Task.FromResult(new AgentRunResponse(responseMessage)); } - public override async IAsyncEnumerable RunStreamingAsync( + protected override async IAsyncEnumerable RunCoreStreamingAsync( IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/RepresentationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/RepresentationTests.cs index 1878a55868..5eb8696221 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/RepresentationTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/RepresentationTests.cs @@ -30,10 +30,10 @@ public override AgentThread GetNewThread() public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null) => throw new NotImplementedException(); - public override Task RunAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) => + protected override Task RunCoreAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) => throw new NotImplementedException(); - public override IAsyncEnumerable RunStreamingAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) => + protected override IAsyncEnumerable RunCoreStreamingAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) => throw new NotImplementedException(); } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/06_GroupChat_Workflow.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/06_GroupChat_Workflow.cs index 16a51876d0..a351c45b20 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/06_GroupChat_Workflow.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/06_GroupChat_Workflow.cs @@ -66,17 +66,17 @@ public override AgentThread GetNewThread() public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null) => new HelloAgentThread(); - public override async Task RunAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) + protected override async Task RunCoreAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) { IEnumerable update = [ - await this.RunStreamingAsync(messages, thread, options, cancellationToken) + await this.RunCoreStreamingAsync(messages, thread, options, cancellationToken) .SingleAsync(cancellationToken) .ConfigureAwait(false)]; return update.ToAgentRunResponse(); } - public override async IAsyncEnumerable RunStreamingAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + protected override async IAsyncEnumerable RunCoreStreamingAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { yield return new(ChatRole.Assistant, "Hello World!") { diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/SpecializedExecutorSmokeTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/SpecializedExecutorSmokeTests.cs index daff2c248e..ed9af701c6 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/SpecializedExecutorSmokeTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/SpecializedExecutorSmokeTests.cs @@ -62,14 +62,14 @@ public static TestAIAgent FromStrings(params string[] messages) => public List Messages { get; } = Validate(messages) ?? []; - public override Task RunAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) => + protected override Task RunCoreAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) => Task.FromResult(new AgentRunResponse(this.Messages) { AgentId = this.Id, ResponseId = Guid.NewGuid().ToString("N") }); - public override async IAsyncEnumerable RunStreamingAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + protected override async IAsyncEnumerable RunCoreStreamingAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { string responseId = Guid.NewGuid().ToString("N"); foreach (ChatMessage message in this.Messages) diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestEchoAgent.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestEchoAgent.cs index caec2a0631..422d7a16ba 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestEchoAgent.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestEchoAgent.cs @@ -60,7 +60,7 @@ protected virtual IEnumerable GetEpilogueMessages(AgentRunOptions? return []; } - public override Task RunAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) + protected override Task RunCoreAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) { AgentRunResponse result = new(this.EchoMessages(messages, thread, options).ToList()) @@ -73,7 +73,7 @@ public override Task RunAsync(IEnumerable message return Task.FromResult(result); } - public override async IAsyncEnumerable RunStreamingAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + protected override async IAsyncEnumerable RunCoreStreamingAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { string responseId = Guid.NewGuid().ToString("N"); From 577ad4b838c39e3bbfc25cc788c4d0645e3de7ce Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Mon, 5 Jan 2026 02:32:33 +0100 Subject: [PATCH 02/13] add issue template and additional labeling (#3006) --- .github/ISSUE_TEMPLATE/config.yml | 8 ++ .github/ISSUE_TEMPLATE/issue.yml | 203 +++++++++++++++++++++++++++++ .github/workflows/label-issues.yml | 61 +++++++-- 3 files changed, 261 insertions(+), 11 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/issue.yml diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000000..b75f996b42 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Documentation + url: https://aka.ms/agent-framework + about: Check out the official documentation for guides and API reference. + - name: Discussions + url: https://github.com/microsoft/agent-framework/discussions + about: Ask questions and share ideas in GitHub Discussions. diff --git a/.github/ISSUE_TEMPLATE/issue.yml b/.github/ISSUE_TEMPLATE/issue.yml new file mode 100644 index 0000000000..1fe99ebbfd --- /dev/null +++ b/.github/ISSUE_TEMPLATE/issue.yml @@ -0,0 +1,203 @@ +name: Issue Report +description: Report a bug, request a feature, or ask a question about Microsoft Agent Framework +title: "[Issue]: " +labels: ["triage"] +body: + - type: dropdown + id: language + attributes: + label: Language + description: Which language/SDK are you using? + options: + - .NET + - Python + - None / Not Applicable + validations: + required: true + + - type: dropdown + id: issue-type + attributes: + label: Type of Issue + description: What type of issue is this? + options: + - Bug + - Feature Request + - Question + validations: + required: true + + - type: markdown + attributes: + value: | + ## Version Information + Please provide the version of the package(s) you are using. Select the relevant packages below. + + - type: markdown + attributes: + value: "### .NET Packages" + + - type: input + id: dotnet-agents-ai + attributes: + label: Microsoft.Agents.AI + description: Version of Microsoft.Agents.AI (e.g., 1.0.0) + placeholder: "e.g., 1.0.0" + validations: + required: false + + - type: input + id: dotnet-agents-ai-abstractions + attributes: + label: Microsoft.Agents.AI.Abstractions + description: Version of Microsoft.Agents.AI.Abstractions + placeholder: "e.g., 1.0.0" + validations: + required: false + + - type: input + id: dotnet-agents-ai-openai + attributes: + label: Microsoft.Agents.AI.OpenAI + description: Version of Microsoft.Agents.AI.OpenAI + placeholder: "e.g., 1.0.0" + validations: + required: false + + - type: input + id: dotnet-agents-ai-azureai + attributes: + label: Microsoft.Agents.AI.AzureAI + description: Version of Microsoft.Agents.AI.AzureAI + placeholder: "e.g., 1.0.0" + validations: + required: false + + - type: input + id: dotnet-agents-ai-anthropic + attributes: + label: Microsoft.Agents.AI.Anthropic + description: Version of Microsoft.Agents.AI.Anthropic + placeholder: "e.g., 1.0.0" + validations: + required: false + + - type: input + id: dotnet-agents-ai-hosting + attributes: + label: Microsoft.Agents.AI.Hosting + description: Version of Microsoft.Agents.AI.Hosting + placeholder: "e.g., 1.0.0" + validations: + required: false + + - type: input + id: dotnet-agents-ai-workflows + attributes: + label: Microsoft.Agents.AI.Workflows + description: Version of Microsoft.Agents.AI.Workflows + placeholder: "e.g., 1.0.0" + validations: + required: false + + - type: input + id: dotnet-other-packages + attributes: + label: Other .NET Packages + description: List any other Microsoft.Agents.* packages and versions you are using + placeholder: "e.g., Microsoft.Agents.AI.CopilotStudio: 1.0.0, Microsoft.Agents.AI.Purview: 1.0.0" + validations: + required: false + + - type: markdown + attributes: + value: "### Python Packages" + + - type: input + id: python-core + attributes: + label: agent-framework-core + description: Version of agent-framework-core + placeholder: "e.g., 1.0.0b1" + validations: + required: false + + - type: input + id: python-azure-ai + attributes: + label: agent-framework-azure-ai + description: Version of agent-framework-azure-ai + placeholder: "e.g., 1.0.0b1" + validations: + required: false + + - type: input + id: python-anthropic + attributes: + label: agent-framework-anthropic + description: Version of agent-framework-anthropic + placeholder: "e.g., 1.0.0b1" + validations: + required: false + + - type: input + id: python-azurefunctions + attributes: + label: agent-framework-azurefunctions + description: Version of agent-framework-azurefunctions + placeholder: "e.g., 1.0.0b1" + validations: + required: false + + - type: input + id: python-other-packages + attributes: + label: Other Python Packages + description: List any other agent-framework-* packages and versions you are using + placeholder: "e.g., agent-framework-mem0: 1.0.0b1, agent-framework-redis: 1.0.0b1" + validations: + required: false + + - type: markdown + attributes: + value: "---" + + - type: textarea + id: description + attributes: + label: Description + description: Please provide a clear and detailed description of the issue, feature request, or question. + placeholder: | + For bugs: Describe what happened, what you expected to happen, and steps to reproduce. + For features: Describe the feature you'd like and why it would be useful. + For questions: Describe what you're trying to accomplish. + validations: + required: true + + - type: textarea + id: code-sample + attributes: + label: Code Sample + description: If applicable, provide a minimal code sample that demonstrates the issue or your use case. + placeholder: | + ```python + # Your code here + ``` + + or + + ```csharp + // Your code here + ``` + render: markdown + validations: + required: false + + - type: textarea + id: additional-context + attributes: + label: Additional Context + description: Add any other context, screenshots, error messages, or stack traces that might be helpful. + placeholder: "Any additional information..." + validations: + required: false diff --git a/.github/workflows/label-issues.yml b/.github/workflows/label-issues.yml index 231ee6833d..111c63ef13 100644 --- a/.github/workflows/label-issues.yml +++ b/.github/workflows/label-issues.yml @@ -45,19 +45,58 @@ jobs: labels.push("triage") } - // Check if the body or the title contains the word 'python' (case-insensitive) - if ((body != null && body.match(/python/i)) || (title != null && title.match(/python/i))) { - // Add the 'python' label to the array - labels.push("python") + // Helper function to extract field value from issue form body + // Issue forms format fields as: ### Field Name\n\nValue + function getFormFieldValue(body, fieldName) { + if (!body) return null + const regex = new RegExp(`###\\s*${fieldName}\\s*\\n\\n([^\\n#]+)`, 'i') + const match = body.match(regex) + return match ? match[1].trim() : null } - // Check if the body or the title contains the words 'dotnet', '.net', 'c#' or 'csharp' (case-insensitive) - if ((body != null && body.match(/.net/i)) || (title != null && title.match(/.net/i)) || - (body != null && body.match(/dotnet/i)) || (title != null && title.match(/dotnet/i)) || - (body != null && body.match(/C#/i)) || (title != null && title.match(/C#/i)) || - (body != null && body.match(/csharp/i)) || (title != null && title.match(/csharp/i))) { - // Add the '.NET' label to the array - labels.push(".NET") + // Check for language from issue form dropdown first + const languageField = getFormFieldValue(body, 'Language') + let languageLabelAdded = false + + if (languageField) { + if (languageField === 'Python') { + labels.push("python") + languageLabelAdded = true + } else if (languageField === '.NET') { + labels.push(".NET") + languageLabelAdded = true + } + // 'None / Not Applicable' - don't add any language label + } + + // Fallback: Check if the body or the title contains the word 'python' (case-insensitive) + // Only if language wasn't already determined from the form field + if (!languageLabelAdded) { + if ((body != null && body.match(/python/i)) || (title != null && title.match(/python/i))) { + // Add the 'python' label to the array + labels.push("python") + } + + // Check if the body or the title contains the words 'dotnet', '.net', 'c#' or 'csharp' (case-insensitive) + if ((body != null && body.match(/\.net/i)) || (title != null && title.match(/\.net/i)) || + (body != null && body.match(/dotnet/i)) || (title != null && title.match(/dotnet/i)) || + (body != null && body.match(/C#/i)) || (title != null && title.match(/C#/i)) || + (body != null && body.match(/csharp/i)) || (title != null && title.match(/csharp/i))) { + // Add the '.NET' label to the array + labels.push(".NET") + } + } + + // Check for issue type from issue form dropdown + const issueTypeField = getFormFieldValue(body, 'Type of Issue') + if (issueTypeField) { + if (issueTypeField === 'Bug') { + labels.push("bug") + } else if (issueTypeField === 'Feature Request') { + labels.push("enhancement") + } else if (issueTypeField === 'Question') { + labels.push("question") + } } // Add the labels to the issue (only if there are labels to add) From deea844bc746b0787cb4e466b0cd1f7b4cd9045e Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Mon, 5 Jan 2026 05:35:10 +0100 Subject: [PATCH 03/13] fix and extra int test (#3037) --- .../agent_framework/openai/_chat_client.py | 2 +- .../tests/openai/test_openai_chat_client.py | 30 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/python/packages/core/agent_framework/openai/_chat_client.py b/python/packages/core/agent_framework/openai/_chat_client.py index b7cac3ba20..305757356d 100644 --- a/python/packages/core/agent_framework/openai/_chat_client.py +++ b/python/packages/core/agent_framework/openai/_chat_client.py @@ -183,7 +183,7 @@ def _prepare_options(self, messages: MutableSequence[ChatMessage], chat_options: translations = { "model_id": "model", "allow_multiple_tool_calls": "parallel_tool_calls", - "max_tokens": "max_output_tokens", + "max_tokens": "max_completion_tokens", } for old_key, new_key in translations.items(): if old_key in run_options and old_key != new_key: diff --git a/python/packages/core/tests/openai/test_openai_chat_client.py b/python/packages/core/tests/openai/test_openai_chat_client.py index d2ddc1fb02..18854799fd 100644 --- a/python/packages/core/tests/openai/test_openai_chat_client.py +++ b/python/packages/core/tests/openai/test_openai_chat_client.py @@ -236,6 +236,36 @@ async def test_openai_chat_completion_response() -> None: assert "scientists" in response.text +@pytest.mark.flaky +@skip_if_openai_integration_tests_disabled +async def test_openai_chat_completion_response_params() -> None: + """Test OpenAI chat completion responses.""" + openai_chat_client = OpenAIChatClient() + + assert isinstance(openai_chat_client, ChatClientProtocol) + + messages: list[ChatMessage] = [] + messages.append( + ChatMessage( + role="user", + text="Emily and David, two passionate scientists, met during a research expedition to Antarctica. " + "Bonded by their love for the natural world and shared curiosity, they uncovered a " + "groundbreaking phenomenon in glaciology that could potentially reshape our understanding " + "of climate change.", + ) + ) + messages.append(ChatMessage(role="user", text="who are Emily and David?")) + + # Test that the client can be used to get a response + response = await openai_chat_client.get_response( + messages=messages, chat_options=ChatOptions(max_tokens=150, temperature=0.7, top_p=0.9) + ) + + assert response is not None + assert isinstance(response, ChatResponse) + assert "scientists" in response.text + + @pytest.mark.flaky @skip_if_openai_integration_tests_disabled async def test_openai_chat_completion_response_tools() -> None: From 3ef67eff10a9d74d34d50cf117cae9e2b6f74e17 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Mon, 5 Jan 2026 11:51:15 +0000 Subject: [PATCH 04/13] .NET: [BREAKING] Refactor ChatMessageStore methods to be similar to AIContextProvider and add filtering support (#2604) * Refactor ChatMessageStore methods to be similar to AIContextProvider * Fix file encoding * Ensure that AIContextProvider messages area also persisted. * Update formatting and seal context classes * Improve formatting * Remove optional messages from constructor and add unit test * Add ChatMessageStore filtering via a decorator * Update sample and cosmos message store to store AIContextProvider messages in right order. Fix unit tests. * Update Workflowmessage store to use aicontext provider messages. * Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Apply suggestions from code review Co-authored-by: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> * Improve xml docs messaging * Address code review comments. * Also notify message store on failure --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> --- .../Program.cs | 20 +- .../Program.cs | 7 +- .../Program.cs | 46 ++-- .../ChatMessageStore.cs | 114 +++++++++- .../ChatMessageStoreExtensions.cs | 50 +++++ .../ChatMessageStoreMessageFilter.cs | 74 +++++++ .../InMemoryChatMessageStore.cs | 29 ++- .../CosmosChatMessageStore.cs | 15 +- .../WorkflowHostAgent.cs | 8 +- .../WorkflowMessageStore.cs | 18 +- .../ChatClient/ChatClientAgent.cs | 70 +++++- .../AnthropicChatCompletionFixture.cs | 7 +- .../AIProjectClientFixture.cs | 7 +- .../ChatMessageStoreMessageFilterTests.cs | 205 ++++++++++++++++++ .../ChatMessageStoreTests.cs | 8 +- .../InMemoryChatMessageStoreTests.cs | 72 ++++-- .../CosmosChatMessageStoreTests.cs | 142 ++++++++---- .../ChatClient/ChatClientAgentTests.cs | 63 +++++- ...hatClientAgent_BackgroundResponsesTests.cs | 8 +- .../OpenAIChatCompletionFixture.cs | 7 +- .../OpenAIResponseFixture.cs | 7 +- 21 files changed, 838 insertions(+), 139 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.AI.Abstractions/ChatMessageStoreExtensions.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Abstractions/ChatMessageStoreMessageFilter.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/ChatMessageStoreMessageFilterTests.cs diff --git a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_CustomImplementation/Program.cs b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_CustomImplementation/Program.cs index a4e588f347..6beef64405 100644 --- a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_CustomImplementation/Program.cs +++ b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_CustomImplementation/Program.cs @@ -44,11 +44,19 @@ protected override async Task RunCoreAsync(IEnumerable responseMessages = CloneAndToUpperCase(messages, this.Name).ToList(); // Notify the thread of the input and output messages. - await typedThread.MessageStore.AddMessagesAsync(messages.Concat(responseMessages), cancellationToken); + var invokedContext = new ChatMessageStore.InvokedContext(messages, storeMessages) + { + ResponseMessages = responseMessages + }; + await typedThread.MessageStore.InvokedAsync(invokedContext, cancellationToken); return new AgentRunResponse { @@ -68,11 +76,19 @@ protected override async IAsyncEnumerable RunCoreStreami throw new ArgumentException($"The provided thread is not of type {nameof(CustomAgentThread)}.", nameof(thread)); } + // Get existing messages from the store + var invokingContext = new ChatMessageStore.InvokingContext(messages); + var storeMessages = await typedThread.MessageStore.InvokingAsync(invokingContext, cancellationToken); + // Clone the input messages and turn them into response messages with upper case text. List responseMessages = CloneAndToUpperCase(messages, this.Name).ToList(); // Notify the thread of the input and output messages. - await typedThread.MessageStore.AddMessagesAsync(messages.Concat(responseMessages), cancellationToken); + var invokedContext = new ChatMessageStore.InvokedContext(messages, storeMessages) + { + ResponseMessages = responseMessages + }; + await typedThread.MessageStore.InvokedAsync(invokedContext, cancellationToken); foreach (var message in responseMessages) { diff --git a/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step01_BasicTextRAG/Program.cs b/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step01_BasicTextRAG/Program.cs index 42015d87cd..9207a08182 100644 --- a/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step01_BasicTextRAG/Program.cs +++ b/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step01_BasicTextRAG/Program.cs @@ -62,7 +62,12 @@ .CreateAIAgent(new ChatClientAgentOptions { ChatOptions = new() { Instructions = "You are a helpful support specialist for Contoso Outdoors. Answer questions using the provided context and cite the source document when available." }, - AIContextProviderFactory = ctx => new TextSearchProvider(SearchAdapter, ctx.SerializedState, ctx.JsonSerializerOptions, textSearchOptions) + AIContextProviderFactory = ctx => new TextSearchProvider(SearchAdapter, ctx.SerializedState, ctx.JsonSerializerOptions, textSearchOptions), + // Since we are using ChatCompletion which stores chat history locally, we can also add a message removal policy + // that removes messages produced by the TextSearchProvider before they are added to the chat history, so that + // we don't bloat chat history with all the search result messages. + ChatMessageStoreFactory = ctx => new InMemoryChatMessageStore(ctx.SerializedState, ctx.JsonSerializerOptions) + .WithAIContextProviderMessageRemoval(), }); AgentThread thread = agent.GetNewThread(); diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step07_3rdPartyThreadStorage/Program.cs b/dotnet/samples/GettingStarted/Agents/Agent_Step07_3rdPartyThreadStorage/Program.cs index e9794e871a..280c84dc0d 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step07_3rdPartyThreadStorage/Program.cs +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step07_3rdPartyThreadStorage/Program.cs @@ -89,24 +89,7 @@ public VectorChatMessageStore(VectorStore vectorStore, JsonElement serializedSto public string? ThreadDbKey { get; private set; } - public override async Task AddMessagesAsync(IEnumerable messages, CancellationToken cancellationToken = default) - { - this.ThreadDbKey ??= Guid.NewGuid().ToString("N"); - - var collection = this._vectorStore.GetCollection("ChatHistory"); - await collection.EnsureCollectionExistsAsync(cancellationToken); - - await collection.UpsertAsync(messages.Select(x => new ChatHistoryItem() - { - Key = this.ThreadDbKey + x.MessageId, - Timestamp = DateTimeOffset.UtcNow, - ThreadId = this.ThreadDbKey, - SerializedMessage = JsonSerializer.Serialize(x), - MessageText = x.Text - }), cancellationToken); - } - - public override async Task> GetMessagesAsync(CancellationToken cancellationToken = default) + public override async ValueTask> InvokingAsync(InvokingContext context, CancellationToken cancellationToken = default) { var collection = this._vectorStore.GetCollection("ChatHistory"); await collection.EnsureCollectionExistsAsync(cancellationToken); @@ -124,6 +107,33 @@ public override async Task> GetMessagesAsync(Cancellati return messages; } + public override async ValueTask InvokedAsync(InvokedContext context, CancellationToken cancellationToken = default) + { + // Don't store messages if the request failed. + if (context.InvokeException is not null) + { + return; + } + + this.ThreadDbKey ??= Guid.NewGuid().ToString("N"); + + var collection = this._vectorStore.GetCollection("ChatHistory"); + await collection.EnsureCollectionExistsAsync(cancellationToken); + + // Add both request and response messages to the store + // Optionally messages produced by the AIContextProvider can also be persisted (not shown). + var allNewMessages = context.RequestMessages.Concat(context.AIContextProviderMessages ?? []).Concat(context.ResponseMessages ?? []); + + await collection.UpsertAsync(allNewMessages.Select(x => new ChatHistoryItem() + { + Key = this.ThreadDbKey + x.MessageId, + Timestamp = DateTimeOffset.UtcNow, + ThreadId = this.ThreadDbKey, + SerializedMessage = JsonSerializer.Serialize(x), + MessageText = x.Text + }), cancellationToken); + } + public override JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null) => // We have to serialize the thread id, so that on deserialization we can retrieve the messages using the same thread id. JsonSerializer.SerializeToElement(this.ThreadDbKey); diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/ChatMessageStore.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/ChatMessageStore.cs index 9f89031464..d28cd191b7 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/ChatMessageStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/ChatMessageStore.cs @@ -32,8 +32,9 @@ namespace Microsoft.Agents.AI; public abstract class ChatMessageStore { /// - /// Asynchronously retrieves all messages from the store that should be provided as context for the next agent invocation. + /// Called at the start of agent invocation to retrieve all messages from the store that should be provided as context for the next agent invocation. /// + /// 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 a collection of @@ -59,20 +60,19 @@ public abstract class ChatMessageStore /// and context management. /// /// - public abstract Task> GetMessagesAsync(CancellationToken cancellationToken = default); + public abstract ValueTask> InvokingAsync(InvokingContext context, CancellationToken cancellationToken = default); /// - /// Asynchronously adds new messages to the store. + /// Called at the end of the agent invocation to add new messages to the store. /// - /// The collection of chat messages to add to the store. + /// Contains the invocation context including request messages, response messages, and any exception that occurred. /// The to monitor for cancellation requests. The default is . /// A task that represents the asynchronous add operation. - /// is . /// /// /// Messages should be added in the order they were generated to maintain proper chronological sequence. /// The store is responsible for preserving message ordering and ensuring that subsequent calls to - /// return messages in the correct chronological order. + /// return messages in the correct chronological order. /// /// /// Implementations may perform additional processing during message addition, such as: @@ -83,8 +83,12 @@ public abstract class ChatMessageStore /// Updating indices or search capabilities /// /// + /// + /// This method is called regardless of whether the invocation succeeded or failed. + /// To check if the invocation was successful, inspect the property. + /// /// - public abstract Task AddMessagesAsync(IEnumerable messages, CancellationToken cancellationToken = default); + public abstract ValueTask InvokedAsync(InvokedContext context, CancellationToken cancellationToken = default); /// /// Serializes the current object's state to a using the specified serialization options. @@ -121,4 +125,100 @@ public abstract class ChatMessageStore /// public TService? GetService(object? serviceKey = null) => this.GetService(typeof(TService), serviceKey) is TService service ? service : default; + + /// + /// Contains the context information provided to . + /// + /// + /// This class provides context about the invocation before the messages are retrieved from the store, + /// including the new messages that will be used. Stores can use this information to determine what + /// messages should be retrieved for the invocation. + /// + public sealed class InvokingContext + { + /// + /// Initializes a new instance of the class with the specified request messages. + /// + /// The new messages to be used by the agent for this invocation. + /// is . + public InvokingContext(IEnumerable requestMessages) + { + this.RequestMessages = requestMessages ?? throw new ArgumentNullException(nameof(requestMessages)); + } + + /// + /// Gets the caller provided messages that will be used by the agent for this invocation. + /// + /// + /// A collection of instances representing new messages that were provided by the caller. + /// + public IEnumerable RequestMessages { get; } + } + + /// + /// Contains the context information provided to . + /// + /// + /// 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 sealed class InvokedContext + { + /// + /// Initializes a new instance of the class with the specified request messages. + /// + /// The caller provided messages that were used by the agent for this invocation. + /// The messages retrieved from the for this invocation. + /// is . + public InvokedContext(IEnumerable requestMessages, IEnumerable chatMessageStoreMessages) + { + this.RequestMessages = Throw.IfNull(requestMessages); + this.ChatMessageStoreMessages = chatMessageStoreMessages; + } + + /// + /// Gets the caller provided messages that were used by the agent for this invocation. + /// + /// + /// 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 messages retrieved from the for this invocation, if any. + /// + /// + /// A collection of instances that were retrieved from the , + /// and were used by the agent as part of the invocation. + /// + public IEnumerable ChatMessageStoreMessages { get; } + + /// + /// Gets or sets the messages provided by the for this invocation, if any. + /// + /// + /// A collection of instances that were provided by the , + /// and were used by the agent as part of the invocation. + /// + public IEnumerable? AIContextProviderMessages { get; set; } + + /// + /// 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; set; } + + /// + /// Gets the that was thrown during the invocation, if the invocation failed. + /// + /// + /// The exception that caused the invocation to fail, or if the invocation succeeded. + /// + public Exception? InvokeException { get; set; } + } } diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/ChatMessageStoreExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/ChatMessageStoreExtensions.cs new file mode 100644 index 0000000000..a205fc1d9e --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/ChatMessageStoreExtensions.cs @@ -0,0 +1,50 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI; + +/// +/// Contains extension methods for the class. +/// +public static class ChatMessageStoreExtensions +{ + /// + /// Adds message filtering to an existing store, so that messages passed to the store and messages produced by the store + /// can be filtered, updated or replaced. + /// + /// The store to add the message filter to. + /// An optional filter function to apply to messages produced by the store. If null, no filter is applied at this + /// stage. + /// An optional filter function to apply to the invoked context messages before they are passed to the store. If null, no + /// filter is applied at this stage. + /// The with filtering applied. + public static ChatMessageStore WithMessageFilters( + this ChatMessageStore store, + Func, IEnumerable>? invokingMessagesFilter = null, + Func? invokedMessagesFilter = null) + { + return new ChatMessageStoreMessageFilter( + innerChatMessageStore: store, + invokingMessagesFilter: invokingMessagesFilter, + invokedMessagesFilter: invokedMessagesFilter); + } + + /// + /// Decorates the provided chat message store so that it does not store messages produced by any . + /// + /// The store to add the message filter to. + /// A new instance that filters out messages so they do not get stored. + public static ChatMessageStore WithAIContextProviderMessageRemoval(this ChatMessageStore store) + { + return new ChatMessageStoreMessageFilter( + innerChatMessageStore: store, + invokedMessagesFilter: (ctx) => + { + ctx.AIContextProviderMessages = null; + return ctx; + }); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/ChatMessageStoreMessageFilter.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/ChatMessageStoreMessageFilter.cs new file mode 100644 index 0000000000..e58f233067 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/ChatMessageStoreMessageFilter.cs @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// A decorator that allows filtering the messages +/// passed into and out of an inner . +/// +public sealed class ChatMessageStoreMessageFilter : ChatMessageStore +{ + private readonly ChatMessageStore _innerChatMessageStore; + private readonly Func, IEnumerable>? _invokingMessagesFilter; + private readonly Func? _invokedMessagesFilter; + + /// + /// Initializes a new instance of the class. + /// + /// Use this constructor to customize how messages are filtered before and after invocation by + /// providing appropriate filter functions. If no filters are provided, the message store operates without + /// additional filtering. + /// The underlying chat message store to be wrapped. Cannot be null. + /// An optional filter function to apply to messages before they are invoked. If null, no filter is applied at this + /// stage. + /// An optional filter function to apply to the invocation context after messages have been invoked. If null, no + /// filter is applied at this stage. + /// Thrown if innerChatMessageStore is null. + public ChatMessageStoreMessageFilter( + ChatMessageStore innerChatMessageStore, + Func, IEnumerable>? invokingMessagesFilter = null, + Func? invokedMessagesFilter = null) + { + this._innerChatMessageStore = Throw.IfNull(innerChatMessageStore); + + if (invokingMessagesFilter == null && invokedMessagesFilter == null) + { + throw new ArgumentException("At least one filter function, invokingMessagesFilter or invokedMessagesFilter, must be provided."); + } + + this._invokingMessagesFilter = invokingMessagesFilter; + this._invokedMessagesFilter = invokedMessagesFilter; + } + + /// + public override async ValueTask> InvokingAsync(InvokingContext context, CancellationToken cancellationToken = default) + { + var messages = await this._innerChatMessageStore.InvokingAsync(context, cancellationToken).ConfigureAwait(false); + return this._invokingMessagesFilter != null ? this._invokingMessagesFilter(messages) : messages; + } + + /// + public override ValueTask InvokedAsync(InvokedContext context, CancellationToken cancellationToken = default) + { + if (this._invokedMessagesFilter != null) + { + context = this._invokedMessagesFilter(context); + } + + return this._innerChatMessageStore.InvokedAsync(context, cancellationToken); + } + + /// + public override JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null) + { + return this._innerChatMessageStore.Serialize(jsonSerializerOptions); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatMessageStore.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatMessageStore.cs index 79d303207c..f7f4522f8f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatMessageStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatMessageStore.cs @@ -134,27 +134,36 @@ public ChatMessage this[int index] } /// - public override async Task AddMessagesAsync(IEnumerable messages, CancellationToken cancellationToken = default) + public override async ValueTask> InvokingAsync(InvokingContext context, CancellationToken cancellationToken = default) { - _ = Throw.IfNull(messages); + _ = Throw.IfNull(context); - this._messages.AddRange(messages); - - if (this.ReducerTriggerEvent is ChatReducerTriggerEvent.AfterMessageAdded && this.ChatReducer is not null) + if (this.ReducerTriggerEvent is ChatReducerTriggerEvent.BeforeMessagesRetrieval && this.ChatReducer is not null) { this._messages = (await this.ChatReducer.ReduceAsync(this._messages, cancellationToken).ConfigureAwait(false)).ToList(); } + + return this._messages; } /// - public override async Task> GetMessagesAsync(CancellationToken cancellationToken = default) + public override async ValueTask InvokedAsync(InvokedContext context, CancellationToken cancellationToken = default) { - if (this.ReducerTriggerEvent is ChatReducerTriggerEvent.BeforeMessagesRetrieval && this.ChatReducer is not null) + _ = Throw.IfNull(context); + + if (context.InvokeException is not null) { - this._messages = (await this.ChatReducer.ReduceAsync(this._messages, cancellationToken).ConfigureAwait(false)).ToList(); + return; } - return this._messages; + // Add request, AI context provider, and response messages to the store + var allNewMessages = context.RequestMessages.Concat(context.AIContextProviderMessages ?? []).Concat(context.ResponseMessages ?? []); + this._messages.AddRange(allNewMessages); + + if (this.ReducerTriggerEvent is ChatReducerTriggerEvent.AfterMessageAdded && this.ChatReducer is not null) + { + this._messages = (await this.ChatReducer.ReduceAsync(this._messages, cancellationToken).ConfigureAwait(false)).ToList(); + } } /// @@ -221,7 +230,7 @@ public enum ChatReducerTriggerEvent { /// /// Trigger the reducer when a new message is added. - /// will only complete when reducer processing is done. + /// will only complete when reducer processing is done. /// AfterMessageAdded, diff --git a/dotnet/src/Microsoft.Agents.AI.CosmosNoSql/CosmosChatMessageStore.cs b/dotnet/src/Microsoft.Agents.AI.CosmosNoSql/CosmosChatMessageStore.cs index 03334d90f9..5c2c23ff9e 100644 --- a/dotnet/src/Microsoft.Agents.AI.CosmosNoSql/CosmosChatMessageStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.CosmosNoSql/CosmosChatMessageStore.cs @@ -287,7 +287,7 @@ public static CosmosChatMessageStore CreateFromSerializedState(CosmosClient cosm } /// - public override async Task> GetMessagesAsync(CancellationToken cancellationToken = default) + public override async ValueTask> InvokingAsync(InvokingContext context, CancellationToken cancellationToken = default) { #pragma warning disable CA1513 // Use ObjectDisposedException.ThrowIf - not available on all target frameworks if (this._disposed) @@ -347,11 +347,14 @@ public override async Task> GetMessagesAsync(Cancellati } /// - public override async Task AddMessagesAsync(IEnumerable messages, CancellationToken cancellationToken = default) + public override async ValueTask InvokedAsync(InvokedContext context, CancellationToken cancellationToken = default) { - if (messages is null) + Throw.IfNull(context); + + if (context.InvokeException is not null) { - throw new ArgumentNullException(nameof(messages)); + // Do not store messages if there was an exception during invocation + return; } #pragma warning disable CA1513 // Use ObjectDisposedException.ThrowIf - not available on all target frameworks @@ -361,7 +364,7 @@ public override async Task AddMessagesAsync(IEnumerable messages, C } #pragma warning restore CA1513 - var messageList = messages as IReadOnlyCollection ?? messages.ToList(); + var messageList = context.RequestMessages.Concat(context.AIContextProviderMessages ?? []).Concat(context.ResponseMessages ?? []).ToList(); if (messageList.Count == 0) { return; @@ -381,7 +384,7 @@ public override async Task AddMessagesAsync(IEnumerable messages, C /// /// Adds multiple messages using transactional batch operations for atomicity. /// - private async Task AddMessagesInBatchAsync(IReadOnlyCollection messages, CancellationToken cancellationToken) + private async Task AddMessagesInBatchAsync(List messages, CancellationToken cancellationToken) { var currentTimestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostAgent.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostAgent.cs index 7c0479b85e..8e8012f5bb 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostAgent.cs @@ -66,7 +66,7 @@ private async ValueTask ValidateWorkflowAsync() public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null) => new WorkflowThread(this._workflow, serializedThread, this._executionEnvironment, this._checkpointManager, jsonSerializerOptions); - private async ValueTask UpdateThreadAsync(IEnumerable messages, AgentThread? thread = null, CancellationToken cancellationToken = default) + private ValueTask UpdateThreadAsync(IEnumerable messages, AgentThread? thread = null, CancellationToken cancellationToken = default) { thread ??= this.GetNewThread(); @@ -75,8 +75,10 @@ private async ValueTask UpdateThreadAsync(IEnumerable(workflowThread); } protected override async diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowMessageStore.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowMessageStore.cs index 39c83bcadf..87cef04e76 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowMessageStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowMessageStore.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System.Collections.Generic; +using System.Linq; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -45,14 +46,21 @@ internal sealed class StoreState internal void AddMessages(params IEnumerable messages) => this._chatMessages.AddRange(messages); - public override Task AddMessagesAsync(IEnumerable messages, CancellationToken cancellationToken = default) + public override ValueTask> InvokingAsync(InvokingContext context, CancellationToken cancellationToken = default) + => new(this._chatMessages.AsReadOnly()); + + public override ValueTask InvokedAsync(InvokedContext context, CancellationToken cancellationToken = default) { - this._chatMessages.AddRange(messages); + if (context.InvokeException is not null) + { + return default; + } - return Task.CompletedTask; - } + var allNewMessages = context.RequestMessages.Concat(context.AIContextProviderMessages ?? []).Concat(context.ResponseMessages ?? []); + this._chatMessages.AddRange(allNewMessages); - public override Task> GetMessagesAsync(CancellationToken cancellationToken = default) => Task.FromResult>(this._chatMessages.AsReadOnly()); + return default; + } public IEnumerable GetFromBookmark() { diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs index f4a7fcd9c2..9c5858b8e2 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs @@ -201,7 +201,7 @@ protected override async IAsyncEnumerable RunCoreStreami { var inputMessages = Throw.IfNull(messages) as IReadOnlyCollection ?? messages.ToList(); - (ChatClientAgentThread safeThread, ChatOptions? chatOptions, List inputMessagesForChatClient, IList? aiContextProviderMessages) = + (ChatClientAgentThread safeThread, ChatOptions? chatOptions, List inputMessagesForChatClient, IList? aiContextProviderMessages, IList? chatMessageStoreMessages) = await this.PrepareThreadAndMessagesAsync(thread, inputMessages, options, cancellationToken).ConfigureAwait(false); ValidateStreamResumptionAllowed(chatOptions?.ContinuationToken, safeThread); @@ -225,6 +225,7 @@ protected override async IAsyncEnumerable RunCoreStreami } catch (Exception ex) { + await NotifyMessageStoreOfFailureAsync(safeThread, ex, inputMessages, chatMessageStoreMessages, aiContextProviderMessages, cancellationToken).ConfigureAwait(false); await NotifyAIContextProviderOfFailureAsync(safeThread, ex, inputMessages, aiContextProviderMessages, cancellationToken).ConfigureAwait(false); throw; } @@ -239,6 +240,7 @@ protected override async IAsyncEnumerable RunCoreStreami } catch (Exception ex) { + await NotifyMessageStoreOfFailureAsync(safeThread, ex, inputMessages, chatMessageStoreMessages, aiContextProviderMessages, cancellationToken).ConfigureAwait(false); await NotifyAIContextProviderOfFailureAsync(safeThread, ex, inputMessages, aiContextProviderMessages, cancellationToken).ConfigureAwait(false); throw; } @@ -260,6 +262,7 @@ protected override async IAsyncEnumerable RunCoreStreami } catch (Exception ex) { + await NotifyMessageStoreOfFailureAsync(safeThread, ex, inputMessages, chatMessageStoreMessages, aiContextProviderMessages, cancellationToken).ConfigureAwait(false); await NotifyAIContextProviderOfFailureAsync(safeThread, ex, inputMessages, aiContextProviderMessages, cancellationToken).ConfigureAwait(false); throw; } @@ -272,7 +275,7 @@ protected override async IAsyncEnumerable RunCoreStreami 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 NotifyMessageStoreOfNewMessagesAsync(safeThread, inputMessages.Concat(aiContextProviderMessages ?? []).Concat(chatResponse.Messages), cancellationToken).ConfigureAwait(false); + await NotifyMessageStoreOfNewMessagesAsync(safeThread, inputMessages, chatMessageStoreMessages, aiContextProviderMessages, chatResponse.Messages, cancellationToken).ConfigureAwait(false); // Notify the AIContextProvider of all new messages. await NotifyAIContextProviderOfSuccessAsync(safeThread, inputMessages, aiContextProviderMessages, chatResponse.Messages, cancellationToken).ConfigureAwait(false); @@ -379,7 +382,7 @@ private async Task RunCoreAsync ?? messages.ToList(); - (ChatClientAgentThread safeThread, ChatOptions? chatOptions, List inputMessagesForChatClient, IList? aiContextProviderMessages) = + (ChatClientAgentThread safeThread, ChatOptions? chatOptions, List inputMessagesForChatClient, IList? aiContextProviderMessages, IList? chatMessageStoreMessages) = await this.PrepareThreadAndMessagesAsync(thread, inputMessages, options, cancellationToken).ConfigureAwait(false); var chatClient = this.ChatClient; @@ -398,6 +401,7 @@ private async Task RunCoreAsync RunCoreAsyncOptional 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 InputMessagesForChatClient, IList? AIContextProviderMessages)> PrepareThreadAndMessagesAsync( + private async Task + <( + ChatClientAgentThread AgentThread, + ChatOptions? ChatOptions, + List InputMessagesForChatClient, + IList? AIContextProviderMessages, + IList? ChatMessageStoreMessages + )> PrepareThreadAndMessagesAsync( AgentThread? thread, IEnumerable inputMessages, AgentRunOptions? runOptions, @@ -637,6 +648,7 @@ await thread.AIContextProvider.InvokedAsync(new(inputMessages, aiContextProvider List inputMessagesForChatClient = []; IList? aiContextProviderMessages = null; + IList? chatMessageStoreMessages = null; // Populate the thread messages only if we are not continuing an existing response as it's not allowed if (chatOptions?.ContinuationToken is null) @@ -644,7 +656,10 @@ await thread.AIContextProvider.InvokedAsync(new(inputMessages, aiContextProvider // Add any existing messages from the thread to the messages to be sent to the chat client. if (typedThread.MessageStore is not null) { - inputMessagesForChatClient.AddRange(await typedThread.MessageStore.GetMessagesAsync(cancellationToken).ConfigureAwait(false)); + var invokingContext = new ChatMessageStore.InvokingContext(inputMessages); + var storeMessages = await typedThread.MessageStore.InvokingAsync(invokingContext, cancellationToken).ConfigureAwait(false); + inputMessagesForChatClient.AddRange(storeMessages); + chatMessageStoreMessages = storeMessages as IList ?? storeMessages.ToList(); } // If we have an AIContextProvider, we should get context from it, and update our @@ -698,7 +713,7 @@ await thread.AIContextProvider.InvokedAsync(new(inputMessages, aiContextProvider chatOptions.ConversationId = typedThread.ConversationId; } - return (typedThread, chatOptions, inputMessagesForChatClient, aiContextProviderMessages); + return (typedThread, chatOptions, inputMessagesForChatClient, aiContextProviderMessages, chatMessageStoreMessages); } private void UpdateThreadWithTypeAndConversationId(ChatClientAgentThread thread, string? responseConversationId) @@ -725,7 +740,13 @@ private void UpdateThreadWithTypeAndConversationId(ChatClientAgentThread thread, } } - private static Task NotifyMessageStoreOfNewMessagesAsync(ChatClientAgentThread thread, IEnumerable newMessages, CancellationToken cancellationToken) + private static Task NotifyMessageStoreOfFailureAsync( + ChatClientAgentThread thread, + Exception ex, + IEnumerable requestMessages, + IEnumerable? chatMessageStoreMessages, + IEnumerable? aiContextProviderMessages, + CancellationToken cancellationToken) { var messageStore = thread.MessageStore; @@ -733,7 +754,38 @@ private static Task NotifyMessageStoreOfNewMessagesAsync(ChatClientAgentThread t // If we don't have one, it means that the chat history is service managed and the underlying service is responsible for storing messages. if (messageStore is not null) { - return messageStore.AddMessagesAsync(newMessages, cancellationToken); + var invokedContext = new ChatMessageStore.InvokedContext(requestMessages, chatMessageStoreMessages!) + { + AIContextProviderMessages = aiContextProviderMessages, + InvokeException = ex + }; + + return messageStore.InvokedAsync(invokedContext, cancellationToken).AsTask(); + } + + return Task.CompletedTask; + } + + private static Task NotifyMessageStoreOfNewMessagesAsync( + ChatClientAgentThread thread, + IEnumerable requestMessages, + IEnumerable? chatMessageStoreMessages, + IEnumerable? aiContextProviderMessages, + IEnumerable responseMessages, + CancellationToken cancellationToken) + { + var messageStore = thread.MessageStore; + + // Only notify the message store if we have one. + // If we don't have one, it means that the chat history is service managed and the underlying service is responsible for storing messages. + if (messageStore is not null) + { + var invokedContext = new ChatMessageStore.InvokedContext(requestMessages, chatMessageStoreMessages!) + { + AIContextProviderMessages = aiContextProviderMessages, + ResponseMessages = responseMessages + }; + return messageStore.InvokedAsync(invokedContext, cancellationToken).AsTask(); } return Task.CompletedTask; diff --git a/dotnet/tests/AnthropicChatCompletion.IntegrationTests/AnthropicChatCompletionFixture.cs b/dotnet/tests/AnthropicChatCompletion.IntegrationTests/AnthropicChatCompletionFixture.cs index 72c0b14ae2..2bec0b366e 100644 --- a/dotnet/tests/AnthropicChatCompletion.IntegrationTests/AnthropicChatCompletionFixture.cs +++ b/dotnet/tests/AnthropicChatCompletion.IntegrationTests/AnthropicChatCompletionFixture.cs @@ -39,7 +39,12 @@ public async Task> GetChatHistoryAsync(AgentThread thread) { var typedThread = (ChatClientAgentThread)thread; - return typedThread.MessageStore is null ? [] : (await typedThread.MessageStore.GetMessagesAsync()).ToList(); + if (typedThread.MessageStore is null) + { + return []; + } + + return (await typedThread.MessageStore.InvokingAsync(new([]))).ToList(); } public Task CreateChatClientAgentAsync( diff --git a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientFixture.cs b/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientFixture.cs index 883b317f5e..ddb015eb17 100644 --- a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientFixture.cs +++ b/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientFixture.cs @@ -48,7 +48,12 @@ public async Task> GetChatHistoryAsync(AgentThread thread) return await this.GetChatHistoryFromResponsesChainAsync(chatClientThread.ConversationId); } - return chatClientThread.MessageStore is null ? [] : (await chatClientThread.MessageStore.GetMessagesAsync()).ToList(); + if (chatClientThread.MessageStore is null) + { + return []; + } + + return (await chatClientThread.MessageStore.InvokingAsync(new([]))).ToList(); } private async Task> GetChatHistoryFromResponsesChainAsync(string conversationId) diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/ChatMessageStoreMessageFilterTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/ChatMessageStoreMessageFilterTests.cs new file mode 100644 index 0000000000..ab10c377ae --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/ChatMessageStoreMessageFilterTests.cs @@ -0,0 +1,205 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Moq; + +namespace Microsoft.Agents.AI.Abstractions.UnitTests; + +/// +/// Contains tests for the class. +/// +public sealed class ChatMessageStoreMessageFilterTests +{ + [Fact] + public void Constructor_WithNullInnerStore_ThrowsArgumentNullException() + { + // Arrange, Act & Assert + Assert.Throws(() => new ChatMessageStoreMessageFilter(null!)); + } + + [Fact] + public void Constructor_WithOnlyInnerStore_Throws() + { + // Arrange + var innerStoreMock = new Mock(); + + // Act & Assert + Assert.Throws(() => new ChatMessageStoreMessageFilter(innerStoreMock.Object)); + } + + [Fact] + public void Constructor_WithAllParameters_CreatesInstance() + { + // Arrange + var innerStoreMock = new Mock(); + + IEnumerable InvokingFilter(IEnumerable msgs) => msgs; + ChatMessageStore.InvokedContext InvokedFilter(ChatMessageStore.InvokedContext ctx) => ctx; + + // Act + var filter = new ChatMessageStoreMessageFilter(innerStoreMock.Object, InvokingFilter, InvokedFilter); + + // Assert + Assert.NotNull(filter); + } + + [Fact] + public async Task InvokingAsync_WithNoOpFilters_ReturnsInnerStoreMessagesAsync() + { + // Arrange + var innerStoreMock = new Mock(); + var expectedMessages = new List + { + new(ChatRole.User, "Hello"), + new(ChatRole.Assistant, "Hi there!") + }; + var context = new ChatMessageStore.InvokingContext([new ChatMessage(ChatRole.User, "Test")]); + + innerStoreMock + .Setup(s => s.InvokingAsync(context, It.IsAny())) + .ReturnsAsync(expectedMessages); + + var filter = new ChatMessageStoreMessageFilter(innerStoreMock.Object, x => x, x => x); + + // Act + var result = (await filter.InvokingAsync(context, CancellationToken.None)).ToList(); + + // Assert + Assert.Equal(2, result.Count); + Assert.Equal("Hello", result[0].Text); + Assert.Equal("Hi there!", result[1].Text); + innerStoreMock.Verify(s => s.InvokingAsync(context, It.IsAny()), Times.Once); + } + + [Fact] + public async Task InvokingAsync_WithInvokingFilter_AppliesFilterAsync() + { + // Arrange + var innerStoreMock = new Mock(); + var innerMessages = new List + { + new(ChatRole.User, "Hello"), + new(ChatRole.Assistant, "Hi there!"), + new(ChatRole.User, "How are you?") + }; + var context = new ChatMessageStore.InvokingContext([new ChatMessage(ChatRole.User, "Test")]); + + innerStoreMock + .Setup(s => s.InvokingAsync(context, It.IsAny())) + .ReturnsAsync(innerMessages); + + // Filter to only user messages + IEnumerable InvokingFilter(IEnumerable msgs) => msgs.Where(m => m.Role == ChatRole.User); + + var filter = new ChatMessageStoreMessageFilter(innerStoreMock.Object, InvokingFilter); + + // Act + var result = (await filter.InvokingAsync(context, CancellationToken.None)).ToList(); + + // Assert + Assert.Equal(2, result.Count); + Assert.All(result, msg => Assert.Equal(ChatRole.User, msg.Role)); + innerStoreMock.Verify(s => s.InvokingAsync(context, It.IsAny()), Times.Once); + } + + [Fact] + public async Task InvokingAsync_WithInvokingFilter_CanModifyMessagesAsync() + { + // Arrange + var innerStoreMock = new Mock(); + var innerMessages = new List + { + new(ChatRole.User, "Hello"), + new(ChatRole.Assistant, "Hi there!") + }; + var context = new ChatMessageStore.InvokingContext([new ChatMessage(ChatRole.User, "Test")]); + + innerStoreMock + .Setup(s => s.InvokingAsync(context, It.IsAny())) + .ReturnsAsync(innerMessages); + + // Filter that transforms messages + IEnumerable InvokingFilter(IEnumerable msgs) => + msgs.Select(m => new ChatMessage(m.Role, $"[FILTERED] {m.Text}")); + + var filter = new ChatMessageStoreMessageFilter(innerStoreMock.Object, InvokingFilter); + + // Act + var result = (await filter.InvokingAsync(context, CancellationToken.None)).ToList(); + + // Assert + Assert.Equal(2, result.Count); + Assert.Equal("[FILTERED] Hello", result[0].Text); + Assert.Equal("[FILTERED] Hi there!", result[1].Text); + } + + [Fact] + public async Task InvokedAsync_WithInvokedFilter_AppliesFilterAsync() + { + // Arrange + var innerStoreMock = new Mock(); + var requestMessages = new List { new(ChatRole.User, "Hello") }; + var chatMessageStoreMessages = new List { new(ChatRole.System, "System") }; + var responseMessages = new List { new(ChatRole.Assistant, "Response") }; + var context = new ChatMessageStore.InvokedContext(requestMessages, chatMessageStoreMessages) + { + ResponseMessages = responseMessages + }; + + ChatMessageStore.InvokedContext? capturedContext = null; + innerStoreMock + .Setup(s => s.InvokedAsync(It.IsAny(), It.IsAny())) + .Callback((ctx, ct) => capturedContext = ctx) + .Returns(default(ValueTask)); + + // Filter that modifies the context + ChatMessageStore.InvokedContext InvokedFilter(ChatMessageStore.InvokedContext ctx) + { + var modifiedRequestMessages = ctx.RequestMessages.Select(m => new ChatMessage(m.Role, $"[FILTERED] {m.Text}")).ToList(); + return new ChatMessageStore.InvokedContext(modifiedRequestMessages, ctx.ChatMessageStoreMessages) + { + ResponseMessages = ctx.ResponseMessages, + AIContextProviderMessages = ctx.AIContextProviderMessages, + InvokeException = ctx.InvokeException + }; + } + + var filter = new ChatMessageStoreMessageFilter(innerStoreMock.Object, invokedMessagesFilter: InvokedFilter); + + // Act + await filter.InvokedAsync(context, CancellationToken.None); + + // Assert + Assert.NotNull(capturedContext); + Assert.Single(capturedContext.RequestMessages); + Assert.Equal("[FILTERED] Hello", capturedContext.RequestMessages.First().Text); + innerStoreMock.Verify(s => s.InvokedAsync(It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public void Serialize_DelegatesToInnerStore() + { + // Arrange + var innerStoreMock = new Mock(); + var expectedJson = JsonSerializer.SerializeToElement("data", TestJsonSerializerContext.Default.String); + + innerStoreMock + .Setup(s => s.Serialize(It.IsAny())) + .Returns(expectedJson); + + var filter = new ChatMessageStoreMessageFilter(innerStoreMock.Object, x => x, x => x); + + // Act + var result = filter.Serialize(); + + // Assert + Assert.Equal(expectedJson.GetRawText(), result.GetRawText()); + innerStoreMock.Verify(s => s.Serialize(null), Times.Once); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/ChatMessageStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/ChatMessageStoreTests.cs index 4100b20f5a..883941458c 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/ChatMessageStoreTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/ChatMessageStoreTests.cs @@ -78,11 +78,11 @@ public void GetService_Generic_ReturnsNullForUnrelatedType() private sealed class TestChatMessageStore : ChatMessageStore { - public override Task> GetMessagesAsync(CancellationToken cancellationToken = default) - => Task.FromResult>([]); + public override ValueTask> InvokingAsync(InvokingContext context, CancellationToken cancellationToken = default) + => new(Array.Empty()); - public override Task AddMessagesAsync(IEnumerable messages, CancellationToken cancellationToken = default) - => Task.CompletedTask; + public override ValueTask InvokedAsync(InvokedContext context, CancellationToken cancellationToken = default) + => default; public override JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null) => default; diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/InMemoryChatMessageStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/InMemoryChatMessageStoreTests.cs index 824fb62f6d..43bfacca79 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/InMemoryChatMessageStoreTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/InMemoryChatMessageStoreTests.cs @@ -47,34 +47,54 @@ public void Constructor_Arguments_SetOnPropertiesCorrectly() } [Fact] - public async Task AddMessagesAsyncAddsMessagesAndReturnsNullThreadIdAsync() + public async Task InvokedAsyncAddsMessagesAsync() { - var store = new InMemoryChatMessageStore(); - var messages = new List + var requestMessages = new List + { + new(ChatRole.User, "Hello") + }; + var responseMessages = new List { - new(ChatRole.User, "Hello"), new(ChatRole.Assistant, "Hi there!") }; + var messageStoreMessages = new List() + { + new(ChatRole.System, "original instructions") + }; + var aiContextProviderMessages = new List() + { + new(ChatRole.System, "additional context") + }; - await store.AddMessagesAsync(messages, CancellationToken.None); + var store = new InMemoryChatMessageStore(); + store.Add(messageStoreMessages[0]); + var context = new ChatMessageStore.InvokedContext(requestMessages, messageStoreMessages) + { + AIContextProviderMessages = aiContextProviderMessages, + ResponseMessages = responseMessages + }; + await store.InvokedAsync(context, CancellationToken.None); - Assert.Equal(2, store.Count); - Assert.Equal("Hello", store[0].Text); - Assert.Equal("Hi there!", store[1].Text); + Assert.Equal(4, store.Count); + Assert.Equal("original instructions", store[0].Text); + Assert.Equal("Hello", store[1].Text); + Assert.Equal("additional context", store[2].Text); + Assert.Equal("Hi there!", store[3].Text); } [Fact] - public async Task AddMessagesAsyncWithEmptyDoesNotFailAsync() + public async Task InvokedAsyncWithEmptyDoesNotFailAsync() { var store = new InMemoryChatMessageStore(); - await store.AddMessagesAsync([], CancellationToken.None); + var context = new ChatMessageStore.InvokedContext([], []); + await store.InvokedAsync(context, CancellationToken.None); Assert.Empty(store); } [Fact] - public async Task GetMessagesAsyncReturnsAllMessagesAsync() + public async Task InvokingAsyncReturnsAllMessagesAsync() { var store = new InMemoryChatMessageStore { @@ -82,7 +102,8 @@ public async Task GetMessagesAsyncReturnsAllMessagesAsync() new ChatMessage(ChatRole.Assistant, "Test2") }; - var result = (await store.GetMessagesAsync(CancellationToken.None)).ToList(); + var context = new ChatMessageStore.InvokingContext([]); + var result = (await store.InvokingAsync(context, CancellationToken.None)).ToList(); Assert.Equal(2, result.Count); Assert.Contains(result, m => m.Text == "Test1"); @@ -157,24 +178,25 @@ public async Task SerializeAndDeserializeWorksWithExperimentalContentTypesAsync( } [Fact] - public async Task AddMessagesAsyncWithEmptyMessagesDoesNotChangeStoreAsync() + public async Task InvokedAsyncWithEmptyMessagesDoesNotChangeStoreAsync() { var store = new InMemoryChatMessageStore(); var messages = new List(); - await store.AddMessagesAsync(messages, CancellationToken.None); + var context = new ChatMessageStore.InvokedContext(messages, []); + await store.InvokedAsync(context, CancellationToken.None); Assert.Empty(store); } [Fact] - public async Task AddMessagesAsync_WithNullMessages_ThrowsArgumentNullExceptionAsync() + public async Task InvokedAsync_WithNullContext_ThrowsArgumentNullExceptionAsync() { // Arrange var store = new InMemoryChatMessageStore(); // Act & Assert - await Assert.ThrowsAsync(() => store.AddMessagesAsync(null!, CancellationToken.None)); + await Assert.ThrowsAsync(() => store.InvokedAsync(null!, CancellationToken.None).AsTask()); } [Fact] @@ -498,7 +520,8 @@ public async Task AddMessagesAsync_WithReducer_AfterMessageAdded_InvokesReducerA var store = new InMemoryChatMessageStore(reducerMock.Object, InMemoryChatMessageStore.ChatReducerTriggerEvent.AfterMessageAdded); // Act - await store.AddMessagesAsync(originalMessages, CancellationToken.None); + var context = new ChatMessageStore.InvokedContext(originalMessages, []); + await store.InvokedAsync(context, CancellationToken.None); // Assert Assert.Single(store); @@ -526,10 +549,15 @@ public async Task GetMessagesAsync_WithReducer_BeforeMessagesRetrieval_InvokesRe .ReturnsAsync(reducedMessages); var store = new InMemoryChatMessageStore(reducerMock.Object, InMemoryChatMessageStore.ChatReducerTriggerEvent.BeforeMessagesRetrieval); - await store.AddMessagesAsync(originalMessages, CancellationToken.None); + // Add messages directly to the store for this test + foreach (var msg in originalMessages) + { + store.Add(msg); + } // Act - var result = (await store.GetMessagesAsync(CancellationToken.None)).ToList(); + var invokingContext = new ChatMessageStore.InvokingContext(Array.Empty()); + var result = (await store.InvokingAsync(invokingContext, CancellationToken.None)).ToList(); // Assert Assert.Single(result); @@ -551,7 +579,8 @@ public async Task AddMessagesAsync_WithReducer_ButWrongTrigger_DoesNotInvokeRedu var store = new InMemoryChatMessageStore(reducerMock.Object, InMemoryChatMessageStore.ChatReducerTriggerEvent.BeforeMessagesRetrieval); // Act - await store.AddMessagesAsync(originalMessages, CancellationToken.None); + var context = new ChatMessageStore.InvokedContext(originalMessages, []); + await store.InvokedAsync(context, CancellationToken.None); // Assert Assert.Single(store); @@ -576,7 +605,8 @@ public async Task GetMessagesAsync_WithReducer_ButWrongTrigger_DoesNotInvokeRedu }; // Act - var result = (await store.GetMessagesAsync(CancellationToken.None)).ToList(); + var invokingContext = new ChatMessageStore.InvokingContext(Array.Empty()); + var result = (await store.InvokingAsync(invokingContext, CancellationToken.None)).ToList(); // Assert Assert.Single(result); diff --git a/dotnet/tests/Microsoft.Agents.AI.CosmosNoSql.UnitTests/CosmosChatMessageStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.CosmosNoSql.UnitTests/CosmosChatMessageStoreTests.cs index 3dbd3ec367..9410e68f1b 100644 --- a/dotnet/tests/Microsoft.Agents.AI.CosmosNoSql.UnitTests/CosmosChatMessageStoreTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.CosmosNoSql.UnitTests/CosmosChatMessageStoreTests.cs @@ -202,11 +202,11 @@ public void Constructor_WithEmptyConversationId_ShouldThrowArgumentException() #endregion - #region AddMessagesAsync Tests + #region InvokedAsync Tests [SkippableFact] [Trait("Category", "CosmosDB")] - public async Task AddMessagesAsync_WithSingleMessage_ShouldAddMessageAsync() + public async Task InvokedAsync_WithSingleMessage_ShouldAddMessageAsync() { // Arrange this.SkipIfEmulatorNotAvailable(); @@ -214,14 +214,20 @@ public async Task AddMessagesAsync_WithSingleMessage_ShouldAddMessageAsync() using var store = new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, TestContainerId, conversationId); var message = new ChatMessage(ChatRole.User, "Hello, world!"); + var context = new ChatMessageStore.InvokedContext([message], []) + { + ResponseMessages = [] + }; + // Act - await store.AddMessagesAsync([message]); + await store.InvokedAsync(context); // Wait a moment for eventual consistency await Task.Delay(100); // Assert - var messages = await store.GetMessagesAsync(); + var invokingContext = new ChatMessageStore.InvokingContext([]); + var messages = await store.InvokingAsync(invokingContext); var messageList = messages.ToList(); // Simple assertion - if this fails, we know the deserialization is the issue @@ -256,7 +262,7 @@ public async Task AddMessagesAsync_WithSingleMessage_ShouldAddMessageAsync() } string rawJson = rawResults.Count > 0 ? Newtonsoft.Json.JsonConvert.SerializeObject(rawResults[0], Newtonsoft.Json.Formatting.Indented) : "null"; - Assert.Fail($"GetMessagesAsync returned 0 messages, but direct count query found {count} items for conversation {conversationId}. Raw document: {rawJson}"); + Assert.Fail($"InvokingAsync returned 0 messages, but direct count query found {count} items for conversation {conversationId}. Raw document: {rawJson}"); } Assert.Single(messageList); @@ -266,45 +272,63 @@ public async Task AddMessagesAsync_WithSingleMessage_ShouldAddMessageAsync() [SkippableFact] [Trait("Category", "CosmosDB")] - public async Task AddMessagesAsync_WithMultipleMessages_ShouldAddAllMessagesAsync() + public async Task InvokedAsync_WithMultipleMessages_ShouldAddAllMessagesAsync() { // Arrange this.SkipIfEmulatorNotAvailable(); var conversationId = Guid.NewGuid().ToString(); using var store = new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, TestContainerId, conversationId); - var messages = new[] + var requestMessages = new[] { new ChatMessage(ChatRole.User, "First message"), new ChatMessage(ChatRole.Assistant, "Second message"), new ChatMessage(ChatRole.User, "Third message") }; + var aiContextProviderMessages = new[] + { + new ChatMessage(ChatRole.System, "System context message") + }; + var responseMessages = new[] + { + new ChatMessage(ChatRole.Assistant, "Response message") + }; + + var context = new ChatMessageStore.InvokedContext(requestMessages, []) + { + AIContextProviderMessages = aiContextProviderMessages, + ResponseMessages = responseMessages + }; // Act - await store.AddMessagesAsync(messages); + await store.InvokedAsync(context); // Assert - var retrievedMessages = await store.GetMessagesAsync(); + var invokingContext = new ChatMessageStore.InvokingContext([]); + var retrievedMessages = await store.InvokingAsync(invokingContext); var messageList = retrievedMessages.ToList(); - Assert.Equal(3, messageList.Count); + Assert.Equal(5, messageList.Count); Assert.Equal("First message", messageList[0].Text); Assert.Equal("Second message", messageList[1].Text); Assert.Equal("Third message", messageList[2].Text); + Assert.Equal("System context message", messageList[3].Text); + Assert.Equal("Response message", messageList[4].Text); } #endregion - #region GetMessagesAsync Tests + #region InvokingAsync Tests [SkippableFact] [Trait("Category", "CosmosDB")] - public async Task GetMessagesAsync_WithNoMessages_ShouldReturnEmptyAsync() + public async Task InvokingAsync_WithNoMessages_ShouldReturnEmptyAsync() { // Arrange this.SkipIfEmulatorNotAvailable(); using var store = new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, TestContainerId, Guid.NewGuid().ToString()); // Act - var messages = await store.GetMessagesAsync(); + var invokingContext = new ChatMessageStore.InvokingContext([]); + var messages = await store.InvokingAsync(invokingContext); // Assert Assert.Empty(messages); @@ -312,7 +336,7 @@ public async Task GetMessagesAsync_WithNoMessages_ShouldReturnEmptyAsync() [SkippableFact] [Trait("Category", "CosmosDB")] - public async Task GetMessagesAsync_WithConversationIsolation_ShouldOnlyReturnMessagesForConversationAsync() + public async Task InvokingAsync_WithConversationIsolation_ShouldOnlyReturnMessagesForConversationAsync() { // Arrange this.SkipIfEmulatorNotAvailable(); @@ -322,12 +346,18 @@ public async Task GetMessagesAsync_WithConversationIsolation_ShouldOnlyReturnMes using var store1 = new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, TestContainerId, conversation1); using var store2 = new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, TestContainerId, conversation2); - await store1.AddMessagesAsync([new ChatMessage(ChatRole.User, "Message for conversation 1")]); - await store2.AddMessagesAsync([new ChatMessage(ChatRole.User, "Message for conversation 2")]); + var context1 = new ChatMessageStore.InvokedContext([new ChatMessage(ChatRole.User, "Message for conversation 1")], []); + var context2 = new ChatMessageStore.InvokedContext([new ChatMessage(ChatRole.User, "Message for conversation 2")], []); + + await store1.InvokedAsync(context1); + await store2.InvokedAsync(context2); // Act - var messages1 = await store1.GetMessagesAsync(); - var messages2 = await store2.GetMessagesAsync(); + var invokingContext1 = new ChatMessageStore.InvokingContext([]); + var invokingContext2 = new ChatMessageStore.InvokingContext([]); + + var messages1 = await store1.InvokingAsync(invokingContext1); + var messages2 = await store2.InvokingAsync(invokingContext2); // Assert var messageList1 = messages1.ToList(); @@ -361,16 +391,18 @@ public async Task FullWorkflow_AddAndGet_ShouldWorkCorrectlyAsync() }; // Act 1: Add messages - await originalStore.AddMessagesAsync(messages); + var invokedContext = new ChatMessageStore.InvokedContext(messages, []); + await originalStore.InvokedAsync(invokedContext); // Act 2: Verify messages were added - var retrievedMessages = await originalStore.GetMessagesAsync(); + var invokingContext = new ChatMessageStore.InvokingContext([]); + var retrievedMessages = await originalStore.InvokingAsync(invokingContext); var retrievedList = retrievedMessages.ToList(); Assert.Equal(5, retrievedList.Count); // Act 3: Create new store instance for same conversation (test persistence) using var newStore = new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, TestContainerId, conversationId); - var persistedMessages = await newStore.GetMessagesAsync(); + var persistedMessages = await newStore.InvokingAsync(invokingContext); var persistedList = persistedMessages.ToList(); // Assert final state @@ -502,7 +534,7 @@ public void Constructor_WithHierarchicalWhitespaceSessionId_ShouldThrowArgumentE [SkippableFact] [Trait("Category", "CosmosDB")] - public async Task AddMessagesAsync_WithHierarchicalPartitioning_ShouldAddMessageWithMetadataAsync() + public async Task InvokedAsync_WithHierarchicalPartitioning_ShouldAddMessageWithMetadataAsync() { // Arrange this.SkipIfEmulatorNotAvailable(); @@ -513,14 +545,17 @@ public async Task AddMessagesAsync_WithHierarchicalPartitioning_ShouldAddMessage using var store = new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, HierarchicalTestContainerId, TenantId, UserId, SessionId); var message = new ChatMessage(ChatRole.User, "Hello from hierarchical partitioning!"); + var context = new ChatMessageStore.InvokedContext([message], []); + // Act - await store.AddMessagesAsync([message]); + await store.InvokedAsync(context); // Wait a moment for eventual consistency await Task.Delay(100); // Assert - var messages = await store.GetMessagesAsync(); + var invokingContext = new ChatMessageStore.InvokingContext([]); + var messages = await store.InvokingAsync(invokingContext); var messageList = messages.ToList(); Assert.Single(messageList); @@ -551,7 +586,7 @@ public async Task AddMessagesAsync_WithHierarchicalPartitioning_ShouldAddMessage [SkippableFact] [Trait("Category", "CosmosDB")] - public async Task AddMessagesAsync_WithHierarchicalMultipleMessages_ShouldAddAllMessagesAsync() + public async Task InvokedAsync_WithHierarchicalMultipleMessages_ShouldAddAllMessagesAsync() { // Arrange this.SkipIfEmulatorNotAvailable(); @@ -567,14 +602,17 @@ public async Task AddMessagesAsync_WithHierarchicalMultipleMessages_ShouldAddAll new ChatMessage(ChatRole.User, "Third hierarchical message") }; + var context = new ChatMessageStore.InvokedContext(messages, []); + // Act - await store.AddMessagesAsync(messages); + await store.InvokedAsync(context); // Wait a moment for eventual consistency await Task.Delay(100); // Assert - var retrievedMessages = await store.GetMessagesAsync(); + var invokingContext = new ChatMessageStore.InvokingContext([]); + var retrievedMessages = await store.InvokingAsync(invokingContext); var messageList = retrievedMessages.ToList(); Assert.Equal(3, messageList.Count); @@ -585,7 +623,7 @@ public async Task AddMessagesAsync_WithHierarchicalMultipleMessages_ShouldAddAll [SkippableFact] [Trait("Category", "CosmosDB")] - public async Task GetMessagesAsync_WithHierarchicalPartitionIsolation_ShouldIsolateMessagesByUserIdAsync() + public async Task InvokingAsync_WithHierarchicalPartitionIsolation_ShouldIsolateMessagesByUserIdAsync() { // Arrange this.SkipIfEmulatorNotAvailable(); @@ -599,17 +637,23 @@ public async Task GetMessagesAsync_WithHierarchicalPartitionIsolation_ShouldIsol using var store2 = new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, HierarchicalTestContainerId, TenantId, UserId2, SessionId); // Add messages to both stores - await store1.AddMessagesAsync([new ChatMessage(ChatRole.User, "Message from user 1")]); - await store2.AddMessagesAsync([new ChatMessage(ChatRole.User, "Message from user 2")]); + var context1 = new ChatMessageStore.InvokedContext([new ChatMessage(ChatRole.User, "Message from user 1")], []); + var context2 = new ChatMessageStore.InvokedContext([new ChatMessage(ChatRole.User, "Message from user 2")], []); + + await store1.InvokedAsync(context1); + await store2.InvokedAsync(context2); // Wait a moment for eventual consistency await Task.Delay(100); // Act & Assert - var messages1 = await store1.GetMessagesAsync(); + var invokingContext1 = new ChatMessageStore.InvokingContext([]); + var invokingContext2 = new ChatMessageStore.InvokingContext([]); + + var messages1 = await store1.InvokingAsync(invokingContext1); var messageList1 = messages1.ToList(); - var messages2 = await store2.GetMessagesAsync(); + var messages2 = await store2.InvokingAsync(invokingContext2); var messageList2 = messages2.ToList(); // With true hierarchical partitioning, each user sees only their own messages @@ -630,7 +674,9 @@ public async Task SerializeDeserialize_WithHierarchicalPartitioning_ShouldPreser const string SessionId = "session-serialize"; using var originalStore = new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, HierarchicalTestContainerId, TenantId, UserId, SessionId); - await originalStore.AddMessagesAsync([new ChatMessage(ChatRole.User, "Test serialization message")]); + + var context = new ChatMessageStore.InvokedContext([new ChatMessage(ChatRole.User, "Test serialization message")], []); + await originalStore.InvokedAsync(context); // Act - Serialize the store state var serializedState = originalStore.Serialize(); @@ -647,7 +693,8 @@ public async Task SerializeDeserialize_WithHierarchicalPartitioning_ShouldPreser await Task.Delay(100); // Assert - The deserialized store should have the same functionality - var messages = await deserializedStore.GetMessagesAsync(); + var invokingContext = new ChatMessageStore.InvokingContext([]); + var messages = await deserializedStore.InvokingAsync(invokingContext); var messageList = messages.ToList(); Assert.Single(messageList); @@ -670,17 +717,22 @@ public async Task HierarchicalAndSimplePartitioning_ShouldCoexistAsync() using var hierarchicalStore = new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, HierarchicalTestContainerId, "tenant-coexist", "user-coexist", SessionId); // Add messages to both - await simpleStore.AddMessagesAsync([new ChatMessage(ChatRole.User, "Simple partitioning message")]); - await hierarchicalStore.AddMessagesAsync([new ChatMessage(ChatRole.User, "Hierarchical partitioning message")]); + var simpleContext = new ChatMessageStore.InvokedContext([new ChatMessage(ChatRole.User, "Simple partitioning message")], []); + var hierarchicalContext = new ChatMessageStore.InvokedContext([new ChatMessage(ChatRole.User, "Hierarchical partitioning message")], []); + + await simpleStore.InvokedAsync(simpleContext); + await hierarchicalStore.InvokedAsync(hierarchicalContext); // Wait a moment for eventual consistency await Task.Delay(100); // Act & Assert - var simpleMessages = await simpleStore.GetMessagesAsync(); + var invokingContext = new ChatMessageStore.InvokingContext([]); + + var simpleMessages = await simpleStore.InvokingAsync(invokingContext); var simpleMessageList = simpleMessages.ToList(); - var hierarchicalMessages = await hierarchicalStore.GetMessagesAsync(); + var hierarchicalMessages = await hierarchicalStore.InvokingAsync(invokingContext); var hierarchicalMessageList = hierarchicalMessages.ToList(); // Each should only see its own messages since they use different containers @@ -707,14 +759,17 @@ public async Task MaxMessagesToRetrieve_ShouldLimitAndReturnMostRecentAsync() messages.Add(new ChatMessage(ChatRole.User, $"Message {i}")); await Task.Delay(10); // Small delay to ensure different timestamps } - await store.AddMessagesAsync(messages); + + var context = new ChatMessageStore.InvokedContext(messages, []); + await store.InvokedAsync(context); // Wait for eventual consistency await Task.Delay(100); // Act - Set max to 5 and retrieve store.MaxMessagesToRetrieve = 5; - var retrievedMessages = await store.GetMessagesAsync(); + var invokingContext = new ChatMessageStore.InvokingContext([]); + var retrievedMessages = await store.InvokingAsync(invokingContext); var messageList = retrievedMessages.ToList(); // Assert - Should get the 5 most recent messages (6-10) in ascending order @@ -742,13 +797,16 @@ public async Task MaxMessagesToRetrieve_Null_ShouldReturnAllMessagesAsync() { messages.Add(new ChatMessage(ChatRole.User, $"Message {i}")); } - await store.AddMessagesAsync(messages); + + var context = new ChatMessageStore.InvokedContext(messages, []); + await store.InvokedAsync(context); // Wait for eventual consistency await Task.Delay(100); // Act - No limit set (default null) - var retrievedMessages = await store.GetMessagesAsync(); + var invokingContext = new ChatMessageStore.InvokingContext([]); + var retrievedMessages = await store.InvokingAsync(invokingContext); var messageList = retrievedMessages.ToList(); // Assert - Should get all 10 messages diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs index 6e9d952b57..5850bc56ba 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs @@ -502,6 +502,12 @@ public async Task RunAsyncUsesChatMessageStoreFactoryWhenProvidedAndNoConversati It.IsAny())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])); Mock mockChatMessageStore = new(); + mockChatMessageStore.Setup(s => s.InvokingAsync( + It.IsAny(), + It.IsAny())).ReturnsAsync([new ChatMessage(ChatRole.User, "Existing Chat History")]); + mockChatMessageStore.Setup(s => s.InvokedAsync( + It.IsAny(), + It.IsAny())).Returns(new ValueTask()); Mock> mockFactory = new(); mockFactory.Setup(f => f(It.IsAny())).Returns(mockChatMessageStore.Object); @@ -518,7 +524,58 @@ public async Task RunAsyncUsesChatMessageStoreFactoryWhenProvidedAndNoConversati // Assert Assert.IsType(thread!.MessageStore, exactMatch: false); - mockChatMessageStore.Verify(s => s.AddMessagesAsync(It.Is>(x => x.Count() == 2), It.IsAny()), Times.Once); + mockService.Verify( + x => x.GetResponseAsync( + It.Is>(msgs => msgs.Count() == 2 && msgs.Any(m => m.Text == "Existing Chat History") && msgs.Any(m => m.Text == "test")), + It.IsAny(), + It.IsAny()), + Times.Once); + mockChatMessageStore.Verify(s => s.InvokingAsync( + It.Is(x => x.RequestMessages.Count() == 1), + It.IsAny()), + Times.Once); + mockChatMessageStore.Verify(s => s.InvokedAsync( + It.Is(x => x.RequestMessages.Count() == 1 && x.ChatMessageStoreMessages.Count() == 1 && x.ResponseMessages!.Count() == 1), + It.IsAny()), + Times.Once); + mockFactory.Verify(f => f(It.IsAny()), Times.Once); + } + + /// + /// Verify that RunAsync notifies the ChatMessageStore on failure. + /// + [Fact] + public async Task RunAsyncNotifiesChatMessageStoreOnFailureAsync() + { + // Arrange + Mock mockService = new(); + mockService.Setup( + s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())).Throws(new InvalidOperationException("Test Error")); + + Mock mockChatMessageStore = new(); + + Mock> mockFactory = new(); + mockFactory.Setup(f => f(It.IsAny())).Returns(mockChatMessageStore.Object); + + ChatClientAgent agent = new(mockService.Object, options: new() + { + ChatOptions = new() { Instructions = "test instructions" }, + ChatMessageStoreFactory = mockFactory.Object + }); + + // Act + ChatClientAgentThread? thread = agent.GetNewThread() as ChatClientAgentThread; + await Assert.ThrowsAsync(() => agent.RunAsync([new(ChatRole.User, "test")], thread)); + + // Assert + Assert.IsType(thread!.MessageStore, exactMatch: false); + mockChatMessageStore.Verify(s => s.InvokedAsync( + It.Is(x => x.RequestMessages.Count() == 1 && x.ResponseMessages == null && x.InvokeException!.Message == "Test Error"), + It.IsAny()), + Times.Once); mockFactory.Verify(f => f(It.IsAny()), Times.Once); } @@ -610,7 +667,7 @@ public async Task RunAsyncInvokesAIContextProviderAndUsesResultAsync() 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 + // Verify that the thread was updated with the ai context provider, input and response messages var messageStore = Assert.IsType(thread!.MessageStore); Assert.Equal(3, messageStore.Count); Assert.Equal("user message", messageStore[0].Text); @@ -2067,7 +2124,7 @@ public async Task RunStreamingAsyncInvokesAIContextProviderAndUsesResultAsync() 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 + // Verify that the thread was updated with the input, ai context provider, and response messages var messageStore = Assert.IsType(thread!.MessageStore); Assert.Equal(3, messageStore.Count); Assert.Equal("user message", messageStore[0].Text); diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_BackgroundResponsesTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_BackgroundResponsesTests.cs index 583a0815ca..3bc28ee12f 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_BackgroundResponsesTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_BackgroundResponsesTests.cs @@ -339,7 +339,7 @@ public async Task RunAsyncSkipsThreadMessagePopulationWithContinuationTokenAsync // Create a mock message store that would normally provide messages var mockMessageStore = new Mock(); mockMessageStore - .Setup(ms => ms.GetMessagesAsync(It.IsAny())) + .Setup(ms => ms.InvokingAsync(It.IsAny(), It.IsAny())) .ReturnsAsync([new(ChatRole.User, "Message from message store")]); // Create a mock AI context provider that would normally provide context @@ -383,7 +383,7 @@ public async Task RunAsyncSkipsThreadMessagePopulationWithContinuationTokenAsync // Verify that message store was never called due to continuation token mockMessageStore.Verify( - ms => ms.GetMessagesAsync(It.IsAny()), + ms => ms.InvokingAsync(It.IsAny(), It.IsAny()), Times.Never); // Verify that AI context provider was never called due to continuation token @@ -401,7 +401,7 @@ public async Task RunStreamingAsyncSkipsThreadMessagePopulationWithContinuationT // Create a mock message store that would normally provide messages var mockMessageStore = new Mock(); mockMessageStore - .Setup(ms => ms.GetMessagesAsync(It.IsAny())) + .Setup(ms => ms.InvokingAsync(It.IsAny(), It.IsAny())) .ReturnsAsync([new(ChatRole.User, "Message from message store")]); // Create a mock AI context provider that would normally provide context @@ -446,7 +446,7 @@ public async Task RunStreamingAsyncSkipsThreadMessagePopulationWithContinuationT // Verify that message store was never called due to continuation token mockMessageStore.Verify( - ms => ms.GetMessagesAsync(It.IsAny()), + ms => ms.InvokingAsync(It.IsAny(), It.IsAny()), Times.Never); // Verify that AI context provider was never called due to continuation token diff --git a/dotnet/tests/OpenAIChatCompletion.IntegrationTests/OpenAIChatCompletionFixture.cs b/dotnet/tests/OpenAIChatCompletion.IntegrationTests/OpenAIChatCompletionFixture.cs index 656d310ddf..0fb9745d2d 100644 --- a/dotnet/tests/OpenAIChatCompletion.IntegrationTests/OpenAIChatCompletionFixture.cs +++ b/dotnet/tests/OpenAIChatCompletion.IntegrationTests/OpenAIChatCompletionFixture.cs @@ -32,7 +32,12 @@ public async Task> GetChatHistoryAsync(AgentThread thread) { var typedThread = (ChatClientAgentThread)thread; - return typedThread.MessageStore is null ? [] : (await typedThread.MessageStore.GetMessagesAsync()).ToList(); + if (typedThread.MessageStore is null) + { + return []; + } + + return (await typedThread.MessageStore.InvokingAsync(new([]))).ToList(); } public Task CreateChatClientAgentAsync( diff --git a/dotnet/tests/OpenAIResponse.IntegrationTests/OpenAIResponseFixture.cs b/dotnet/tests/OpenAIResponse.IntegrationTests/OpenAIResponseFixture.cs index c6c84db569..c57e1c460d 100644 --- a/dotnet/tests/OpenAIResponse.IntegrationTests/OpenAIResponseFixture.cs +++ b/dotnet/tests/OpenAIResponse.IntegrationTests/OpenAIResponseFixture.cs @@ -50,7 +50,12 @@ public async Task> GetChatHistoryAsync(AgentThread thread) return [.. previousMessages, responseMessage]; } - return typedThread.MessageStore is null ? [] : (await typedThread.MessageStore.GetMessagesAsync()).ToList(); + if (typedThread.MessageStore is null) + { + return []; + } + + return (await typedThread.MessageStore.InvokingAsync(new([]))).ToList(); } private static ChatMessage ConvertToChatMessage(ResponseItem item) From 0aba02c402a6cc7cff98a070f96ca9ff8d241736 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Mon, 5 Jan 2026 14:03:18 +0000 Subject: [PATCH 05/13] [BREAKING] Remove unused AgentThreadMetadata (#3067) * Remove unused AgentThreadMetadata * Update DurableTask Changelog --- .../08_ReliableStreaming/FunctionTriggers.cs | 9 +++--- .../RedisStreamResponseHandler.cs | 7 ++--- .../AgentThread.cs | 2 +- .../AgentThreadMetadata.cs | 29 ------------------- .../CHANGELOG.md | 1 + .../DurableAgentThread.cs | 6 ---- .../ChatClient/ChatClientAgentThread.cs | 4 +-- 7 files changed, 10 insertions(+), 48 deletions(-) delete mode 100644 dotnet/src/Microsoft.Agents.AI.Abstractions/AgentThreadMetadata.cs diff --git a/dotnet/samples/AzureFunctions/08_ReliableStreaming/FunctionTriggers.cs b/dotnet/samples/AzureFunctions/08_ReliableStreaming/FunctionTriggers.cs index e642b64337..a6d3e9db55 100644 --- a/dotnet/samples/AzureFunctions/08_ReliableStreaming/FunctionTriggers.cs +++ b/dotnet/samples/AzureFunctions/08_ReliableStreaming/FunctionTriggers.cs @@ -96,16 +96,15 @@ public async Task CreateAsync( // Create a new agent thread AgentThread thread = agentProxy.GetNewThread(); - AgentThreadMetadata metadata = thread.GetService() - ?? throw new InvalidOperationException("Failed to get AgentThreadMetadata from new thread."); + string agentSessionId = thread.GetService().ToString(); - this._logger.LogInformation("Creating new agent session: {ConversationId}", metadata.ConversationId); + this._logger.LogInformation("Creating new agent session: {AgentSessionId}", agentSessionId); // Run the agent in the background (fire-and-forget) DurableAgentRunOptions options = new() { IsFireAndForget = true }; await agentProxy.RunAsync(prompt, thread, options, cancellationToken); - this._logger.LogInformation("Agent run started for session: {ConversationId}", metadata.ConversationId); + this._logger.LogInformation("Agent run started for session: {AgentSessionId}", agentSessionId); // Check Accept header to determine response format // text/plain = raw text output (ideal for terminals) @@ -114,7 +113,7 @@ public async Task CreateAsync( bool useSseFormat = acceptHeader?.Contains("text/plain", StringComparison.OrdinalIgnoreCase) != true; return await this.StreamToClientAsync( - conversationId: metadata.ConversationId!, cursor: null, useSseFormat, request.HttpContext, cancellationToken); + conversationId: agentSessionId, cursor: null, useSseFormat, request.HttpContext, cancellationToken); } /// diff --git a/dotnet/samples/AzureFunctions/08_ReliableStreaming/RedisStreamResponseHandler.cs b/dotnet/samples/AzureFunctions/08_ReliableStreaming/RedisStreamResponseHandler.cs index b0a95f49f6..21f944338a 100644 --- a/dotnet/samples/AzureFunctions/08_ReliableStreaming/RedisStreamResponseHandler.cs +++ b/dotnet/samples/AzureFunctions/08_ReliableStreaming/RedisStreamResponseHandler.cs @@ -65,11 +65,10 @@ public async ValueTask OnStreamingResponseUpdateAsync( "DurableAgentContext.Current is not set. This handler must be used within a durable agent context."); } - // Get conversation ID from the current thread context, which is only available in the context of + // Get session ID from the current thread context, which is only available in the context of // a durable agent execution. - string conversationId = context.CurrentThread.GetService()?.ConversationId - ?? throw new InvalidOperationException("Unable to determine conversation ID from the current thread."); - string streamKey = GetStreamKey(conversationId); + string agentSessionId = context.CurrentThread.GetService().ToString(); + string streamKey = GetStreamKey(agentSessionId); IDatabase db = this._redis.GetDatabase(); int sequenceNumber = 0; diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentThread.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentThread.cs index 4794457f41..0a3301d05f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentThread.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentThread.cs @@ -68,7 +68,7 @@ public virtual JsonElement Serialize(JsonSerializerOptions? jsonSerializerOption /// is . /// /// The purpose of this method is to allow for the retrieval of strongly-typed services that might be provided by the , - /// including itself or any services it might be wrapping. For example, to access the for the instance, + /// including itself or any services it might be wrapping. For example, to access a if available for the instance, /// may be used to request it. /// public virtual object? GetService(Type serviceType, object? serviceKey = null) diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentThreadMetadata.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentThreadMetadata.cs deleted file mode 100644 index 3a2d506745..0000000000 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentThreadMetadata.cs +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Diagnostics; - -namespace Microsoft.Agents.AI; - -/// -/// Provides metadata information about an instance. -/// -[DebuggerDisplay("ConversationId = {ConversationId}")] -public class AgentThreadMetadata -{ - /// - /// Initializes a new instance of the class. - /// - /// The unique identifier for the conversation, if available. - public AgentThreadMetadata(string? conversationId) - { - this.ConversationId = conversationId; - } - - /// - /// Gets the unique identifier for the conversation, if available. - /// - /// - /// The meaning of this ID may vary depending on the agent implementation. - /// - public string? ConversationId { get; } -} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md b/dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md index ccc6aa7181..8f8f64fe5c 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md @@ -6,6 +6,7 @@ - Added TTL configuration for durable agent entities ([#2679](https://github.com/microsoft/agent-framework/pull/2679)) - Switch to new "Run" method name ([#2843](https://github.com/microsoft/agent-framework/pull/2843)) +- Removed AgentThreadMetadata and used AgentSessionId directly instead ([#3067](https://github.com/microsoft/agent-framework/pull/3067)); ## v1.0.0-preview.251204.1 diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentThread.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentThread.cs index 32dea2cb18..98dc8ea4b1 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentThread.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentThread.cs @@ -55,12 +55,6 @@ internal static DurableAgentThread Deserialize(JsonElement serializedThread, Jso /// public override object? GetService(Type serviceType, object? serviceKey = null) { - // This is a common convention for MAF agents. - if (serviceType == typeof(AgentThreadMetadata)) - { - return new AgentThreadMetadata(conversationId: this.SessionId.ToString()); - } - if (serviceType == typeof(AgentSessionId)) { return this.SessionId; diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentThread.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentThread.cs index 7f0ce9a1ea..91e9502968 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentThread.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentThread.cs @@ -171,9 +171,7 @@ public override JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptio /// public override object? GetService(Type serviceType, object? serviceKey = null) => - serviceType == typeof(AgentThreadMetadata) - ? new AgentThreadMetadata(this.ConversationId) - : base.GetService(serviceType, serviceKey) + base.GetService(serviceType, serviceKey) ?? this.AIContextProvider?.GetService(serviceType, serviceKey) ?? this.MessageStore?.GetService(serviceType, serviceKey); From 928c9d54ade4d308bf51ffbac5b505f3eb5694dd Mon Sep 17 00:00:00 2001 From: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Date: Tue, 6 Jan 2026 07:05:46 +0900 Subject: [PATCH 06/13] Python: Fix AzureAIClient failure when conversation history contains assistant messages (#3076) * Fix AzureAIClient failure when conversation history contains assistant messages * Address PR review feedback: improve docstring and test assertions * Remove redundant cast --- .../agent_framework_azure_ai/_client.py | 49 ++++++++++- .../azure-ai/tests/test_azure_ai_client.py | 87 +++++++++++++++++++ 2 files changed, 135 insertions(+), 1 deletion(-) diff --git a/python/packages/azure-ai/agent_framework_azure_ai/_client.py b/python/packages/azure-ai/agent_framework_azure_ai/_client.py index e10fc19068..b06b7b5df0 100644 --- a/python/packages/azure-ai/agent_framework_azure_ai/_client.py +++ b/python/packages/azure-ai/agent_framework_azure_ai/_client.py @@ -2,7 +2,7 @@ import sys from collections.abc import Mapping, MutableSequence -from typing import Any, ClassVar, TypeVar +from typing import Any, ClassVar, TypeVar, cast from agent_framework import ( AGENT_FRAMEWORK_USER_AGENT, @@ -379,6 +379,15 @@ async def _prepare_options( """Take ChatOptions and create the specific options for Azure AI.""" prepared_messages, instructions = self._prepare_messages_for_azure_ai(messages) run_options = await super()._prepare_options(prepared_messages, chat_options, **kwargs) + + # WORKAROUND: Azure AI Projects 'create responses' API has schema divergence from OpenAI's + # Responses API. Azure requires 'type' at item level and 'annotations' in content items. + # See: https://github.com/Azure/azure-sdk-for-python/issues/44493 + # See: https://github.com/microsoft/agent-framework/issues/2926 + # TODO(agent-framework#2926): Remove this workaround when Azure SDK aligns with OpenAI schema. + if "input" in run_options and isinstance(run_options["input"], list): + run_options["input"] = self._transform_input_for_azure_ai(cast(list[dict[str, Any]], run_options["input"])) + if not self._is_application_endpoint: # Application-scoped response APIs do not support "agent" property. agent_reference = await self._get_agent_reference_or_create(run_options, instructions) @@ -393,6 +402,44 @@ async def _prepare_options( return run_options + def _transform_input_for_azure_ai(self, input_items: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Transform input items to match Azure AI Projects expected schema. + + WORKAROUND: Azure AI Projects 'create responses' API expects a different schema than OpenAI's + Responses API. Azure requires 'type' at the item level, and requires 'annotations' + only for output_text content items (assistant messages), not for input_text content items + (user messages). This helper adapts the OpenAI-style input to the Azure schema. + + See: https://github.com/Azure/azure-sdk-for-python/issues/44493 + TODO(agent-framework#2926): Remove when Azure SDK aligns with OpenAI schema. + """ + transformed: list[dict[str, Any]] = [] + for item in input_items: + new_item: dict[str, Any] = dict(item) + + # Add 'type': 'message' at item level for role-based items + if "role" in new_item and "type" not in new_item: + new_item["type"] = "message" + + # Add 'annotations' only to output_text content items (assistant messages) + # User messages (input_text) do NOT support annotations in Azure AI + if "content" in new_item and isinstance(new_item["content"], list): + new_content: list[dict[str, Any] | Any] = [] + for content_item in new_item["content"]: + if isinstance(content_item, dict): + new_content_item: dict[str, Any] = dict(content_item) + # Only add annotations to output_text (assistant content) + if new_content_item.get("type") == "output_text" and "annotations" not in new_content_item: + new_content_item["annotations"] = [] + new_content.append(new_content_item) + else: + new_content.append(content_item) + new_item["content"] = new_content + + transformed.append(new_item) + + return transformed + @override def _get_current_conversation_id(self, chat_options: ChatOptions, **kwargs: Any) -> str | None: """Get the current conversation ID from chat options or kwargs.""" diff --git a/python/packages/azure-ai/tests/test_azure_ai_client.py b/python/packages/azure-ai/tests/test_azure_ai_client.py index 028e8fbdb8..2ca49c2033 100644 --- a/python/packages/azure-ai/tests/test_azure_ai_client.py +++ b/python/packages/azure-ai/tests/test_azure_ai_client.py @@ -286,6 +286,93 @@ async def test_azure_ai_client_prepare_messages_for_azure_ai_no_system_messages( assert instructions is None +def test_azure_ai_client_transform_input_for_azure_ai(mock_project_client: MagicMock) -> None: + """Test _transform_input_for_azure_ai adds required fields for Azure AI schema. + + WORKAROUND TEST: Azure AI Projects API requires 'type' at item level and + 'annotations' in output_text content items, which OpenAI's Responses API does not require. + See: https://github.com/Azure/azure-sdk-for-python/issues/44493 + See: https://github.com/microsoft/agent-framework/issues/2926 + """ + client = create_test_azure_ai_client(mock_project_client) + + # Input in OpenAI Responses API format (what agent-framework generates) + openai_format_input = [ + { + "role": "user", + "content": [ + {"type": "input_text", "text": "Hello"}, + ], + }, + { + "role": "assistant", + "content": [ + {"type": "output_text", "text": "Hi there!"}, + ], + }, + ] + + result = client._transform_input_for_azure_ai(openai_format_input) # type: ignore + + # Verify 'type': 'message' added at item level + assert result[0]["type"] == "message" + assert result[1]["type"] == "message" + + # Verify 'annotations' added ONLY to output_text (assistant) content, NOT input_text (user) + assert result[0]["content"][0]["type"] == "input_text" # user content type preserved + assert "annotations" not in result[0]["content"][0] # user message - no annotations + assert result[1]["content"][0]["type"] == "output_text" # assistant content type preserved + assert result[1]["content"][0]["annotations"] == [] # assistant message - has annotations + + # Verify original fields preserved + assert result[0]["role"] == "user" + assert result[0]["content"][0]["text"] == "Hello" + assert result[1]["role"] == "assistant" + assert result[1]["content"][0]["text"] == "Hi there!" + + +def test_azure_ai_client_transform_input_preserves_existing_fields(mock_project_client: MagicMock) -> None: + """Test _transform_input_for_azure_ai preserves existing type and annotations.""" + client = create_test_azure_ai_client(mock_project_client) + + # Input that already has the fields (shouldn't duplicate) + input_with_fields = [ + { + "type": "message", + "role": "assistant", + "content": [ + {"type": "output_text", "text": "Hello", "annotations": [{"some": "annotation"}]}, + ], + }, + ] + + result = client._transform_input_for_azure_ai(input_with_fields) # type: ignore + + # Should preserve existing values, not overwrite + assert result[0]["type"] == "message" + assert result[0]["content"][0]["annotations"] == [{"some": "annotation"}] + + +def test_azure_ai_client_transform_input_handles_non_dict_content(mock_project_client: MagicMock) -> None: + """Test _transform_input_for_azure_ai handles non-dict content items.""" + client = create_test_azure_ai_client(mock_project_client) + + # Input with string content (edge case) + input_with_string_content = [ + { + "role": "user", + "content": ["plain string content"], + }, + ] + + result = client._transform_input_for_azure_ai(input_with_string_content) # type: ignore + + # Should add 'type': 'message' at item level even with non-dict content + assert result[0]["type"] == "message" + # Non-dict content items should be preserved without modification + assert result[0]["content"] == ["plain string content"] + + async def test_azure_ai_client_prepare_options_basic(mock_project_client: MagicMock) -> None: """Test prepare_options basic functionality.""" client = create_test_azure_ai_client(mock_project_client, agent_name="test-agent", agent_version="1.0") From ed5278c41df0decb02185285feb86ba10988371e Mon Sep 17 00:00:00 2001 From: takanori-terai <123897708+takanori-terai@users.noreply.github.com> Date: Tue, 6 Jan 2026 13:45:29 +0900 Subject: [PATCH 07/13] Fix: Update OTLP exporter protocol conditions (#3070) --- python/packages/core/agent_framework/observability.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/python/packages/core/agent_framework/observability.py b/python/packages/core/agent_framework/observability.py index 9dc6e4d4a9..26c261038b 100644 --- a/python/packages/core/agent_framework/observability.py +++ b/python/packages/core/agent_framework/observability.py @@ -321,7 +321,7 @@ def _create_otlp_exporters( if not actual_logs_endpoint and not actual_traces_endpoint and not actual_metrics_endpoint: return exporters - if protocol in ("grpc", "http/protobuf"): + if protocol == "grpc": # Import all gRPC exporters try: from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter as GRPCLogExporter @@ -357,7 +357,7 @@ def _create_otlp_exporters( ) ) - elif protocol == "http": + elif protocol in ("http/protobuf", "http"): # Import all HTTP exporters try: from opentelemetry.exporter.otlp.proto.http._log_exporter import OTLPLogExporter as HTTPLogExporter From 844d34510652f08a9b7805586ff2825a7b613c13 Mon Sep 17 00:00:00 2001 From: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Date: Tue, 6 Jan 2026 18:12:26 +0900 Subject: [PATCH 08/13] Python: Fix ExecutorInvokedEvent and ExecutorCompletedEvent observability data (#3090) * Fix ExecutorInvokedEvent.data mutation bug * Fix bug related to not yielding output type --- .../agent_framework/_workflows/_executor.py | 10 +++-- .../_workflows/_workflow_context.py | 15 +++++++ .../core/tests/workflow/test_executor.py | 43 +++++++++++++++++-- .../observability/executor_io_observation.py | 1 + 4 files changed, 62 insertions(+), 7 deletions(-) diff --git a/python/packages/core/agent_framework/_workflows/_executor.py b/python/packages/core/agent_framework/_workflows/_executor.py index 3624a7c267..fad1e5f15e 100644 --- a/python/packages/core/agent_framework/_workflows/_executor.py +++ b/python/packages/core/agent_framework/_workflows/_executor.py @@ -1,6 +1,7 @@ # Copyright (c) Microsoft. All rights reserved. import contextlib +import copy import functools import inspect import logging @@ -263,8 +264,9 @@ async def execute( ) # Invoke the handler with the message and context + # Use deepcopy to capture original input state before handler can mutate it with _framework_event_origin(): - invoke_event = ExecutorInvokedEvent(self.id, message) + invoke_event = ExecutorInvokedEvent(self.id, copy.deepcopy(message)) await context.add_event(invoke_event) try: await handler(message, context) @@ -275,9 +277,11 @@ async def execute( await context.add_event(failure_event) raise with _framework_event_origin(): - # Include sent messages as the completion data + # Include sent messages and yielded outputs as the completion data sent_messages = context.get_sent_messages() - completed_event = ExecutorCompletedEvent(self.id, sent_messages if sent_messages else None) + yielded_outputs = context.get_yielded_outputs() + completion_data = sent_messages + yielded_outputs + completed_event = ExecutorCompletedEvent(self.id, completion_data if completion_data else None) await context.add_event(completed_event) def _create_context_for_handler( diff --git a/python/packages/core/agent_framework/_workflows/_workflow_context.py b/python/packages/core/agent_framework/_workflows/_workflow_context.py index 9719ce164a..cffeb02aa0 100644 --- a/python/packages/core/agent_framework/_workflows/_workflow_context.py +++ b/python/packages/core/agent_framework/_workflows/_workflow_context.py @@ -1,5 +1,6 @@ # Copyright (c) Microsoft. All rights reserved. +import copy import inspect import logging import uuid @@ -290,6 +291,9 @@ def __init__( # Track messages sent via send_message() for ExecutorCompletedEvent self._sent_messages: list[Any] = [] + # Track outputs yielded via yield_output() for ExecutorCompletedEvent + self._yielded_outputs: list[Any] = [] + # Store trace contexts and source span IDs for linking (supporting multiple sources) self._trace_contexts = trace_contexts or [] self._source_span_ids = source_span_ids or [] @@ -336,6 +340,9 @@ async def yield_output(self, output: T_W_Out) -> None: output: The output to yield. This must conform to the workflow output type(s) declared on this context. """ + # Track yielded output for ExecutorCompletedEvent (deepcopy to capture state at yield time) + self._yielded_outputs.append(copy.deepcopy(output)) + with _framework_event_origin(): event = WorkflowOutputEvent(data=output, source_executor_id=self._executor_id) await self._runner_context.add_event(event) @@ -424,6 +431,14 @@ def get_sent_messages(self) -> list[Any]: """ return self._sent_messages.copy() + def get_yielded_outputs(self) -> list[Any]: + """Get all outputs yielded via yield_output() during this handler execution. + + Returns: + A list of outputs that were yielded as workflow outputs. + """ + return self._yielded_outputs.copy() + @deprecated( "Override `on_checkpoint_save()` methods instead. " "For cross-executor state sharing, use set_shared_state() instead. " diff --git a/python/packages/core/tests/workflow/test_executor.py b/python/packages/core/tests/workflow/test_executor.py index 3c5558ac30..176c3027c8 100644 --- a/python/packages/core/tests/workflow/test_executor.py +++ b/python/packages/core/tests/workflow/test_executor.py @@ -3,12 +3,14 @@ import pytest from agent_framework import ( + ChatMessage, Executor, ExecutorCompletedEvent, ExecutorInvokedEvent, Message, WorkflowBuilder, WorkflowContext, + executor, handler, ) @@ -182,8 +184,8 @@ async def handle(self, text: str, ctx: WorkflowContext) -> None: assert collector_completed.data is None -async def test_executor_completed_event_none_when_no_messages_sent(): - """Test that ExecutorCompletedEvent.data is None when no messages are sent.""" +async def test_executor_completed_event_includes_yielded_outputs(): + """Test that ExecutorCompletedEvent.data includes yielded outputs.""" from typing_extensions import Never from agent_framework import WorkflowOutputEvent @@ -201,9 +203,10 @@ async def handle(self, text: str, ctx: WorkflowContext[Never, str]) -> None: assert len(completed_events) == 1 assert completed_events[0].executor_id == "yielder" - assert completed_events[0].data is None + # Yielded outputs are now included in ExecutorCompletedEvent.data + assert completed_events[0].data == ["TEST"] - # Verify the output was still yielded correctly + # Verify the output was also yielded as WorkflowOutputEvent output_events = [e for e in events if isinstance(e, WorkflowOutputEvent)] assert len(output_events) == 1 assert output_events[0].data == "TEST" @@ -261,3 +264,35 @@ async def handle(self, response: Response, ctx: WorkflowContext) -> None: collector_invoked = next(e for e in invoked_events if e.executor_id == "collector") assert isinstance(collector_invoked.data, Response) assert collector_invoked.data.results == ["HELLO", "HELLO", "HELLO"] + + +async def test_executor_invoked_event_data_not_mutated_by_handler(): + """Test that ExecutorInvokedEvent.data captures original input, not mutated input.""" + + @executor(id="Mutator") + async def mutator(messages: list[ChatMessage], ctx: WorkflowContext[list[ChatMessage]]) -> None: + # The handler mutates the input list by appending new messages + original_len = len(messages) + messages.append(ChatMessage(role="assistant", text="Added by executor")) + await ctx.send_message(messages) + # Verify mutation happened + assert len(messages) == original_len + 1 + + workflow = WorkflowBuilder().set_start_executor(mutator).build() + + # Run with a single user message + input_messages = [ChatMessage(role="user", text="hello")] + events = await workflow.run(input_messages) + + # Find the invoked event for the Mutator executor + invoked_events = [e for e in events if isinstance(e, ExecutorInvokedEvent)] + assert len(invoked_events) == 1 + mutator_invoked = invoked_events[0] + + # The event data should contain ONLY the original input (1 user message) + assert mutator_invoked.executor_id == "Mutator" + assert len(mutator_invoked.data) == 1, ( + f"Expected 1 message (original input), got {len(mutator_invoked.data)}: " + f"{[m.text for m in mutator_invoked.data]}" + ) + assert mutator_invoked.data[0].text == "hello" diff --git a/python/samples/getting_started/workflows/observability/executor_io_observation.py b/python/samples/getting_started/workflows/observability/executor_io_observation.py index cd9b5d44f5..0237f294f2 100644 --- a/python/samples/getting_started/workflows/observability/executor_io_observation.py +++ b/python/samples/getting_started/workflows/observability/executor_io_observation.py @@ -119,6 +119,7 @@ async def main() -> None: Input: str: 'HELLO WORLD' [WORKFLOW OUTPUT] str: 'DLROW OLLEH' [COMPLETED] reverse_text + Output: list: [str: 'DLROW OLLEH'] """ From 0aa0579b1b20c4bcc3789985c6913eb2318dcbdb Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 6 Jan 2026 10:44:13 +0000 Subject: [PATCH 09/13] .NET: Seal ChatClientAgentThread (#2842) * Initial plan * Seal ChatClientAgentThread class Co-authored-by: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> --- .../src/Microsoft.Agents.AI/ChatClient/ChatClientAgentThread.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentThread.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentThread.cs index 91e9502968..f4cf4aa033 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentThread.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentThread.cs @@ -11,7 +11,7 @@ namespace Microsoft.Agents.AI; /// Provides a thread implementation for use with . /// [DebuggerDisplay("{DebuggerDisplay,nq}")] -public class ChatClientAgentThread : AgentThread +public sealed class ChatClientAgentThread : AgentThread { private ChatMessageStore? _messageStore; From 7a0584960910155a8287bedcce5f5b2610d63d02 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Tue, 6 Jan 2026 14:55:29 +0000 Subject: [PATCH 10/13] Fix broken strands urls. (#3102) * Fix broken strands urls. * Fix typos --- docs/decisions/0001-agent-run-response.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/decisions/0001-agent-run-response.md b/docs/decisions/0001-agent-run-response.md index b60878adff..9f13af787c 100644 --- a/docs/decisions/0001-agent-run-response.md +++ b/docs/decisions/0001-agent-run-response.md @@ -64,7 +64,7 @@ Approaches observed from the compared SDKs: | AutoGen | **Approach 1** Separates messages into Agent-Agent (maps to Primary) and Internal (maps to Secondary) and these are returned as separate properties on the agent response object. See [types of messages](https://microsoft.github.io/autogen/stable/user-guide/agentchat-user-guide/tutorial/messages.html#types-of-messages) and [Response](https://microsoft.github.io/autogen/stable/reference/python/autogen_agentchat.base.html#autogen_agentchat.base.Response) | **Approach 2** Returns a stream of internal events and the last item is a Response object. See [ChatAgent.on_messages_stream](https://microsoft.github.io/autogen/stable/reference/python/autogen_agentchat.base.html#autogen_agentchat.base.ChatAgent.on_messages_stream) | | OpenAI Agent SDK | **Approach 1** Separates new_items (Primary+Secondary) from final output (Primary) as separate properties on the [RunResult](https://github.com/openai/openai-agents-python/blob/main/src/agents/result.py#L39) | **Approach 1** Similar to non-streaming, has a way of streaming updates via a method on the response object which includes all data, and then a separate final output property on the response object which is populated only when the run is complete. See [RunResultStreaming](https://github.com/openai/openai-agents-python/blob/main/src/agents/result.py#L136) | | Google ADK | **Approach 2** [Emits events](https://google.github.io/adk-docs/runtime/#step-by-step-breakdown) with [FinalResponse](https://github.com/google/adk-java/blob/main/core/src/main/java/com/google/adk/events/Event.java#L232) true (Primary) / false (Secondary) and callers have to filter out those with false to get just the final response message | **Approach 2** Similar to non-streaming except [events](https://google.github.io/adk-docs/runtime/#streaming-vs-non-streaming-output-partialtrue) are emitted with [Partial](https://github.com/google/adk-java/blob/main/core/src/main/java/com/google/adk/events/Event.java#L133) true to indicate that they are streaming messages. A final non partial event is also emitted. | -| AWS (Strands) | **Approach 3** Returns an [AgentResult](https://strandsagents.com/latest/api-reference/agent/#strands.agent.agent_result.AgentResult) (Primary) with messages and a reason for the run's completion. | **Approach 2** [Streams events](https://strandsagents.com/latest/api-reference/agent/#strands.agent.agent.Agent.stream_async) (Primary+Secondary) including, response text, current_tool_use, even data from "callbacks" (strands plugins) | +| AWS (Strands) | **Approach 3** Returns an [AgentResult](https://strandsagents.com/latest/documentation/docs/api-reference/python/agent/agent_result/) (Primary) with messages and a reason for the run's completion. | **Approach 2** [Streams events](https://strandsagents.com/latest/documentation/docs/api-reference/python/agent/agent/#strands.agent.agent.Agent.stream_async) (Primary+Secondary) including, response text, current_tool_use, even data from "callbacks" (strands plugins) | | LangGraph | **Approach 2** A mixed list of all [messages](https://langchain-ai.github.io/langgraph/agents/run_agents/#output-format) | **Approach 2** A mixed list of all [messages](https://langchain-ai.github.io/langgraph/agents/run_agents/#output-format) | | Agno | **Combination of various approaches** Returns a [RunResponse](https://docs.agno.com/reference/agents/run-response) object with text content, messages (essentially chat history including inputs and instructions), reasoning and thinking text properties. Secondary events could potentially be extracted from messages. | **Approach 2** Returns [RunResponseEvent](https://docs.agno.com/reference/agents/run-response#runresponseevent-types-and-attributes) objects including tool call, memory update, etc, information, where the [RunResponseCompletedEvent](https://docs.agno.com/reference/agents/run-response#runresponsecompletedevent) has similar properties to RunResponse| | A2A | **Approach 3** Returns a [Task or Message](https://a2aproject.github.io/A2A/latest/specification/#71-messagesend) where the message is the final result (Primary) and task is a reference to a long running process. | **Approach 2** Returns a [stream](https://a2aproject.github.io/A2A/latest/specification/#72-messagestream) that contains task updates (Secondary) and a final message (Primary) | @@ -495,8 +495,8 @@ We need to decide what AIContent types, each agent response type will be mapped | SDK | Structured Outputs support | |-|-| | AutoGen | **Approach 1** Supports [configuring an agent](https://microsoft.github.io/autogen/stable/user-guide/agentchat-user-guide/tutorial/agents.html#structured-output) at agent creation. | -| Google ADK | **Approach 1** Both [input and output shemas can be specified for LLM Agents](https://google.github.io/adk-docs/agents/llm-agents/#structuring-data-input_schema-output_schema-output_key) at construction time. This option is specific to this agent type and other agent types do not necessarily support | -| AWS (Strands) | **Approach 2** Supports a special invocation method called [structured_output](https://strandsagents.com/latest/api-reference/agent/#strands.agent.agent.Agent.structured_output) | +| Google ADK | **Approach 1** Both [input and output schemas can be specified for LLM Agents](https://google.github.io/adk-docs/agents/llm-agents/#structuring-data-input_schema-output_schema-output_key) at construction time. This option is specific to this agent type and other agent types do not necessarily support | +| AWS (Strands) | **Approach 2** Supports a special invocation method called [structured_output](https://strandsagents.com/latest/documentation/docs/api-reference/python/agent/agent/#strands.agent.agent.Agent.structured_output) | | LangGraph | **Approach 1** Supports [configuring an agent](https://langchain-ai.github.io/langgraph/agents/agents/?h=structured#6-configure-structured-output) at agent construction time, and a [structured response](https://langchain-ai.github.io/langgraph/agents/run_agents/#output-format) can be retrieved as a special property on the agent response | | Agno | **Approach 1** Supports [configuring an agent](https://docs.agno.com/examples/getting-started/structured-output) at agent construction time | | A2A | **Informal Approach 2** Doesn't formally support schema negotiation, but [hints can be provided via metadata](https://a2a-protocol.org/latest/specification/#97-structured-data-exchange-requesting-and-providing-json) at invocation time | @@ -508,7 +508,7 @@ We need to decide what AIContent types, each agent response type will be mapped |-|-| | AutoGen | Supports a [stop reason](https://microsoft.github.io/autogen/stable/reference/python/autogen_agentchat.base.html#autogen_agentchat.base.TaskResult.stop_reason) which is a freeform text string | | Google ADK | [No equivalent present](https://github.com/google/adk-python/blob/main/src/google/adk/events/event.py) | -| AWS (Strands) | Exposes a [stop_reason](https://strandsagents.com/latest/api-reference/types/#strands.types.event_loop.StopReason) property on the [AgentResult](https://strandsagents.com/latest/api-reference/agent/#strands.agent.agent_result.AgentResult) class with options that are tied closely to LLM operations. | +| AWS (Strands) | Exposes a [stop_reason](https://strandsagents.com/latest/documentation/docs/api-reference/python/types/event_loop/#strands.types.event_loop.StopReason) property on the [AgentResult](https://strandsagents.com/latest/documentation/docs/api-reference/python/agent/agent_result/) class with options that are tied closely to LLM operations. | | LangGraph | No equivalent present, output contains only [messages](https://langchain-ai.github.io/langgraph/agents/run_agents/#output-format) | | Agno | [No equivalent present](https://docs.agno.com/reference/agents/run-response) | | A2A | No equivalent present, response only contains a [message](https://a2a-protocol.org/latest/specification/#64-message-object) or [task](https://a2a-protocol.org/latest/specification/#61-task-object). | From 953fde69ac9b8fa2e2ce209a2e7e13cd21289444 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 6 Jan 2026 15:39:42 +0000 Subject: [PATCH 11/13] .NET: Fix message ordering inconsistency when using AIContextProvider (#2659) * Initial plan * Fix message ordering inconsistency when using AIContextProvider Co-authored-by: westey-m <164392973+westey-m@users.noreply.github.com> * Revert to original message ordering: Input, AIContextProvider, Response Co-authored-by: westey-m <164392973+westey-m@users.noreply.github.com> * Reorder messages to ChatClient to match MessageStore order: Existing, Input, AIContextProvider Co-authored-by: westey-m <164392973+westey-m@users.noreply.github.com> * Remove redundant test methods as existing tests already verify the behavior Co-authored-by: westey-m <164392973+westey-m@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: westey-m <164392973+westey-m@users.noreply.github.com> Co-authored-by: Mark Wallace <127216156+markwallace-microsoft@users.noreply.github.com> Co-authored-by: Chris <66376200+crickman@users.noreply.github.com> --- .../ChatClient/ChatClientAgent.cs | 6 +++--- .../ChatClient/ChatClientAgentTests.cs | 20 +++++++++---------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs index 9c5858b8e2..7d75f077ac 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs @@ -662,6 +662,9 @@ private async Task chatMessageStoreMessages = storeMessages as IList ?? storeMessages.ToList(); } + // Add the input messages before getting context from AIContextProvider. + inputMessagesForChatClient.AddRange(inputMessages); + // If we have an AIContextProvider, we should get context from it, and update our // messages and options with the additional context. if (typedThread.AIContextProvider is not null) @@ -690,9 +693,6 @@ private async Task chatOptions.Instructions = string.IsNullOrWhiteSpace(chatOptions.Instructions) ? aiContext.Instructions : $"{chatOptions.Instructions}\n{aiContext.Instructions}"; } } - - // Add the input messages to the end of thread messages. - inputMessagesForChatClient.AddRange(inputMessages); } // If a user provided two different thread ids, via the thread object and options, we should throw diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs index 5850bc56ba..29d3d3afee 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs @@ -656,13 +656,13 @@ public async Task RunAsyncInvokesAIContextProviderAndUsesResultAsync() await agent.RunAsync(requestMessages, thread); // Assert - // Should contain: base instructions, context message, user message, base function, context function + // Should contain: base instructions, user message, context 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("user message", capturedMessages[0].Text); + Assert.Equal(ChatRole.User, capturedMessages[0].Role); + Assert.Equal("context provider message", capturedMessages[1].Text); + Assert.Equal(ChatRole.System, 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"); @@ -2113,13 +2113,13 @@ public async Task RunStreamingAsyncInvokesAIContextProviderAndUsesResultAsync() _ = await updates.ToAgentRunResponseAsync(); // Assert - // Should contain: base instructions, context message, user message, base function, context function + // Should contain: base instructions, user message, context 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("user message", capturedMessages[0].Text); + Assert.Equal(ChatRole.User, capturedMessages[0].Role); + Assert.Equal("context provider message", capturedMessages[1].Text); + Assert.Equal(ChatRole.System, 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"); From 24c822590f6c72e1b85067a62fa9e4cde0d95b46 Mon Sep 17 00:00:00 2001 From: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Date: Wed, 7 Jan 2026 07:51:20 +0900 Subject: [PATCH 12/13] fix: tool_choice parameter not being honored when passed to agent.run() (#3095) --- .../packages/core/agent_framework/_clients.py | 16 ++-- .../agent_framework/openai/_chat_client.py | 4 +- .../openai/_responses_client.py | 4 +- .../packages/core/tests/core/test_agents.py | 90 +++++++++++++++++++ python/packages/core/tests/core/test_types.py | 48 ++++++++++ 5 files changed, 153 insertions(+), 9 deletions(-) diff --git a/python/packages/core/agent_framework/_clients.py b/python/packages/core/agent_framework/_clients.py index bfb2c3f7d4..6743902475 100644 --- a/python/packages/core/agent_framework/_clients.py +++ b/python/packages/core/agent_framework/_clients.py @@ -101,7 +101,7 @@ async def get_response( stop: str | Sequence[str] | None = None, store: bool | None = None, temperature: float | None = None, - tool_choice: ToolMode | Literal["auto", "required", "none"] | dict[str, Any] | None = "auto", + tool_choice: ToolMode | Literal["auto", "required", "none"] | dict[str, Any] | None = None, tools: ToolProtocol | Callable[..., Any] | MutableMapping[str, Any] @@ -160,7 +160,7 @@ def get_streaming_response( stop: str | Sequence[str] | None = None, store: bool | None = None, temperature: float | None = None, - tool_choice: ToolMode | Literal["auto", "required", "none"] | dict[str, Any] | None = "auto", + tool_choice: ToolMode | Literal["auto", "required", "none"] | dict[str, Any] | None = None, tools: ToolProtocol | Callable[..., Any] | MutableMapping[str, Any] @@ -501,7 +501,7 @@ async def get_response( stop: str | Sequence[str] | None = None, store: bool | None = None, temperature: float | None = None, - tool_choice: ToolMode | Literal["auto", "required", "none"] | dict[str, Any] | None = "auto", + tool_choice: ToolMode | Literal["auto", "required", "none"] | dict[str, Any] | None = None, tools: ToolProtocol | Callable[..., Any] | MutableMapping[str, Any] @@ -596,7 +596,7 @@ async def get_streaming_response( stop: str | Sequence[str] | None = None, store: bool | None = None, temperature: float | None = None, - tool_choice: ToolMode | Literal["auto", "required", "none"] | dict[str, Any] | None = "auto", + tool_choice: ToolMode | Literal["auto", "required", "none"] | dict[str, Any] | None = None, tools: ToolProtocol | Callable[..., Any] | MutableMapping[str, Any] @@ -688,12 +688,18 @@ def _prepare_tool_choice(self, chat_options: ChatOptions) -> None: chat_options: The chat options to prepare. """ chat_tool_mode = chat_options.tool_choice - if chat_tool_mode is None or chat_tool_mode == ToolMode.NONE or chat_tool_mode == "none": + # Explicitly disabled: clear tools and set to NONE + if chat_tool_mode == ToolMode.NONE or chat_tool_mode == "none": chat_options.tools = None chat_options.tool_choice = ToolMode.NONE return + # No tools available: set to NONE regardless of requested mode if not chat_options.tools: chat_options.tool_choice = ToolMode.NONE + # Tools available but no explicit mode: default to AUTO + elif chat_tool_mode is None: + chat_options.tool_choice = ToolMode.AUTO + # Tools available with explicit mode: preserve the mode else: chat_options.tool_choice = chat_tool_mode diff --git a/python/packages/core/agent_framework/openai/_chat_client.py b/python/packages/core/agent_framework/openai/_chat_client.py index 305757356d..a2365b58f2 100644 --- a/python/packages/core/agent_framework/openai/_chat_client.py +++ b/python/packages/core/agent_framework/openai/_chat_client.py @@ -205,8 +205,8 @@ def _prepare_options(self, messages: MutableSequence[ChatMessage], chat_options: run_options.pop("tools", None) run_options.pop("parallel_tool_calls", None) run_options.pop("tool_choice", None) - # tool choice when `tool_choice` is a dict with single key `mode`, extract the mode value - if (tool_choice := run_options.get("tool_choice")) and len(tool_choice.keys()) == 1: + # tool_choice: ToolMode serializes to {"type": "tool_mode", "mode": "..."}, extract mode + if (tool_choice := run_options.get("tool_choice")) and isinstance(tool_choice, dict) and "mode" in tool_choice: run_options["tool_choice"] = tool_choice["mode"] # response format diff --git a/python/packages/core/agent_framework/openai/_responses_client.py b/python/packages/core/agent_framework/openai/_responses_client.py index 54a0f5544b..9e50677fae 100644 --- a/python/packages/core/agent_framework/openai/_responses_client.py +++ b/python/packages/core/agent_framework/openai/_responses_client.py @@ -435,8 +435,8 @@ async def _prepare_options( else: run_options.pop("parallel_tool_calls", None) run_options.pop("tool_choice", None) - # tool choice when `tool_choice` is a dict with single key `mode`, extract the mode value - if (tool_choice := run_options.get("tool_choice")) and len(tool_choice.keys()) == 1: + # tool_choice: ToolMode serializes to {"type": "tool_mode", "mode": "..."}, extract mode + if (tool_choice := run_options.get("tool_choice")) and isinstance(tool_choice, dict) and "mode" in tool_choice: run_options["tool_choice"] = tool_choice["mode"] # additional properties diff --git a/python/packages/core/tests/core/test_agents.py b/python/packages/core/tests/core/test_agents.py index a6df07cbbe..7611df0cb0 100644 --- a/python/packages/core/tests/core/test_agents.py +++ b/python/packages/core/tests/core/test_agents.py @@ -632,3 +632,93 @@ def echo_thread_info(text: str, **kwargs: Any) -> str: # type: ignore[reportUnk assert result.text == "done" assert captured.get("has_thread") is True assert captured.get("has_message_store") is True + + +async def test_chat_agent_tool_choice_run_level_overrides_agent_level( + chat_client_base: Any, ai_function_tool: Any +) -> None: + """Verify that tool_choice passed to run() overrides agent-level tool_choice.""" + from agent_framework import ChatOptions, ToolMode + + captured_options: list[ChatOptions] = [] + + # Store the original inner method + original_inner = chat_client_base._inner_get_response + + async def capturing_inner( + *, messages: MutableSequence[ChatMessage], chat_options: ChatOptions, **kwargs: Any + ) -> ChatResponse: + captured_options.append(chat_options) + return await original_inner(messages=messages, chat_options=chat_options, **kwargs) + + chat_client_base._inner_get_response = capturing_inner + + # Create agent with agent-level tool_choice="auto" and a tool (tools required for tool_choice to be meaningful) + agent = ChatAgent(chat_client=chat_client_base, tool_choice="auto", tools=[ai_function_tool]) + + # Run with run-level tool_choice="required" + await agent.run("Hello", tool_choice="required") + + # Verify the client received tool_choice="required", not "auto" + assert len(captured_options) >= 1 + assert captured_options[0].tool_choice == "required" + assert captured_options[0].tool_choice == ToolMode.REQUIRED_ANY + + +async def test_chat_agent_tool_choice_agent_level_used_when_run_level_not_specified( + chat_client_base: Any, ai_function_tool: Any +) -> None: + """Verify that agent-level tool_choice is used when run() doesn't specify one.""" + from agent_framework import ChatOptions, ToolMode + + captured_options: list[ChatOptions] = [] + + original_inner = chat_client_base._inner_get_response + + async def capturing_inner( + *, messages: MutableSequence[ChatMessage], chat_options: ChatOptions, **kwargs: Any + ) -> ChatResponse: + captured_options.append(chat_options) + return await original_inner(messages=messages, chat_options=chat_options, **kwargs) + + chat_client_base._inner_get_response = capturing_inner + + # Create agent with agent-level tool_choice="required" and a tool + agent = ChatAgent(chat_client=chat_client_base, tool_choice="required", tools=[ai_function_tool]) + + # Run without specifying tool_choice + await agent.run("Hello") + + # Verify the client received tool_choice="required" from agent-level + assert len(captured_options) >= 1 + assert captured_options[0].tool_choice == "required" + assert captured_options[0].tool_choice == ToolMode.REQUIRED_ANY + + +async def test_chat_agent_tool_choice_none_at_run_preserves_agent_level( + chat_client_base: Any, ai_function_tool: Any +) -> None: + """Verify that tool_choice=None at run() uses agent-level default.""" + from agent_framework import ChatOptions + + captured_options: list[ChatOptions] = [] + + original_inner = chat_client_base._inner_get_response + + async def capturing_inner( + *, messages: MutableSequence[ChatMessage], chat_options: ChatOptions, **kwargs: Any + ) -> ChatResponse: + captured_options.append(chat_options) + return await original_inner(messages=messages, chat_options=chat_options, **kwargs) + + chat_client_base._inner_get_response = capturing_inner + + # Create agent with agent-level tool_choice="auto" and a tool + agent = ChatAgent(chat_client=chat_client_base, tool_choice="auto", tools=[ai_function_tool]) + + # Run with explicitly passing None (same as not specifying) + await agent.run("Hello", tool_choice=None) + + # Verify the client received tool_choice="auto" from agent-level + assert len(captured_options) >= 1 + assert captured_options[0].tool_choice == "auto" diff --git a/python/packages/core/tests/core/test_types.py b/python/packages/core/tests/core/test_types.py index 81242147d2..4d52d81d22 100644 --- a/python/packages/core/tests/core/test_types.py +++ b/python/packages/core/tests/core/test_types.py @@ -844,6 +844,54 @@ def test_chat_options_and(ai_function_tool, ai_tool) -> None: assert options3.additional_properties.get("p") == 1 +def test_chat_options_and_tool_choice_override() -> None: + """Test that tool_choice from other takes precedence in ChatOptions merge.""" + # Agent-level defaults to "auto" + agent_options = ChatOptions(model_id="gpt-4o", tool_choice="auto") + # Run-level specifies "required" + run_options = ChatOptions(tool_choice="required") + + merged = agent_options & run_options + + # Run-level should override agent-level + assert merged.tool_choice == "required" + assert merged.model_id == "gpt-4o" # Other fields preserved + + +def test_chat_options_and_tool_choice_none_in_other_uses_self() -> None: + """Test that when other.tool_choice is None, self.tool_choice is used.""" + agent_options = ChatOptions(tool_choice="auto") + run_options = ChatOptions(model_id="gpt-4.1") # tool_choice is None + + merged = agent_options & run_options + + # Should keep agent-level tool_choice since run-level is None + assert merged.tool_choice == "auto" + assert merged.model_id == "gpt-4.1" + + +def test_chat_options_and_tool_choice_with_tool_mode() -> None: + """Test ChatOptions merge with ToolMode objects.""" + agent_options = ChatOptions(tool_choice=ToolMode.AUTO) + run_options = ChatOptions(tool_choice=ToolMode.REQUIRED_ANY) + + merged = agent_options & run_options + + assert merged.tool_choice == ToolMode.REQUIRED_ANY + assert merged.tool_choice == "required" # ToolMode equality with string + + +def test_chat_options_and_tool_choice_required_specific_function() -> None: + """Test ChatOptions merge with required specific function.""" + agent_options = ChatOptions(tool_choice="auto") + run_options = ChatOptions(tool_choice=ToolMode.REQUIRED(function_name="get_weather")) + + merged = agent_options & run_options + + assert merged.tool_choice == "required" + assert merged.tool_choice.required_function_name == "get_weather" + + # region Agent Response Fixtures From ea370f8ff68c5c1b7f69d4968aded9e0895578a9 Mon Sep 17 00:00:00 2001 From: Giles Odigwe <79032838+giles17@users.noreply.github.com> Date: Tue, 6 Jan 2026 14:57:54 -0800 Subject: [PATCH 13/13] sharepoint sample fix (#3108) --- .../agents/azure_ai/azure_ai_with_sharepoint.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/python/samples/getting_started/agents/azure_ai/azure_ai_with_sharepoint.py b/python/samples/getting_started/agents/azure_ai/azure_ai_with_sharepoint.py index a724bda2d2..a58de50e84 100644 --- a/python/samples/getting_started/agents/azure_ai/azure_ai_with_sharepoint.py +++ b/python/samples/getting_started/agents/azure_ai/azure_ai_with_sharepoint.py @@ -28,7 +28,11 @@ async def main() -> None: tools={ "type": "sharepoint_grounding_preview", "sharepoint_grounding_preview": { - "project_connection_id": os.environ["SHAREPOINT_PROJECT_CONNECTION_ID"] + "project_connections": [ + { + "project_connection_id": os.environ["SHAREPOINT_PROJECT_CONNECTION_ID"], + } + ] }, }, ) as agent,