From c83011b30d91cadac8d688be7579261dbe604dc3 Mon Sep 17 00:00:00 2001 From: Peter Ibekwe <109177538+peibekwe@users.noreply.github.com> Date: Mon, 3 Nov 2025 08:34:55 -0800 Subject: [PATCH 01/15] .NET: Peibekwe/workflows cancellation token fix (#1740) * Propagate cancellation token down the stack * Added unit tests to cover workflow cancellation scenarios * Updated tests based on feedback to simplify assert. * Create custom AsyncEnumrable to gracefully handle cancellation for Channel reader. Tailor cancellation tests to declarative scenarios. * Update comment and naming for readability. * Fixing minor stylistic recommendation. --------- Co-authored-by: Chris <66376200+crickman@users.noreply.github.com> --- ...NonThrowingChannelReaderAsyncEnumerable.cs | 61 ++++++++++++++++++ .../Execution/StreamingRunEventStream.cs | 8 +-- .../DeclarativeWorkflowTest.cs | 63 +++++++++++++++++-- 3 files changed, 121 insertions(+), 11 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows/Execution/NonThrowingChannelReaderAsyncEnumerable.cs diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/NonThrowingChannelReaderAsyncEnumerable.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/NonThrowingChannelReaderAsyncEnumerable.cs new file mode 100644 index 0000000000..aaae42f2f1 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/NonThrowingChannelReaderAsyncEnumerable.cs @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.Workflows.Execution; + +/// +/// A custom IAsyncEnumerable implementation that reads from a ChannelReader, +/// and suppresses OperationCanceledException when the cancellation token is triggered. +/// +internal sealed class NonThrowingChannelReaderAsyncEnumerable(ChannelReader reader) : IAsyncEnumerable +{ + private class Enumerator(ChannelReader reader, CancellationToken cancellationToken) : IAsyncEnumerator + { + private T? _current; + public T Current => this._current ?? throw new InvalidOperationException("Enumeration not started."); + + public ValueTask DisposeAsync() + { + // no-op - the reader should not be disposed. + return default; + } + + /// + /// Moves to the next item in the channel. + /// + /// If successful, returns true, otherwise false. + public async ValueTask MoveNextAsync() + { + try + { + bool hasData = await reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false); + if (hasData) + { + this._current = await reader.ReadAsync(cancellationToken).ConfigureAwait(false); + return true; + } + } + catch (OperationCanceledException) + { + // Swallow cancellation exceptions to prevent throwing from the enumerator + // Enables clean cancellation and aligns with the expected behavior of IAsyncEnumerable. + } + + return false; + } + } + + /// + /// Returns an async enumerator that reads items from the channel. + /// If cancellation is requested, the enumeration exits silently without throwing. + /// + /// An optional cancellation token from the caller. + /// An async enumerator over the channel items. + public IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) + => new Enumerator(reader, cancellationToken); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/StreamingRunEventStream.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/StreamingRunEventStream.cs index 718ebcd11c..e6afbb5440 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/StreamingRunEventStream.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/StreamingRunEventStream.cs @@ -139,11 +139,9 @@ internal sealed class StreamingRunEventStream : IRunEventStream // Get the current epoch - we'll only respond to completion signals from this epoch or later int myEpoch = Volatile.Read(ref this._completionEpoch) + 1; - // Simply read from channel - all coordination is handled by Channel infrastructure - // Note: When cancellation is requested, ReadAllAsync may throw OperationCanceledException - // or may complete the enumeration. We check IsCancellationRequested explicitly at superstep - // boundaries to ensure clean cancellation. - await foreach (WorkflowEvent evt in this._eventChannel.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false)) + // Use custom async enumerable to avoid exceptions on cancellation. + NonThrowingChannelReaderAsyncEnumerable eventStream = new(this._eventChannel.Reader); + await foreach (WorkflowEvent evt in eventStream.WithCancellation(cancellationToken).ConfigureAwait(false)) { // Filter out internal signals used for run loop coordination if (evt is InternalHaltSignal completionSignal) diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs index ae650352c5..6f2282902f 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs @@ -225,6 +225,54 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow Assert.True(visitor.HasUnsupportedActions); } + [Theory] + [InlineData("CaseInsensitive.yaml", "end_when_match")] + [InlineData("ClearAllVariables.yaml", "clear_all")] + [InlineData("Condition.yaml", "setVariable_test")] + [InlineData("ConditionElse.yaml", "setVariable_test")] + [InlineData("EndConversation.yaml", "end_all")] + [InlineData("EndDialog.yaml", "end_all")] + [InlineData("EditTable.yaml", "edit_var")] + [InlineData("EditTableV2.yaml", "edit_var")] + [InlineData("Goto.yaml", "goto_end")] + [InlineData("LoopBreak.yaml", "break_loop_now")] + [InlineData("LoopContinue.yaml", "foreach_loop")] + [InlineData("LoopEach.yaml", "foreach_loop")] + [InlineData("MixedScopes.yaml", "activity_input")] + [InlineData("ParseValue.yaml", "parse_var")] + [InlineData("ParseValueList.yaml", "parse_var")] + [InlineData("ResetVariable.yaml", "clear_var")] + [InlineData("SendActivity.yaml", "activity_input")] + [InlineData("SetVariable.yaml", "set_var")] + [InlineData("SetTextVariable.yaml", "set_text")] + public async Task CancelRunAsync(string workflowPath, string expectedExecutedId) + { + // Arrange + const string WorkflowInput = "Test input message"; + Workflow workflow = this.CreateWorkflow(workflowPath, WorkflowInput); + await using StreamingRun run = await InProcessExecution.StreamAsync(workflow: workflow, input: WorkflowInput); + + // Act + await foreach (WorkflowEvent workflowEvent in run.WatchStreamAsync()) + { + this.WorkflowEvents.Add(workflowEvent); + + if (workflowEvent is DeclarativeActionInvokedEvent actionInvokedEvent && actionInvokedEvent.ActionId == expectedExecutedId) + { + // Cancel run after the specified declarative action is invoked. + await run.CancelRunAsync(); + } + } + RunStatus currentRunStatus = await run.GetStatusAsync(); + this.WorkflowEventCounts = this.WorkflowEvents.GroupBy(e => e.GetType()).ToDictionary(e => e.Key, e => e.Count()); + + // Assert + Assert.Equal(expected: RunStatus.Ended, actual: currentRunStatus); + Assert.NotEmpty(this.WorkflowEventCounts); + Assert.Contains(this.WorkflowEvents.OfType(), e => e.ActionId == expectedExecutedId); + Assert.DoesNotContain(this.WorkflowEvents.OfType(), e => e.ActionId == expectedExecutedId); + } + private void AssertExecutionCount(int expectedCount) { Assert.Equal(expectedCount + 2, this.WorkflowEventCounts[typeof(ExecutorInvokedEvent)]); @@ -256,12 +304,7 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow private async Task RunWorkflowAsync(string workflowPath, TInput workflowInput) where TInput : notnull { - using StreamReader yamlReader = File.OpenText(Path.Combine("Workflows", workflowPath)); - Mock mockAgentProvider = CreateMockProvider($"{workflowInput}"); - DeclarativeWorkflowOptions workflowContext = new(mockAgentProvider.Object) { LoggerFactory = this.Output }; - - Workflow workflow = DeclarativeWorkflowBuilder.Build(yamlReader, workflowContext); - + Workflow workflow = this.CreateWorkflow(workflowPath, workflowInput); await using StreamingRun run = await InProcessExecution.StreamAsync(workflow, workflowInput); await foreach (WorkflowEvent workflowEvent in run.WatchStreamAsync()) @@ -303,6 +346,14 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow this.WorkflowEventCounts = this.WorkflowEvents.GroupBy(e => e.GetType()).ToDictionary(e => e.Key, e => e.Count()); } + private Workflow CreateWorkflow(string workflowPath, TInput workflowInput) where TInput : notnull + { + using StreamReader yamlReader = File.OpenText(Path.Combine("Workflows", workflowPath)); + Mock mockAgentProvider = CreateMockProvider($"{workflowInput}"); + DeclarativeWorkflowOptions workflowContext = new(mockAgentProvider.Object) { LoggerFactory = this.Output }; + return DeclarativeWorkflowBuilder.Build(yamlReader, workflowContext); + } + private static Mock CreateMockProvider(string input) { Mock mockAgentProvider = new(MockBehavior.Strict); From 91c66f8a2c68c4f283b24fa02ee32d8570af1545 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Date: Mon, 3 Nov 2025 17:53:38 +0000 Subject: [PATCH 02/15] .NET: Sample demonstrating the use of agents in workflows (#1836) * add sample demonstrating the use of agents in workflows * Update dotnet/samples/Catalog/AgentsInWorkflows/README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * use type instead of var --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- dotnet/agent-framework-dotnet.slnx | 1 + .../AgentsInWorkflows.csproj | 23 +++++++++ .../Catalog/AgentsInWorkflows/Program.cs | 48 +++++++++++++++++++ .../Catalog/AgentsInWorkflows/README.md | 26 ++++++++++ 4 files changed, 98 insertions(+) create mode 100644 dotnet/samples/Catalog/AgentsInWorkflows/AgentsInWorkflows.csproj create mode 100644 dotnet/samples/Catalog/AgentsInWorkflows/Program.cs create mode 100644 dotnet/samples/Catalog/AgentsInWorkflows/README.md diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 95812435d8..cbda6c2809 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -137,6 +137,7 @@ + diff --git a/dotnet/samples/Catalog/AgentsInWorkflows/AgentsInWorkflows.csproj b/dotnet/samples/Catalog/AgentsInWorkflows/AgentsInWorkflows.csproj new file mode 100644 index 0000000000..284ea6ceec --- /dev/null +++ b/dotnet/samples/Catalog/AgentsInWorkflows/AgentsInWorkflows.csproj @@ -0,0 +1,23 @@ + + + + Exe + net9.0 + + enable + enable + + + + + + + + + + + + + + + diff --git a/dotnet/samples/Catalog/AgentsInWorkflows/Program.cs b/dotnet/samples/Catalog/AgentsInWorkflows/Program.cs new file mode 100644 index 0000000000..3e01f6e717 --- /dev/null +++ b/dotnet/samples/Catalog/AgentsInWorkflows/Program.cs @@ -0,0 +1,48 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample demonstrates how to integrate AI agents into a workflow pipeline. +// Three translation agents are connected sequentially to create a translation chain: +// English → French → Spanish → English, showing how agents can be composed as workflow executors. + +using Azure.AI.OpenAI; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.AI; + +// Set up the Azure OpenAI client +var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); +var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; + +IChatClient chatClient = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()) + .GetChatClient(deploymentName) + .AsIChatClient(); + +// Create agents +AIAgent frenchAgent = GetTranslationAgent("French", chatClient); +AIAgent spanishAgent = GetTranslationAgent("Spanish", chatClient); +AIAgent englishAgent = GetTranslationAgent("English", chatClient); + +// Build the workflow by adding executors and connecting them +Workflow workflow = new WorkflowBuilder(frenchAgent) + .AddEdge(frenchAgent, spanishAgent) + .AddEdge(spanishAgent, englishAgent) +.Build(); + +// Execute the workflow +await using StreamingRun run = await InProcessExecution.StreamAsync(workflow, new ChatMessage(ChatRole.User, "Hello World!")); + +// Must send the turn token to trigger the agents. +// The agents are wrapped as executors. When they receive messages, +// they will cache the messages and only start processing when they receive a TurnToken. +await run.TrySendMessageAsync(new TurnToken(emitEvents: true)); +await foreach (WorkflowEvent evt in run.WatchStreamAsync()) +{ + if (evt is AgentRunUpdateEvent executorComplete) + { + Console.WriteLine($"{executorComplete.ExecutorId}: {executorComplete.Data}"); + } +} + +static ChatClientAgent GetTranslationAgent(string targetLanguage, IChatClient chatClient) => + new(chatClient, $"You are a translation assistant that translates the provided text to {targetLanguage}."); diff --git a/dotnet/samples/Catalog/AgentsInWorkflows/README.md b/dotnet/samples/Catalog/AgentsInWorkflows/README.md new file mode 100644 index 0000000000..a92012157e --- /dev/null +++ b/dotnet/samples/Catalog/AgentsInWorkflows/README.md @@ -0,0 +1,26 @@ +# What this sample demonstrates + +This sample demonstrates the use of AI agents as executors within a workflow. + +This workflow uses three translation agents: +1. French Agent - translates input text to French +2. Spanish Agent - translates French text to Spanish +3. English Agent - translates Spanish text back to English + +The agents are connected sequentially, creating a translation chain that demonstrates how AI-powered components can be seamlessly integrated into workflow pipelines. + +## Prerequisites + +Before you begin, ensure you have the following prerequisites: + +- .NET 8.0 SDK or later +- Azure OpenAI service endpoint and deployment configured +- Azure CLI installed and authenticated (for Azure credential authentication) + +**Note**: This demo uses Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Azure OpenAI resource. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively). + +Set the following environment variables: + +```powershell +$env:AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/" # Replace with your Azure OpenAI resource endpoint +$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini \ No newline at end of file From b25b0af49b355f55330957790d62914bb3f4944e Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Mon, 3 Nov 2025 13:20:35 -0500 Subject: [PATCH 03/15] .NET: [BREAKING] Unify ExecutorIsh and ExecutorRegistration, unify/simplify APIs (#1637) * refactor: Unify ExecutorIsh and ExecutorRegistration => ExecutorBinding * Switch to more modern Record type-tree for Sum Types * Unify APIs for getting ExecutorBinding * Fix an issue where workflows consisting entirely of cross-run shareable executors which are not instance-resettable do not properly clear state when running non-concurrently. * feat: Simplify function-to-executor pattern * refactor: Normalize API naming --- .../01_ExecutorsAndEdges/Program.cs | 21 +- .../_Foundational/06_SubWorkflows/Program.cs | 2 +- .../README.md | 2 +- .../Interpreter/WorkflowModelBuilder.cs | 6 +- .../AIAgentBinding.cs | 27 ++ .../AgentWorkflowBuilder.cs | 8 +- .../Checkpointing/ExecutorInfo.cs | 6 +- .../Checkpointing/RepresentationExtensions.cs | 10 +- .../Checkpointing/WorkflowInfo.cs | 6 +- .../ConfiguredExecutorBinding.cs | 34 ++ .../Microsoft.Agents.AI.Workflows/Executor.cs | 2 + .../ExecutorBinding.cs | 129 ++++++ .../ExecutorBindingExtensions.cs | 434 ++++++++++++++++++ .../ExecutorInstanceBinding.cs | 39 ++ .../ExecutorIsh.cs | 369 --------------- .../ExecutorPlaceholder.cs | 23 + .../ExecutorRegistration.cs | 72 --- .../FunctionExecutor.cs | 8 +- .../GroupChatWorkflowBuilder.cs | 4 +- .../InProc/InProcessRunnerContext.cs | 2 +- .../RequestPortBinding.cs | 29 ++ .../Specialized/GroupChatHost.cs | 4 +- .../SubworkflowBinding.cs | 44 ++ .../SwitchBuilder.cs | 12 +- .../Visualization/WorkflowVisualizer.cs | 10 +- .../Microsoft.Agents.AI.Workflows/Workflow.cs | 20 +- .../WorkflowBuilder.cs | 75 ++- .../WorkflowBuilderExtensions.cs | 16 +- .../JsonSerializationTests.cs | 2 +- .../RepresentationTests.cs | 40 +- .../07_GroupChat_Workflow_HostAsAgent.cs | 20 +- .../Sample/08_Subworkflow_Simple.cs | 6 +- .../Sample/09_Subworkflow_ExternalRequest.cs | 32 +- .../SampleSmokeTest.cs | 2 + .../WorkflowBuilderSmokeTests.cs | 18 +- 35 files changed, 939 insertions(+), 595 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows/AIAgentBinding.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows/ConfiguredExecutorBinding.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows/ExecutorBinding.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows/ExecutorBindingExtensions.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows/ExecutorInstanceBinding.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows/ExecutorIsh.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows/ExecutorPlaceholder.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows/ExecutorRegistration.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows/RequestPortBinding.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows/SubworkflowBinding.cs diff --git a/dotnet/samples/GettingStarted/Workflows/_Foundational/01_ExecutorsAndEdges/Program.cs b/dotnet/samples/GettingStarted/Workflows/_Foundational/01_ExecutorsAndEdges/Program.cs index 68e2effd04..af1dcb50d9 100644 --- a/dotnet/samples/GettingStarted/Workflows/_Foundational/01_ExecutorsAndEdges/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/_Foundational/01_ExecutorsAndEdges/Program.cs @@ -20,7 +20,9 @@ public static class Program private static async Task Main() { // Create the executors - UppercaseExecutor uppercase = new(); + Func uppercaseFunc = s => s.ToUpperInvariant(); + var uppercase = uppercaseFunc.BindAsExecutor("UppercaseExecutor"); + ReverseTextExecutor reverse = new(); // Build the workflow by connecting executors sequentially @@ -40,23 +42,6 @@ public static class Program } } -/// -/// First executor: converts input text to uppercase. -/// -internal sealed class UppercaseExecutor() : Executor("UppercaseExecutor") -{ - /// - /// Processes the input message by converting it to uppercase. - /// - /// The input text to convert - /// Workflow context for accessing workflow services and adding events - /// The to monitor for cancellation requests. - /// The default is . - /// The input text converted to uppercase - public override ValueTask HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default) => - ValueTask.FromResult(message.ToUpperInvariant()); // The return value will be sent as a message along an edge to subsequent executors -} - /// /// Second executor: reverses the input text and completes the workflow. /// diff --git a/dotnet/samples/GettingStarted/Workflows/_Foundational/06_SubWorkflows/Program.cs b/dotnet/samples/GettingStarted/Workflows/_Foundational/06_SubWorkflows/Program.cs index de00c35ae8..7f9980e047 100644 --- a/dotnet/samples/GettingStarted/Workflows/_Foundational/06_SubWorkflows/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/_Foundational/06_SubWorkflows/Program.cs @@ -40,7 +40,7 @@ public static class Program .Build(); // Step 2: Configure the sub-workflow as an executor for use in the parent workflow - ExecutorIsh subWorkflowExecutor = subWorkflow.ConfigureSubWorkflow("TextProcessingSubWorkflow"); + ExecutorBinding subWorkflowExecutor = subWorkflow.BindAsExecutor("TextProcessingSubWorkflow"); // Step 3: Build a main workflow that uses the sub-workflow as an executor Console.WriteLine("Building main workflow that uses the sub-workflow as an executor...\n"); diff --git a/dotnet/samples/GettingStarted/Workflows/_Foundational/07_MixedWorkflowAgentsAndExecutors/README.md b/dotnet/samples/GettingStarted/Workflows/_Foundational/07_MixedWorkflowAgentsAndExecutors/README.md index 1fd263888b..4ec203892b 100644 --- a/dotnet/samples/GettingStarted/Workflows/_Foundational/07_MixedWorkflowAgentsAndExecutors/README.md +++ b/dotnet/samples/GettingStarted/Workflows/_Foundational/07_MixedWorkflowAgentsAndExecutors/README.md @@ -138,7 +138,7 @@ I cannot process this request as it appears to contain unsafe content. ## What You'll Learn -1. **How to mix executors and agents** - Understanding that both are treated as `ExecutorIsh` internally +1. **How to mix executors and agents** - Understanding that both are treated as `ExecutorBinding` internally 2. **When to use executors vs agents** - Executors for deterministic logic, agents for AI-powered decisions 3. **How to process agent outputs** - Using executors to sync, format, or aggregate agent responses 4. **Building complex pipelines** - Chaining multiple heterogeneous components together diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowModelBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowModelBuilder.cs index 1d3826371d..d70b0841ce 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowModelBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowModelBuilder.cs @@ -19,12 +19,12 @@ internal sealed class WorkflowModelBuilder : IModelBuilder> Debug.WriteLine($"> CONNECT: {source.Id} => {target.Id}{(condition is null ? string.Empty : " (?)")}"); this.WorkflowBuilder.AddEdge( - GetExecutorIsh(source), - GetExecutorIsh(target), + GetExecutorBinding(source), + GetExecutorBinding(target), condition); } - private static ExecutorIsh GetExecutorIsh(IModeledAction action) => + private static ExecutorBinding GetExecutorBinding(IModeledAction action) => action switch { RequestPortAction port => port.RequestPort, diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/AIAgentBinding.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/AIAgentBinding.cs new file mode 100644 index 0000000000..171f7d9eec --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/AIAgentBinding.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.Workflows.Specialized; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Workflows; + +/// +/// Represents the workflow binding details for an AI agent, including configuration options for event emission. +/// +/// The AI agent. +/// Specifies whether the agent should emit events. If null, the default behavior is applied. +public record AIAgentBinding(AIAgent Agent, bool EmitEvents = false) + : ExecutorBinding(Throw.IfNull(Agent).Name ?? Throw.IfNull(Agent.Id), + (_) => new(new AIAgentHostExecutor(Agent, EmitEvents)), + typeof(AIAgentHostExecutor), + Agent) +{ + /// + public override bool IsSharedInstance => false; + + /// + public override bool SupportsConcurrentSharedExecution => true; + + /// + public override bool SupportsResetting => false; +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/AgentWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/AgentWorkflowBuilder.cs index 8b527d5c44..81d254aa53 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/AgentWorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/AgentWorkflowBuilder.cs @@ -40,7 +40,7 @@ public static partial class AgentWorkflowBuilder // Create a builder that chains the agents together in sequence. The workflow simply begins // with the first agent in the sequence. WorkflowBuilder? builder = null; - ExecutorIsh? previous = null; + ExecutorBinding? previous = null; foreach (var agent in agents) { AgentRunStreamingExecutor agentExecutor = new(agent, includeInputInOutput: true); @@ -125,8 +125,8 @@ public static partial class AgentWorkflowBuilder // so that the final accumulator receives a single list of messages from each agent. Otherwise, the // accumulator would not be able to determine what came from what agent, as there's currently no // provenance tracking exposed in the workflow context passed to a handler. - ExecutorIsh[] agentExecutors = (from agent in agents select (ExecutorIsh)new AgentRunStreamingExecutor(agent, includeInputInOutput: false)).ToArray(); - ExecutorIsh[] accumulators = [.. from agent in agentExecutors select (ExecutorIsh)new CollectChatMessagesExecutor($"Batcher/{agent.Id}")]; + ExecutorBinding[] agentExecutors = (from agent in agents select (ExecutorBinding)new AgentRunStreamingExecutor(agent, includeInputInOutput: false)).ToArray(); + ExecutorBinding[] accumulators = [.. from agent in agentExecutors select (ExecutorBinding)new CollectChatMessagesExecutor($"Batcher/{agent.Id}")]; builder.AddFanOutEdge(start, targets: agentExecutors); for (int i = 0; i < agentExecutors.Length; i++) { @@ -141,7 +141,7 @@ public static partial class AgentWorkflowBuilder Func> endFactory = (string _, string __) => new(new ConcurrentEndExecutor(agentExecutors.Length, aggregator)); - ExecutorIsh end = endFactory.ConfigureFactory(ConcurrentEndExecutor.ExecutorId); + ExecutorBinding end = endFactory.BindExecutor(ConcurrentEndExecutor.ExecutorId); builder.AddFanInEdge(end, sources: accumulators); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/ExecutorInfo.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/ExecutorInfo.cs index c9994a91d0..6e019b4928 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/ExecutorInfo.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/ExecutorInfo.cs @@ -12,7 +12,7 @@ internal sealed record class ExecutorInfo(TypeId ExecutorType, string ExecutorId this.ExecutorType.IsMatch(executor.GetType()) && this.ExecutorId == executor.Id; - public bool IsMatch(ExecutorRegistration registration) => - this.ExecutorType.IsMatch(registration.ExecutorType) - && this.ExecutorId == registration.Id; + public bool IsMatch(ExecutorBinding binding) => + this.ExecutorType.IsMatch(binding.ExecutorType) + && this.ExecutorId == binding.Id; } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/RepresentationExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/RepresentationExtensions.cs index 1878cbdd21..3d76c965bd 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/RepresentationExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/RepresentationExtensions.cs @@ -9,10 +9,10 @@ namespace Microsoft.Agents.AI.Workflows.Checkpointing; internal static class RepresentationExtensions { - public static ExecutorInfo ToExecutorInfo(this ExecutorRegistration registration) + public static ExecutorInfo ToExecutorInfo(this ExecutorBinding binding) { - Throw.IfNull(registration); - return new ExecutorInfo(new TypeId(registration.ExecutorType), registration.Id); + Throw.IfNull(binding); + return new ExecutorInfo(new TypeId(binding.ExecutorType), binding.Id); } public static EdgeInfo ToEdgeInfo(this Edge edge) @@ -38,8 +38,8 @@ internal static class RepresentationExtensions Throw.IfNull(workflow); Dictionary executors = - workflow.Registrations.Values.ToDictionary( - keySelector: registration => registration.Id, + workflow.ExecutorBindings.Values.ToDictionary( + keySelector: binding => binding.Id, elementSelector: ToExecutorInfo); Dictionary> edges = workflow.Edges.Keys.ToDictionary( diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/WorkflowInfo.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/WorkflowInfo.cs index 59a41db405..f40882265a 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/WorkflowInfo.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/WorkflowInfo.cs @@ -47,10 +47,10 @@ internal sealed class WorkflowInfo } // Validate the executors - if (workflow.Registrations.Count != this.Executors.Count || + if (workflow.ExecutorBindings.Count != this.Executors.Count || this.Executors.Keys.Any( - executorId => workflow.Registrations.TryGetValue(executorId, out ExecutorRegistration? registration) - && !this.Executors[executorId].IsMatch(registration))) + executorId => workflow.ExecutorBindings.TryGetValue(executorId, out ExecutorBinding? binding) + && !this.Executors[executorId].IsMatch(binding))) { return false; } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/ConfiguredExecutorBinding.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/ConfiguredExecutorBinding.cs new file mode 100644 index 0000000000..cfbfeba355 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/ConfiguredExecutorBinding.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Workflows; + +// TODO: Unwrap the Configured object, just like for SubworkflowBinding +internal record ConfiguredExecutorBinding(Configured ConfiguredExecutor, Type ExecutorType) + : ExecutorBinding(Throw.IfNull(ConfiguredExecutor).Id, + ConfiguredExecutor.BoundFactoryAsync, + ExecutorType, + ConfiguredExecutor.Raw) +{ + /// + public override bool IsSharedInstance { get; } = ConfiguredExecutor.Raw is Executor; + + protected override async ValueTask ResetCoreAsync() + { + if (this.ConfiguredExecutor.Raw is IResettableExecutor resettable) + { + await resettable.ResetAsync().ConfigureAwait(false); + } + + return false; + } + + /// + public override bool SupportsConcurrentSharedExecution => true; + + /// + public override bool SupportsResetting => false; +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Executor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Executor.cs index 4c2821476a..e0b53429f9 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Executor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Executor.cs @@ -27,6 +27,8 @@ public abstract class Executor : IIdentified private static readonly string s_namespace = typeof(Executor).Namespace!; private static readonly ActivitySource s_activitySource = new(s_namespace); + // TODO: Add overloads for binding with a configuration/options object once the Configured hierarchy goes away. + /// /// Initialize the executor with a unique identifier /// diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/ExecutorBinding.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/ExecutorBinding.cs new file mode 100644 index 0000000000..f4c196426c --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/ExecutorBinding.cs @@ -0,0 +1,129 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.Workflows; + +/// +/// Represents the binding information for a workflow executor, including its identifier, factory method, type, and +/// optional raw value. +/// +/// The unique identifier for the executor in the workflow. +/// A factory function that creates an instance of the executor. The function accepts two string parameters and returns +/// a ValueTask containing the created Executor instance. +/// The type of the executor. Must be a type derived from Executor. +/// An optional raw value associated with the binding. +public abstract record class ExecutorBinding(string Id, Func>? FactoryAsync, Type ExecutorType, object? RawValue = null) + : IIdentified, + IEquatable, + IEquatable +{ + /// + /// Gets a value indicating whether the binding is a placeholder (i.e., does not have a factory method defined). + /// + [MemberNotNullWhen(false, nameof(FactoryAsync))] + public bool IsPlaceholder => this.FactoryAsync == null; + + /// + /// Gets a value whether the executor created from this binding is a shared instance across all runs. + /// + public abstract bool IsSharedInstance { get; } + + /// + /// Gets a value whether instances of the executor created from this binding can be used in concurrent runs + /// from the same instance. + /// + public abstract bool SupportsConcurrentSharedExecution { get; } + + /// + /// Gets a value whether instances of the executor created from this binding can be reset between subsequent + /// runs from the same instance. This value is not relevant for executors that . + /// + public abstract bool SupportsResetting { get; } + + /// + public override string ToString() => $"{this.Id}:{(this.IsPlaceholder ? ":" : this.ExecutorType.Name)}"; + + private Executor CheckId(Executor executor) + { + if (executor.Id != this.Id) + { + throw new InvalidOperationException( + $"Executor ID mismatch: expected '{this.Id}', but got '{executor.Id}'."); + } + + return executor; + } + + internal async ValueTask CreateInstanceAsync(string runId) + => !this.IsPlaceholder + ? this.CheckId(await this.FactoryAsync(runId).ConfigureAwait(false)) + : throw new InvalidOperationException( + $"Cannot create executor with ID '{this.Id}': Binding ({this.GetType().Name}) is a placeholder."); + + /// + public virtual bool Equals(ExecutorBinding? other) => + other is not null && other.Id == this.Id; + + /// + public bool Equals(IIdentified? other) => + other is not null && other.Id == this.Id; + + /// + public bool Equals(string? other) => + other is not null && other == this.Id; + + internal ValueTask TryResetAsync() + { + // Non-shared instances do not need resetting + if (!this.IsSharedInstance) + { + return new(true); + } + + // If the executor supports concurrent use, then resetting is a no-op. + if (!this.SupportsResetting) + { + return new(false); + } + + return this.ResetCoreAsync(); + } + + /// + /// Resets the executor's shared resources to their initial state. Must be overridden by bindings that support + /// resetting. + /// + /// + protected virtual ValueTask ResetCoreAsync() => throw new InvalidOperationException("ExecutorBindings that support resetting must override ResetCoreAsync()"); + + /// + public override int GetHashCode() => this.Id.GetHashCode(); + + /// + /// Defines an implicit conversion from an Executor to a . + /// + /// The Executor instance to convert. + public static implicit operator ExecutorBinding(Executor executor) => executor.BindExecutor(); + + /// + /// Defines an implicit conversion from a string identifier to an . + /// + /// The string identifier to convert to a placeholder. + public static implicit operator ExecutorBinding(string id) => new ExecutorPlaceholder(id); + + /// + /// Defines an implicit conversion from a to an . + /// + /// The RequestPort instance to convert. + public static implicit operator ExecutorBinding(RequestPort port) => port.BindAsExecutor(); + + /// + /// Defines an implicit conversion from an to an instance. + /// + /// + public static implicit operator ExecutorBinding(AIAgent agent) => agent.BindAsExecutor(); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/ExecutorBindingExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/ExecutorBindingExtensions.cs new file mode 100644 index 0000000000..5a5e197541 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/ExecutorBindingExtensions.cs @@ -0,0 +1,434 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.ComponentModel; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Workflows; + +/// +/// Extension methods for configuring executors and functions as instances. +/// +public static class ExecutorBindingExtensions +{ + /// + /// Configures an instance for use in a workflow. + /// + /// + /// Note that Executor Ids must be unique within a workflow. + /// + /// The executor instance. + /// An instance wrapping the specified . + public static ExecutorBinding BindExecutor(this Executor executor) + => new ExecutorInstanceBinding(executor); + + /// + /// Configures a factory method for creating an of type , using the + /// type name as the id. + /// + /// + /// Note that Executor Ids must be unique within a workflow. + /// + /// Although this will generally result in a delay-instantiated once messages are available + /// for it, it will be instantiated if a for the is requested, + /// and it is the starting executor. + /// + /// The type of the resulting executor + /// The factory method. + /// An instance that resolves to the result of the factory call when messages get sent to it. + public static ExecutorBinding BindExecutor(this Func> factoryAsync) + where TExecutor : Executor + => BindExecutor((config, runId) => factoryAsync(config.Id, runId), id: typeof(TExecutor).Name, options: null); + + /// + /// Configures a factory method for creating an of type , using the + /// type name as the id. + /// + /// + /// Note that Executor Ids must be unique within a workflow. + /// + /// Although this will generally result in a delay-instantiated once messages are available + /// for it, it will be instantiated if a for the is requested, + /// and it is the starting executor. + /// + /// The type of the resulting executor + /// The factory method. + /// An instance that resolves to the result of the factory call when messages get sent to it. + [Obsolete("Use BindExecutor() instead.")] + [EditorBrowsable(EditorBrowsableState.Never)] + public static ExecutorBinding ConfigureFactory(this Func> factoryAsync) + where TExecutor : Executor + => factoryAsync.BindExecutor(); + + /// + /// Configures a factory method for creating an of type , with + /// the specified id. + /// + /// + /// Although this will generally result in a delay-instantiated once messages are available + /// for it, it will be instantiated if a for the is requested, + /// and it is the starting executor. + /// + /// The type of the resulting executor + /// The factory method. + /// An id for the executor to be instantiated. + /// An instance that resolves to the result of the factory call when messages get sent to it. + public static ExecutorBinding BindExecutor(this Func> factoryAsync, string id) + where TExecutor : Executor + => BindExecutor((_, runId) => factoryAsync(id, runId), id, options: null); + + /// + /// Configures a factory method for creating an of type , with + /// the specified id. + /// + /// + /// Although this will generally result in a delay-instantiated once messages are available + /// for it, it will be instantiated if a for the is requested, + /// and it is the starting executor. + /// + /// The type of the resulting executor + /// The factory method. + /// An id for the executor to be instantiated. + /// An instance that resolves to the result of the factory call when messages get sent to it. + [Obsolete("Use BindExecutor() instead.")] + [EditorBrowsable(EditorBrowsableState.Never)] + public static ExecutorBinding ConfigureFactory(this Func> factoryAsync, string id) + where TExecutor : Executor + => factoryAsync.BindExecutor(id); + + /// + /// Configures a factory method for creating an of type , with + /// the specified id and options. + /// + /// + /// Although this will generally result in a delay-instantiated once messages are available + /// for it, it will be instantiated if a for the is requested, + /// and it is the starting executor. + /// + /// The type of the resulting executor + /// The type of options object to be passed to the factory method. + /// The factory method. + /// An id for the executor to be instantiated. + /// An optional parameter specifying the options. + /// An instance that resolves to the result of the factory call when messages get sent to it. + public static ExecutorBinding BindExecutor(this Func, string, ValueTask> factoryAsync, string id, TOptions? options = null) + where TExecutor : Executor + where TOptions : ExecutorOptions + { + Configured configured = new(factoryAsync, id, options); + + return new ConfiguredExecutorBinding(configured.Super(), typeof(TExecutor)); + } + + /// + /// Configures a factory method for creating an of type , with + /// the specified id and options. + /// + /// + /// Although this will generally result in a delay-instantiated once messages are available + /// for it, it will be instantiated if a for the is requested, + /// and it is the starting executor. + /// + /// The type of the resulting executor + /// The type of options object to be passed to the factory method. + /// The factory method. + /// An id for the executor to be instantiated. + /// An optional parameter specifying the options. + /// An instance that resolves to the result of the factory call when messages get sent to it. + [Obsolete("Use BindExecutor() instead")] + [EditorBrowsable(EditorBrowsableState.Never)] + public static ExecutorBinding ConfigureFactory(this Func, string, ValueTask> factoryAsync, string id, TOptions? options = null) + where TExecutor : Executor + where TOptions : ExecutorOptions + => factoryAsync.BindExecutor(id, options); + + private static ConfiguredExecutorBinding ToBinding(this FunctionExecutor executor, Delegate raw) + => new(Configured.FromInstance(executor, raw: raw) + .Super, Executor>(), + typeof(FunctionExecutor)); + + private static ConfiguredExecutorBinding ToBinding(this FunctionExecutor executor, Delegate raw) + => new(Configured.FromInstance(executor, raw: raw) + .Super, Executor>(), + typeof(FunctionExecutor)); + + /// + /// Configures a sub-workflow executor for the specified workflow, using the provided identifier and options. + /// + /// The workflow instance to be executed as a sub-workflow. Cannot be null. + /// A unique identifier for the sub-workflow execution. Used to distinguish this sub-workflow instance. + /// Optional configuration options for the sub-workflow executor. If null, default options are used. + /// An ExecutorRegistration instance representing the configured sub-workflow executor. + [Obsolete("Use BindAsExecutor() instead")] + [EditorBrowsable(EditorBrowsableState.Never)] + public static ExecutorBinding ConfigureSubWorkflow(this Workflow workflow, string id, ExecutorOptions? options = null) + => workflow.BindAsExecutor(id, options); + + /// + /// Configures a sub-workflow executor for the specified workflow, using the provided identifier and options. + /// + /// The workflow instance to be executed as a sub-workflow. Cannot be null. + /// A unique identifier for the sub-workflow execution. Used to distinguish this sub-workflow instance. + /// Optional configuration options for the sub-workflow executor. If null, default options are used. + /// An instance representing the configured sub-workflow executor. + public static ExecutorBinding BindAsExecutor(this Workflow workflow, string id, ExecutorOptions? options = null) + => new SubworkflowBinding(workflow, id, options); + + /// + /// Configures a function-based asynchronous message handler as an executor with the specified identifier and + /// options. + /// + /// The type of input message. + /// A delegate that defines the asynchronous function to execute for each input message. + /// An optional unique identifier for the executor. If null, will use the function argument as an id. + /// Configuration options for the executor. If null, default options will be used. + /// Declare that the message handler may be used simultaneously by multiple runs concurrently. + /// An instance that wraps the provided asynchronous message handler and configuration. + public static ExecutorBinding BindAsExecutor(this Func messageHandlerAsync, string id, ExecutorOptions? options = null, bool threadsafe = false) + => new FunctionExecutor(id, messageHandlerAsync, options, declareCrossRunShareable: threadsafe).ToBinding(messageHandlerAsync); + + /// + /// Configures a function-based asynchronous message handler as an executor with the specified identifier and + /// options. + /// + /// The type of input message. + /// A delegate that defines the asynchronous function to execute for each input message. + /// An optional unique identifier for the executor. If null, will use the function argument as an id. + /// Configuration options for the executor. If null, default options will be used. + /// Declare that the message handler may be used simultaneously by multiple runs concurrently. + /// An instance that wraps the provided asynchronous message handler and configuration. + public static ExecutorBinding BindAsExecutor(this Func messageHandlerAsync, string id, ExecutorOptions? options = null, bool threadsafe = false) + => ((Func)((input, _, __) => messageHandlerAsync(input))) + .BindAsExecutor(id, options, threadsafe); + + /// + /// Configures a function-based asynchronous message handler as an executor with the specified identifier and + /// options. + /// + /// The type of input message. + /// A delegate that defines the asynchronous function to execute for each input message. + /// An optional unique identifier for the executor. If null, will use the function argument as an id. + /// Configuration options for the executor. If null, default options will be used. + /// Declare that the message handler may be used simultaneously by multiple runs concurrently. + /// An instance that wraps the provided asynchronous message handler and configuration. + public static ExecutorBinding BindAsExecutor(this Func messageHandlerAsync, string id, ExecutorOptions? options = null, bool threadsafe = false) + => ((Func)((input, ctx, __) => messageHandlerAsync(input, ctx))) + .BindAsExecutor(id, options, threadsafe); + + /// + /// Configures a function-based asynchronous message handler as an executor with the specified identifier and + /// options. + /// + /// The type of input message. + /// A delegate that defines the asynchronous function to execute for each input message. + /// An optional unique identifier for the executor. If null, will use the function argument as an id. + /// Configuration options for the executor. If null, default options will be used. + /// Declare that the message handler may be used simultaneously by multiple runs concurrently. + /// An instance that wraps the provided asynchronous message handler and configuration. + public static ExecutorBinding BindAsExecutor(this Func messageHandlerAsync, string id, ExecutorOptions? options = null, bool threadsafe = false) + => ((Func)((input, _, ct) => messageHandlerAsync(input, ct))) + .BindAsExecutor(id, options, threadsafe); + + /// + /// Configures a function-based message handler as an executor with the specified identifier and + /// options. + /// + /// The type of input message. + /// A delegate that defines the function to execute for each input message. + /// An optional unique identifier for the executor. If null, will use the function argument as an id. + /// Configuration options for the executor. If null, default options will be used. + /// Declare that the message handler may be used simultaneously by multiple runs concurrently. + /// An instance that wraps the provided asynchronous message handler and configuration. + public static ExecutorBinding BindAsExecutor(this Action messageHandler, string id, ExecutorOptions? options = null, bool threadsafe = false) + => new FunctionExecutor(id, messageHandler, options, declareCrossRunShareable: threadsafe).ToBinding(messageHandler); + + /// + /// Configures a function-based message handler as an executor with the specified identifier and + /// options. + /// + /// The type of input message. + /// A delegate that defines the function to execute for each input message. + /// An optional unique identifier for the executor. If null, will use the function argument as an id. + /// Configuration options for the executor. If null, default options will be used. + /// Declare that the message handler may be used simultaneously by multiple runs concurrently. + /// An instance that wraps the provided asynchronous message handler and configuration. + public static ExecutorBinding BindAsExecutor(this Action messageHandler, string id, ExecutorOptions? options = null, bool threadsafe = false) + => ((Action)((input, _, __) => messageHandler(input))) + .BindAsExecutor(id, options, threadsafe); + + /// + /// Configures a function-based message handler as an executor with the specified identifier and + /// options. + /// + /// The type of input message. + /// A delegate that defines the function to execute for each input message. + /// An optional unique identifier for the executor. If null, will use the function argument as an id. + /// Configuration options for the executor. If null, default options will be used. + /// Declare that the message handler may be used simultaneously by multiple runs concurrently. + /// An instance that wraps the provided asynchronous message handler and configuration. + public static ExecutorBinding BindAsExecutor(this Action messageHandler, string id, ExecutorOptions? options = null, bool threadsafe = false) + => ((Action)((input, ctx, __) => messageHandler(input, ctx))) + .BindAsExecutor(id, options, threadsafe); + + /// + /// Configures a function-based message handler as an executor with the specified identifier and + /// options. + /// + /// The type of input message. + /// A delegate that defines the function to execute for each input message. + /// An optional unique identifier for the executor. If null, will use the function argument as an id. + /// Configuration options for the executor. If null, default options will be used. + /// Declare that the message handler may be used simultaneously by multiple runs concurrently. + /// An instance that wraps the provided asynchronous message handler and configuration. + public static ExecutorBinding BindAsExecutor(this Action messageHandler, string id, ExecutorOptions? options = null, bool threadsafe = false) + => ((Action)((input, _, ct) => messageHandler(input, ct))) + .BindAsExecutor(id, options, threadsafe); + + /// + /// Configures a function-based asynchronous message handler as an executor with the specified identifier and + /// options. + /// + /// The type of input message. + /// The type of output message. + /// A delegate that defines the asynchronous function to execute for each input message. + /// A unique identifier for the executor. + /// Configuration options for the executor. If null, default options will be used. + /// Declare that the message handler may be used simultaneously by multiple runs concurrently. + /// An instance that wraps the provided asynchronous message handler and configuration. + public static ExecutorBinding BindAsExecutor(this Func> messageHandlerAsync, string id, ExecutorOptions? options = null, bool threadsafe = false) + => new FunctionExecutor(Throw.IfNull(id), messageHandlerAsync, options, declareCrossRunShareable: threadsafe).ToBinding(messageHandlerAsync); + + /// + /// Configures a function-based asynchronous message handler as an executor with the specified identifier and + /// options. + /// + /// The type of input message. + /// The type of output message. + /// A delegate that defines the asynchronous function to execute for each input message. + /// An optional unique identifier for the executor. If null, will use the function argument as an id. + /// Configuration options for the executor. If null, default options will be used. + /// Declare that the message handler may be used simultaneously by multiple runs concurrently. + /// An instance that wraps the provided asynchronous message handler and configuration. + public static ExecutorBinding BindAsExecutor(this Func> messageHandlerAsync, string id, ExecutorOptions? options = null, bool threadsafe = false) + => ((Func>)((input, _, __) => messageHandlerAsync(input))) + .BindAsExecutor(id, options, threadsafe); + + /// + /// Configures a function-based asynchronous message handler as an executor with the specified identifier and + /// options. + /// + /// The type of input message. + /// The type of output message. + /// A delegate that defines the asynchronous function to execute for each input message. + /// An optional unique identifier for the executor. If null, will use the function argument as an id. + /// Configuration options for the executor. If null, default options will be used. + /// Declare that the message handler may be used simultaneously by multiple runs concurrently. + /// An instance that wraps the provided asynchronous message handler and configuration. + public static ExecutorBinding BindAsExecutor(this Func> messageHandlerAsync, string id, ExecutorOptions? options = null, bool threadsafe = false) + => ((Func>)((input, ctx, __) => messageHandlerAsync(input, ctx))) + .BindAsExecutor(id, options, threadsafe); + + /// + /// Configures a function-based asynchronous message handler as an executor with the specified identifier and + /// options. + /// + /// The type of input message. + /// The type of output message. + /// A delegate that defines the asynchronous function to execute for each input message. + /// An optional unique identifier for the executor. If null, will use the function argument as an id. + /// Configuration options for the executor. If null, default options will be used. + /// Declare that the message handler may be used simultaneously by multiple runs concurrently. + /// An instance that wraps the provided asynchronous message handler and configuration. + public static ExecutorBinding BindAsExecutor(this Func> messageHandlerAsync, string id, ExecutorOptions? options = null, bool threadsafe = false) + => ((Func>)((input, _, ct) => messageHandlerAsync(input, ct))) + .BindAsExecutor(id, options, threadsafe); + + /// + /// Configures a function-based message handler as an executor with the specified identifier and options. + /// + /// The type of input message. + /// The type of output message. + /// A delegate that defines the function to execute for each input message. + /// An optional unique identifier for the executor. If null, will use the function argument as an id. + /// Configuration options for the executor. If null, default options will be used. + /// Declare that the message handler may be used simultaneously by multiple runs concurrently. + /// An instance that wraps the provided asynchronous message handler and configuration. + public static ExecutorBinding BindAsExecutor(this Func messageHandler, string id, ExecutorOptions? options = null, bool threadsafe = false) + => new FunctionExecutor(id, messageHandler, options, declareCrossRunShareable: threadsafe).ToBinding(messageHandler); + + /// + /// Configures a function-based message handler as an executor with the specified identifier and options. + /// + /// The type of input message. + /// The type of output message. + /// A delegate that defines the function to execute for each input message. + /// An optional unique identifier for the executor. If null, will use the function argument as an id. + /// Configuration options for the executor. If null, default options will be used. + /// Declare that the message handler may be used simultaneously by multiple runs concurrently. + /// An instance that wraps the provided asynchronous message handler and configuration. + public static ExecutorBinding BindAsExecutor(this Func messageHandler, string id, ExecutorOptions? options = null, bool threadsafe = false) + => ((Func)((input, _, __) => messageHandler(input))) + .BindAsExecutor(id, options, threadsafe); + + /// + /// Configures a function-based message handler as an executor with the specified identifier and options. + /// + /// The type of input message. + /// The type of output message. + /// A delegate that defines the function to execute for each input message. + /// An optional unique identifier for the executor. If null, will use the function argument as an id. + /// Configuration options for the executor. If null, default options will be used. + /// Declare that the message handler may be used simultaneously by multiple runs concurrently. + /// An instance that wraps the provided asynchronous message handler and configuration. + public static ExecutorBinding BindAsExecutor(this Func messageHandler, string id, ExecutorOptions? options = null, bool threadsafe = false) + => ((Func)((input, ctx, __) => messageHandler(input, ctx))) + .BindAsExecutor(id, options, threadsafe); + + /// + /// Configures a function-based message handler as an executor with the specified identifier and options. + /// + /// The type of input message. + /// The type of output message. + /// A delegate that defines the function to execute for each input message. + /// An optional unique identifier for the executor. If null, will use the function argument as an id. + /// Configuration options for the executor. If null, default options will be used. + /// Declare that the message handler may be used simultaneously by multiple runs concurrently. + /// An instance that wraps the provided asynchronous message handler and configuration. + public static ExecutorBinding BindAsExecutor(this Func messageHandler, string id, ExecutorOptions? options = null, bool threadsafe = false) + => ((Func)((input, _, ct) => messageHandler(input, ct))) + .BindAsExecutor(id, options, threadsafe); + + /// + /// Configures a function-based aggregating executor with the specified identifier and options. + /// + /// The type of input message. + /// The type of the accumulating object. + /// A delegate the defines the aggregation procedure + /// A unique identifier for the executor. + /// Configuration options for the executor. If null, default options will be used. + /// Declare that the message handler may be used simultaneously by multiple runs concurrently. + /// An instance that wraps the provided asynchronous message handler and configuration. + public static ExecutorBinding BindAsExecutor(this Func aggregatorFunc, string id, ExecutorOptions? options = null, bool threadsafe = false) + => new AggregatingExecutor(id, aggregatorFunc, options, declareCrossRunShareable: threadsafe); + + /// + /// Configure an as an executor for use in a workflow. + /// + /// The agent instance. + /// Specifies whether the agent should emit streaming events. + /// An instance that wraps the provided agent. + public static ExecutorBinding BindAsExecutor(this AIAgent agent, bool emitEvents = false) + => new AIAgentBinding(agent, emitEvents); + + /// + /// Configure a as an executor for use in a workflow. + /// + /// The port configuration. + /// Specifies whether the port should accept requests already wrapped in + /// . + /// A instance that wraps the provided port. + public static ExecutorBinding BindAsExecutor(this RequestPort port, bool allowWrappedRequests = true) + => new RequestPortBinding(port, allowWrappedRequests); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/ExecutorInstanceBinding.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/ExecutorInstanceBinding.cs new file mode 100644 index 0000000000..916e28a930 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/ExecutorInstanceBinding.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Workflows; + +/// +/// Represents the workflow binding details for a shared executor instance, including configuration options +/// for event emission. +/// +/// The executor instance to bind. Cannot be null. +public record ExecutorInstanceBinding(Executor ExecutorInstance) + : ExecutorBinding(Throw.IfNull(ExecutorInstance).Id, + (_) => new(ExecutorInstance), + ExecutorInstance.GetType(), + ExecutorInstance) +{ + /// + public override bool SupportsConcurrentSharedExecution => this.ExecutorInstance.IsCrossRunShareable; + + /// + public override bool SupportsResetting => this.ExecutorInstance is IResettableExecutor; + + /// + public override bool IsSharedInstance => true; + + /// + protected override async ValueTask ResetCoreAsync() + { + if (this.ExecutorInstance is IResettableExecutor resettable) + { + await resettable.ResetAsync().ConfigureAwait(false); + return true; + } + + return false; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/ExecutorIsh.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/ExecutorIsh.cs deleted file mode 100644 index 63807c5576..0000000000 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/ExecutorIsh.cs +++ /dev/null @@ -1,369 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Agents.AI.Workflows.Specialized; -using Microsoft.Shared.Diagnostics; - -namespace Microsoft.Agents.AI.Workflows; - -/// -/// Extension methods for configuring executors and functions as instances. -/// -public static class ExecutorIshConfigurationExtensions -{ - /// - /// Configures a factory method for creating an of type , using the - /// type name as the id. - /// - /// - /// Note that Executor Ids must be unique within a workflow. - /// - /// Although this will generally result in a delay-instantiated once messages are available - /// for it, it will be instantiated if a for the is requested, - /// and it is the starting executor. - /// - /// The type of the resulting executor - /// The factory method. - /// An ExecutorIsh instance that resolves to the result of the factory call when messages get sent to it. - public static ExecutorIsh ConfigureFactory(this Func> factoryAsync) - where TExecutor : Executor - => ConfigureFactory((config, runId) => factoryAsync(config.Id, runId), typeof(TExecutor).Name, options: null); - - /// - /// Configures a factory method for creating an of type , with - /// the specified id. - /// - /// - /// Although this will generally result in a delay-instantiated once messages are available - /// for it, it will be instantiated if a for the is requested, - /// and it is the starting executor. - /// - /// The type of the resulting executor - /// The factory method. - /// An id for the executor to be instantiated. - /// An ExecutorIsh instance that resolves to the result of the factory call when messages get sent to it. - public static ExecutorIsh ConfigureFactory(this Func> factoryAsync, string id) - where TExecutor : Executor - => ConfigureFactory((_, runId) => factoryAsync(id, runId), id, options: null); - - /// - /// Configures a factory method for creating an of type , with - /// the specified id and options. - /// - /// - /// Although this will generally result in a delay-instantiated once messages are available - /// for it, it will be instantiated if a for the is requested, - /// and it is the starting executor. - /// - /// The type of the resulting executor - /// The type of options object to be passed to the factory method. - /// The factory method. - /// An id for the executor to be instantiated. - /// An optional parameter specifying the options. - /// An ExecutorIsh instance that resolves to the result of the factory call when messages get sent to it. - public static ExecutorIsh ConfigureFactory(this Func, string, ValueTask> factoryAsync, string id, TOptions? options = null) - where TExecutor : Executor - where TOptions : ExecutorOptions - { - Configured configured = new(factoryAsync, id, options); - - return new ExecutorIsh(configured.Super(), typeof(TExecutor), ExecutorIsh.Type.Executor); - } - - private static ExecutorIsh ToExecutorIsh(this FunctionExecutor executor, Delegate raw) => new(Configured.FromInstance(executor, raw: raw) - .Super, Executor>(), - typeof(FunctionExecutor), - ExecutorIsh.Type.Function); - - private static ExecutorIsh ToExecutorIsh(this FunctionExecutor executor, Delegate raw) => new(Configured.FromInstance(executor, raw: raw) - .Super, Executor>(), - typeof(FunctionExecutor), - ExecutorIsh.Type.Function); - - /// - /// Configures a sub-workflow executor for the specified workflow, using the provided identifier and options. - /// - /// The workflow instance to be executed as a sub-workflow. Cannot be null. - /// A unique identifier for the sub-workflow execution. Used to distinguish this sub-workflow instance. - /// Optional configuration options for the sub-workflow executor. If null, default options are used. - /// An ExecutorIsh instance representing the configured sub-workflow executor. - public static ExecutorIsh ConfigureSubWorkflow(this Workflow workflow, string id, ExecutorOptions? options = null) - { - object ownershipToken = new(); - workflow.TakeOwnership(ownershipToken, subworkflow: true); - - Configured configured = new(InitHostExecutorAsync, id, options, raw: workflow); - return new ExecutorIsh(configured.Super(), typeof(WorkflowHostExecutor), ExecutorIsh.Type.Workflow); - - ValueTask InitHostExecutorAsync(Config config, string runId) - { - return new(new WorkflowHostExecutor(config.Id, workflow, runId, ownershipToken, config.Options)); - } - } - - /// - /// Configures a function-based asynchronous message handler as an executor with the specified identifier and - /// options. - /// - /// The type of input message. - /// A delegate that defines the asynchronous function to execute for each input message. - /// A optional unique identifier for the executor. If null, will use the function argument as an id. - /// Configuration options for the executor. If null, default options will be used. - /// Declare that the message handler may be used simultaneously by multiple runs concurrently. - /// An ExecutorIsh instance that wraps the provided asynchronous message handler and configuration. - public static ExecutorIsh AsExecutor(this Func messageHandlerAsync, string id, ExecutorOptions? options = null, bool threadsafe = false) - => new FunctionExecutor(id, messageHandlerAsync, options, declareCrossRunShareable: threadsafe).ToExecutorIsh(messageHandlerAsync); - - /// - /// Configures a function-based asynchronous message handler as an executor with the specified identifier and - /// options. - /// - /// The type of input message. - /// The type of output message. - /// A delegate that defines the asynchronous function to execute for each input message. - /// A unique identifier for the executor. - /// Configuration options for the executor. If null, default options will be used. - /// Declare that the message handler may be used simultaneously by multiple runs concurrently. - /// An ExecutorIsh instance that wraps the provided asynchronous message handler and configuration. - public static ExecutorIsh AsExecutor(this Func> messageHandlerAsync, string id, ExecutorOptions? options = null, bool threadsafe = false) - => new FunctionExecutor(Throw.IfNull(id), messageHandlerAsync, options, declareCrossRunShareable: threadsafe).ToExecutorIsh(messageHandlerAsync); - - /// - /// Configures a function-based aggregating executor with the specified identifier and options. - /// - /// The type of input message. - /// The type of the accumulating object. - /// A delegate the defines the aggregation procedure - /// A unique identifier for the executor. - /// Configuration options for the executor. If null, default options will be used. - /// Declare that the message handler may be used simultaneously by multiple runs concurrently. - /// An ExecutorIsh instance that wraps the provided asynchronous message handler and configuration. - public static ExecutorIsh AsExecutor(this Func aggregatorFunc, string id, ExecutorOptions? options = null, bool threadsafe = false) - => new AggregatingExecutor(id, aggregatorFunc, options, declareCrossRunShareable: threadsafe); -} - -/// -/// A tagged union representing an object that can function like an in a , -/// or a reference to one by ID. -/// -public sealed class ExecutorIsh : - IIdentified, - IEquatable, - IEquatable, - IEquatable -{ - /// - /// The type of the . - /// - public enum Type - { - /// - /// An unbound executor reference, identified only by ID. - /// - Unbound, - /// - /// An actual instance. - /// - Executor, - /// - /// A function delegate to be wrapped as an executor. - /// - Function, - /// - /// An for servicing external requests. - /// - RequestPort, - /// - /// An instance. - /// - Agent, - /// - /// A nested instance. - /// - Workflow, - } - - /// - /// Gets the type of data contained in this instance. - /// - public Type ExecutorType { get; init; } - - private readonly string? _idValue; - - private readonly Configured? _configuredExecutor; - private readonly System.Type? _configuredExecutorType; - - internal readonly RequestPort? _requestPortValue; - private readonly AIAgent? _aiAgentValue; - - /// - /// Initializes a new instance of the class as an unbound reference by ID. - /// - /// A unique identifier for an in the - public ExecutorIsh(string id) - { - this.ExecutorType = Type.Unbound; - this._idValue = Throw.IfNull(id); - } - - internal ExecutorIsh(Configured configured, System.Type configuredExecutorType, Type type) - { - this.ExecutorType = type; - this._configuredExecutor = configured; - this._configuredExecutorType = configuredExecutorType; - } - - /// - /// Initializes a new instance of the ExecutorIsh class using the specified executor. - /// - /// The executor instance to be wrapped. - public ExecutorIsh(Executor executor) - { - this.ExecutorType = Type.Executor; - this._configuredExecutor = Configured.FromInstance(Throw.IfNull(executor)); - this._configuredExecutorType = executor.GetType(); - } - - /// - /// Initializes a new instance of the ExecutorIsh class using the specified input port. - /// - /// The input port to associate to be wrapped. - public ExecutorIsh(RequestPort port) - { - this.ExecutorType = Type.RequestPort; - this._requestPortValue = Throw.IfNull(port); - } - - /// - /// Initializes a new instance of the ExecutorIsh class using the specified AI agent. - /// - /// - public ExecutorIsh(AIAgent aiAgent) - { - this.ExecutorType = Type.Agent; - this._aiAgentValue = Throw.IfNull(aiAgent); - } - - internal bool IsUnbound => this.ExecutorType == Type.Unbound; - - /// - public string Id => this.ExecutorType switch - { - Type.Unbound => this._idValue ?? throw new InvalidOperationException("This ExecutorIsh is unbound and has no ID."), - Type.Executor => this._configuredExecutor!.Id, - Type.RequestPort => this._requestPortValue!.Id, - Type.Agent => this._aiAgentValue!.Id, - Type.Function => this._configuredExecutor!.Id, - Type.Workflow => this._configuredExecutor!.Id, - _ => throw new InvalidOperationException($"Unknown ExecutorIsh type: {this.ExecutorType}") - }; - - internal object? RawData => this.ExecutorType switch - { - Type.Unbound => this._idValue, - Type.Executor => this._configuredExecutor!.Raw ?? this._configuredExecutor, - Type.RequestPort => this._requestPortValue, - Type.Agent => this._aiAgentValue, - Type.Function => this._configuredExecutor!.Raw ?? this._configuredExecutor, - Type.Workflow => this._configuredExecutor!.Raw ?? this._configuredExecutor, - _ => throw new InvalidOperationException($"Unknown ExecutorIsh type: {this.ExecutorType}") - }; - - /// - /// Gets the registration details for the current executor. - /// - /// The returned registration depends on the type of the executor. If the executor is unbound, an - /// is thrown. For other executor types, the registration includes the - /// appropriate ID, type, and provider based on the executor's configuration. - internal ExecutorRegistration Registration => new(this.Id, this.RuntimeType, this.ExecutorProvider, this.RawData); - - private System.Type RuntimeType => this.ExecutorType switch - { - Type.Unbound => throw new InvalidOperationException($"ExecutorIsh with ID '{this.Id}' is unbound."), - Type.Executor => this._configuredExecutorType!, - Type.RequestPort => typeof(RequestInfoExecutor), - Type.Agent => typeof(AIAgentHostExecutor), - Type.Function => this._configuredExecutorType!, - Type.Workflow => this._configuredExecutorType!, - _ => throw new InvalidOperationException($"Unknown ExecutorIsh type: {this.ExecutorType}") - }; - - /// - /// Gets an that can be used to obtain an instance - /// corresponding to this . - /// - private Func> ExecutorProvider => this.ExecutorType switch - { - Type.Unbound => throw new InvalidOperationException($"Executor with ID '{this.Id}' is unbound."), - Type.Executor => this._configuredExecutor!.BoundFactoryAsync, - Type.RequestPort => (runId) => new(new RequestInfoExecutor(this._requestPortValue!)), - Type.Agent => (runId) => new(new AIAgentHostExecutor(this._aiAgentValue!)), - Type.Function => this._configuredExecutor!.BoundFactoryAsync, - Type.Workflow => this._configuredExecutor!.BoundFactoryAsync, - _ => throw new InvalidOperationException($"Unknown ExecutorIsh type: {this.ExecutorType}") - }; - - /// - /// Defines an implicit conversion from an instance to an object. - /// - /// The instance to convert to . - public static implicit operator ExecutorIsh(Executor executor) => new(executor); - - /// - /// Defines an implicit conversion from an to an instance. - /// - /// The to convert to an . - public static implicit operator ExecutorIsh(RequestPort inputPort) => new(inputPort); - - /// - /// Defines an implicit conversion from an to an instance. - /// - /// The to convert to an . - public static implicit operator ExecutorIsh(AIAgent aiAgent) => new(aiAgent); - - /// - /// Defines an implicit conversion from a string to an instance. - /// - /// The string ID to convert to an . - public static implicit operator ExecutorIsh(string id) => new(id); - - /// - public bool Equals(ExecutorIsh? other) => - other is not null && other.Id == this.Id; - - /// - public bool Equals(IIdentified? other) => - other is not null && other.Id == this.Id; - - /// - public bool Equals(string? other) => - other is not null && other == this.Id; - - /// - public override bool Equals(object? obj) => - obj switch - { - null => false, - ExecutorIsh ish => this.Equals(ish), - IIdentified identified => this.Equals(identified), - string str => this.Equals(str), - _ => false - }; - - /// - public override int GetHashCode() => this.Id.GetHashCode(); - - /// - public override string ToString() => this.ExecutorType switch - { - Type.Unbound => $"'{this.Id}':", - Type.Executor => $"'{this.Id}':{this._configuredExecutorType!.Name}", - Type.RequestPort => $"'{this.Id}':Input({this._requestPortValue!.Request.Name}->{this._requestPortValue!.Response.Name})", - Type.Agent => $"{this.Id}':AIAgent(@{this._aiAgentValue!.GetType().Name})", - Type.Function => $"'{this.Id}':{this._configuredExecutorType!.Name}", - Type.Workflow => $"'{this.Id}':{this._configuredExecutorType!.Name}", - _ => $"'{this.Id}':" - }; -} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/ExecutorPlaceholder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/ExecutorPlaceholder.cs new file mode 100644 index 0000000000..f3dc902839 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/ExecutorPlaceholder.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Workflows; + +/// +/// Represents a placeholder entry for an , identified by a unique ID. +/// +/// The unique identifier for the placeholder registration. +public record ExecutorPlaceholder(string Id) + : ExecutorBinding(Id, + null, + typeof(Executor), + Id) +{ + /// + public override bool SupportsConcurrentSharedExecution => false; + + /// + public override bool SupportsResetting => false; + + /// + public override bool IsSharedInstance => false; +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/ExecutorRegistration.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/ExecutorRegistration.cs deleted file mode 100644 index eef749966c..0000000000 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/ExecutorRegistration.cs +++ /dev/null @@ -1,72 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Threading.Tasks; -using Microsoft.Shared.Diagnostics; - -using ExecutorFactoryF = System.Func>; - -namespace Microsoft.Agents.AI.Workflows; - -internal sealed class ExecutorRegistration(string id, Type executorType, ExecutorFactoryF provider, object? rawData) -{ - public string Id { get; } = Throw.IfNullOrEmpty(id); - public Type ExecutorType { get; } = Throw.IfNull(executorType); - private ExecutorFactoryF ProviderAsync { get; } = Throw.IfNull(provider); - - public bool IsSharedInstance { get; } = rawData is Executor; - - /// - /// Gets a value whether instances of the executor created from this registration can be reset between subsequent - /// runs from the same instance. This value is not relevant for executors that . - /// - public bool SupportsResetting { get; } = rawData is Executor && - // Cross-Run Shareable executors are "trivially" resettable, since they - // have no on-object state. - rawData is IResettableExecutor; - - /// - /// Gets a value whether instances of the executor created from this registration can be used in concurrent runs - /// from the same instance. - /// - public bool SupportsConcurrent { get; } = rawData is not Executor executor || executor.IsCrossRunShareable; - - internal async ValueTask TryResetAsync() - { - // Non-shared instances do not need resetting - if (!this.IsSharedInstance) - { - return true; - } - - // Technically we definitely know this is true, since if rawData is an Executor, if it was not resettable - // then we would have returned in the first condition, and if rawData is not an Executor, we would have - // returned in the second condition. That only leaves the possibility of rawData is Executor and also - // IResettableExecutor. - if (this.RawExecutorishData is IResettableExecutor resettableExecutor) - { - await resettableExecutor.ResetAsync().ConfigureAwait(false); - return true; - } - - return false; - } - - internal object? RawExecutorishData { get; } = rawData; - - public override string ToString() => $"{this.ExecutorType.Name}({this.Id})"; - - private Executor CheckId(Executor executor) - { - if (executor.Id != this.Id) - { - throw new InvalidOperationException( - $"Executor ID mismatch: expected '{this.Id}', but got '{executor.Id}'."); - } - - return executor; - } - - public async ValueTask CreateInstanceAsync(string runId) => this.CheckId(await this.ProviderAsync(runId).ConfigureAwait(false)); -} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/FunctionExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/FunctionExecutor.cs index 63db9456b4..a3371dc302 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/FunctionExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/FunctionExecutor.cs @@ -38,7 +38,9 @@ public class FunctionExecutor(string id, /// /// A unique identifier for the executor. /// A synchronous function to execute for each input message and workflow context. - public FunctionExecutor(string id, Action handlerSync) : this(id, WrapAction(handlerSync)) + /// Configuration options for the executor. If null, default options will be used. + /// Declare that this executor may be used simultaneously by multiple runs safely. + public FunctionExecutor(string id, Action handlerSync, ExecutorOptions? options = null, bool declareCrossRunShareable = false) : this(id, WrapAction(handlerSync), options, declareCrossRunShareable) { } } @@ -76,7 +78,9 @@ public class FunctionExecutor(string id, /// /// A unique identifier for the executor. /// A synchronous function to execute for each input message and workflow context. - public FunctionExecutor(string id, Func handlerSync) : this(id, WrapFunc(handlerSync)) + /// Configuration options for the executor. If null, default options will be used. + /// Declare that this executor may be used simultaneously by multiple runs safely. + public FunctionExecutor(string id, Func handlerSync, ExecutorOptions? options = null, bool declareCrossRunShareable = false) : this(id, WrapFunc(handlerSync), options, declareCrossRunShareable) { } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/GroupChatWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/GroupChatWorkflowBuilder.cs index 92b73083a9..c02a609f75 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/GroupChatWorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/GroupChatWorkflowBuilder.cs @@ -50,12 +50,12 @@ public sealed class GroupChatWorkflowBuilder public Workflow Build() { AIAgent[] agents = this._participants.ToArray(); - Dictionary agentMap = agents.ToDictionary(a => a, a => (ExecutorIsh)new AgentRunStreamingExecutor(a, includeInputInOutput: true)); + Dictionary agentMap = agents.ToDictionary(a => a, a => (ExecutorBinding)new AgentRunStreamingExecutor(a, includeInputInOutput: true)); Func> groupChatHostFactory = (string id, string runId) => new(new GroupChatHost(id, agents, agentMap, this._managerFactory)); - ExecutorIsh host = groupChatHostFactory.ConfigureFactory(nameof(GroupChatHost)); + ExecutorBinding host = groupChatHostFactory.BindExecutor(nameof(GroupChatHost)); WorkflowBuilder builder = new(host); foreach (var participant in agentMap.Values) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs index 874464812e..1750f779f2 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs @@ -73,7 +73,7 @@ internal sealed class InProcessRunnerContext : IRunnerContext async Task CreateExecutorAsync(string id) { - if (!this._workflow.Registrations.TryGetValue(executorId, out var registration)) + if (!this._workflow.ExecutorBindings.TryGetValue(executorId, out var registration)) { throw new InvalidOperationException($"Executor with ID '{executorId}' is not registered."); } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/RequestPortBinding.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/RequestPortBinding.cs new file mode 100644 index 0000000000..726895a1a7 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/RequestPortBinding.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; +using Microsoft.Agents.AI.Workflows.Specialized; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Workflows; + +/// +/// Represents the registration details for a request port, including configuration for allowing wrapped requests. +/// +/// The request port. +/// true to allow wrapped requests to be handled by the port; otherwise, false. +/// The default is true. +public record RequestPortBinding(RequestPort Port, bool AllowWrapped = true) + : ExecutorBinding(Throw.IfNull(Port).Id, + (_) => new ValueTask(new RequestInfoExecutor(Port, AllowWrapped)), + typeof(RequestInfoExecutor), + Port) +{ + /// + public override bool IsSharedInstance => false; + + /// + public override bool SupportsConcurrentSharedExecution => true; + + /// + public override bool SupportsResetting => false; +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/GroupChatHost.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/GroupChatHost.cs index 16f749d5a3..76e3f10bd2 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/GroupChatHost.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/GroupChatHost.cs @@ -10,11 +10,11 @@ namespace Microsoft.Agents.AI.Workflows.Specialized; internal sealed class GroupChatHost( string id, AIAgent[] agents, - Dictionary agentMap, + Dictionary agentMap, Func, GroupChatManager> managerFactory) : Executor(id), IResettableExecutor { private readonly AIAgent[] _agents = agents; - private readonly Dictionary _agentMap = agentMap; + private readonly Dictionary _agentMap = agentMap; private readonly Func, GroupChatManager> _managerFactory = managerFactory; private readonly List _pendingMessages = []; diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/SubworkflowBinding.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/SubworkflowBinding.cs new file mode 100644 index 0000000000..1f29ffe426 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/SubworkflowBinding.cs @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Workflows.Specialized; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Workflows; + +/// +/// Represents the workflow binding details for a subworkflow, including its instance, identifier, and optional +/// executor options. +/// +/// +/// +/// +public record SubworkflowBinding(Workflow WorkflowInstance, string Id, ExecutorOptions? ExecutorOptions = null) + : ExecutorBinding(Throw.IfNull(Id), + CreateWorkflowExecutorFactory(WorkflowInstance, Id, ExecutorOptions), + typeof(WorkflowHostExecutor), + WorkflowInstance) +{ + private static Func> CreateWorkflowExecutorFactory(Workflow workflow, string id, ExecutorOptions? options) + { + object ownershipToken = new(); + workflow.TakeOwnership(ownershipToken, subworkflow: true); + + return InitHostExecutorAsync; + + ValueTask InitHostExecutorAsync(string runId) + { + return new(new WorkflowHostExecutor(id, workflow, runId, ownershipToken, options)); + } + } + + /// + public override bool IsSharedInstance => false; + + /// + public override bool SupportsConcurrentSharedExecution => true; + + /// + public override bool SupportsResetting => false; +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/SwitchBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/SwitchBuilder.cs index 306acd4b4e..e180650935 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/SwitchBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/SwitchBuilder.cs @@ -13,7 +13,7 @@ namespace Microsoft.Agents.AI.Workflows; /// public sealed class SwitchBuilder { - private readonly List _executors = []; + private readonly List _executors = []; private readonly Dictionary _executorIndicies = []; private readonly List<(Func Predicate, HashSet OutgoingIndicies)> _caseMap = []; private readonly HashSet _defaultIndicies = []; @@ -30,14 +30,14 @@ public sealed class SwitchBuilder /// One or more executors to associate with the predicate. Each executor will be invoked if the predicate matches. /// Cannot be null. /// The current instance, allowing for method chaining. - public SwitchBuilder AddCase(Func predicate, params IEnumerable executors) + public SwitchBuilder AddCase(Func predicate, params IEnumerable executors) { Throw.IfNull(predicate); Throw.IfNull(executors); HashSet indicies = []; - foreach (ExecutorIsh executor in executors) + foreach (ExecutorBinding executor in executors) { if (!this._executorIndicies.TryGetValue(executor.Id, out int index)) { @@ -60,11 +60,11 @@ public sealed class SwitchBuilder /// /// /// - public SwitchBuilder WithDefault(params IEnumerable executors) + public SwitchBuilder WithDefault(params IEnumerable executors) { Throw.IfNull(executors); - foreach (ExecutorIsh executor in executors) + foreach (ExecutorBinding executor in executors) { if (!this._executorIndicies.TryGetValue(executor.Id, out int index)) { @@ -79,7 +79,7 @@ public sealed class SwitchBuilder return this; } - internal WorkflowBuilder ReduceToFanOut(WorkflowBuilder builder, ExecutorIsh source) + internal WorkflowBuilder ReduceToFanOut(WorkflowBuilder builder, ExecutorBinding source) { List<(Func Predicate, HashSet OutgoingIndicies)> caseMap = this._caseMap; HashSet defaultIndicies = this._defaultIndicies; diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Visualization/WorkflowVisualizer.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Visualization/WorkflowVisualizer.cs index 886002320f..ebf6f08ffb 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Visualization/WorkflowVisualizer.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Visualization/WorkflowVisualizer.cs @@ -69,7 +69,7 @@ public static class WorkflowVisualizer lines.Add($"{indent}\"{MapId(startExecutorId)}\" [fillcolor=lightgreen, label=\"{startExecutorId}\\n(Start)\"];"); // Add other executor nodes - foreach (var executorId in workflow.Registrations.Keys) + foreach (var executorId in workflow.ExecutorBindings.Keys) { if (executorId != startExecutorId) { @@ -108,7 +108,7 @@ public static class WorkflowVisualizer private static void EmitSubWorkflowsDigraph(Workflow workflow, List lines, string indent) { - foreach (var kvp in workflow.Registrations) + foreach (var kvp in workflow.ExecutorBindings) { var execId = kvp.Key; var registration = kvp.Value; @@ -145,7 +145,7 @@ public static class WorkflowVisualizer lines.Add($"{indent}{MapId(startExecutorId)}[\"{startExecutorId} (Start)\"];"); // Add other executor nodes - foreach (var executorId in workflow.Registrations.Keys) + foreach (var executorId in workflow.ExecutorBindings.Keys) { if (executorId != startExecutorId) { @@ -264,9 +264,9 @@ public static class WorkflowVisualizer #endif } - private static bool TryGetNestedWorkflow(ExecutorRegistration registration, [NotNullWhen(true)] out Workflow? workflow) + private static bool TryGetNestedWorkflow(ExecutorBinding binding, [NotNullWhen(true)] out Workflow? workflow) { - if (registration.RawExecutorishData is Workflow subWorkflow) + if (binding.RawValue is Workflow subWorkflow) { workflow = subWorkflow; return true; diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Workflow.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Workflow.cs index 3636500438..a4f6be1210 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Workflow.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Workflow.cs @@ -19,7 +19,7 @@ public class Workflow /// /// A dictionary of executor providers, keyed by executor ID. /// - internal Dictionary Registrations { get; init; } = []; + internal Dictionary ExecutorBindings { get; init; } = []; internal Dictionary> Edges { get; init; } = []; internal HashSet OutputExecutors { get; init; } = []; @@ -41,7 +41,7 @@ public class Workflow /// Gets the collection of external request ports, keyed by their ID. /// /// - /// Each port has a corresponding entry in the dictionary. + /// Each port has a corresponding entry in the dictionary. /// public Dictionary ReflectPorts() { @@ -66,10 +66,10 @@ public class Workflow /// public string? Description { get; internal init; } - internal bool AllowConcurrent => this.Registrations.Values.All(registration => registration.SupportsConcurrent); + internal bool AllowConcurrent => this.ExecutorBindings.Values.All(registration => registration.SupportsConcurrentSharedExecution); internal IEnumerable NonConcurrentExecutorIds => - this.Registrations.Values.Where(r => !r.SupportsConcurrent).Select(r => r.Id); + this.ExecutorBindings.Values.Where(r => !r.SupportsConcurrentSharedExecution).Select(r => r.Id); /// /// Initializes a new instance of the class with the specified starting executor identifier @@ -86,12 +86,14 @@ public class Workflow } private bool _needsReset; - private bool HasResettable => this.Registrations.Values.Any(registration => registration.SupportsResetting); + private bool HasResettableExecutors => + this.ExecutorBindings.Values.Any(registration => registration.SupportsResetting); + private async ValueTask TryResetExecutorRegistrationsAsync() { - if (this.HasResettable) + if (this.HasResettableExecutors) { - foreach (ExecutorRegistration registration in this.Registrations.Values) + foreach (ExecutorBinding registration in this.ExecutorBindings.Values) { // TryResetAsync returns true if the executor does not need resetting if (!await registration.TryResetAsync().ConfigureAwait(false)) @@ -158,7 +160,7 @@ public class Workflow }); } - this._needsReset = this.HasResettable; + this._needsReset = this.HasResettableExecutors; this._ownedAsSubworkflow = subworkflow; } @@ -188,7 +190,7 @@ public class Workflow /// a the protocol this follows. public async ValueTask DescribeProtocolAsync(CancellationToken cancellationToken = default) { - ExecutorRegistration startExecutorRegistration = this.Registrations[this.StartExecutorId]; + ExecutorBinding startExecutorRegistration = this.ExecutorBindings[this.StartExecutorId]; Executor startExecutor = await startExecutorRegistration.CreateInstanceAsync(string.Empty) .ConfigureAwait(false); return startExecutor.DescribeProtocol(); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilder.cs index 9797843fa7..c277a84b36 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilder.cs @@ -19,7 +19,7 @@ namespace Microsoft.Agents.AI.Workflows; /// Use the WorkflowBuilder to incrementally add executors and edges, including fan-in and fan-out /// patterns, before building a strongly-typed workflow instance. Executors must be bound before building the workflow. /// All executors must be bound by calling into if they were intially specified as -/// . +/// . public class WorkflowBuilder { private readonly record struct EdgeConnection(string SourceId, string TargetId) @@ -28,11 +28,11 @@ public class WorkflowBuilder } private int _edgeCount; - private readonly Dictionary _executors = []; + private readonly Dictionary _executors = []; private readonly Dictionary> _edges = []; private readonly HashSet _unboundExecutors = []; private readonly HashSet _conditionlessConnections = []; - private readonly Dictionary _inputPorts = []; + private readonly Dictionary _requestPorts = []; private readonly HashSet _outputExecutors = []; private readonly string _startExecutorId; @@ -46,57 +46,56 @@ public class WorkflowBuilder /// Initializes a new instance of the WorkflowBuilder class with the specified starting executor. /// /// The executor that defines the starting point of the workflow. Cannot be null. - public WorkflowBuilder(ExecutorIsh start) + public WorkflowBuilder(ExecutorBinding start) { this._startExecutorId = this.Track(start).Id; } - private ExecutorIsh Track(ExecutorIsh executorish) + private ExecutorBinding Track(ExecutorBinding registration) { // If the executor is unbound, create an entry for it, unless it already exists. // Otherwise, update the entry for it, and remove the unbound tag - if (executorish.IsUnbound && !this._executors.ContainsKey(executorish.Id)) + if (registration.IsPlaceholder && !this._executors.ContainsKey(registration.Id)) { // If this is an unbound executor, we need to track it separately - this._unboundExecutors.Add(executorish.Id); + this._unboundExecutors.Add(registration.Id); } - else if (!executorish.IsUnbound) + else if (!registration.IsPlaceholder) { - ExecutorRegistration incoming = executorish.Registration; // If there is already a bound executor with this ID, we need to validate (to best efforts) // that the two are matching (at least based on type) - if (this._executors.TryGetValue(executorish.Id, out ExecutorRegistration? existing)) + if (this._executors.TryGetValue(registration.Id, out ExecutorBinding? existing)) { - if (existing.ExecutorType != incoming.ExecutorType) + if (existing.ExecutorType != registration.ExecutorType) { throw new InvalidOperationException( - $"Cannot bind executor with ID '{executorish.Id}' because an executor with the same ID but a different type ({existing.ExecutorType.Name} vs {incoming.ExecutorType.Name}) is already bound."); + $"Cannot bind executor with ID '{registration.Id}' because an executor with the same ID but a different type ({existing.ExecutorType.Name} vs {registration.ExecutorType.Name}) is already bound."); } - if (existing.RawExecutorishData is not null && - !ReferenceEquals(existing.RawExecutorishData, incoming.RawExecutorishData)) + if (existing.RawValue is not null && + !ReferenceEquals(existing.RawValue, registration.RawValue)) { throw new InvalidOperationException( - $"Cannot bind executor with ID '{executorish.Id}' because an executor with the same ID but different instance is already bound."); + $"Cannot bind executor with ID '{registration.Id}' because an executor with the same ID but different instance is already bound."); } } else { - this._executors[executorish.Id] = executorish.Registration; - if (this._unboundExecutors.Contains(executorish.Id)) + this._executors[registration.Id] = registration; + if (this._unboundExecutors.Contains(registration.Id)) { - this._unboundExecutors.Remove(executorish.Id); + this._unboundExecutors.Remove(registration.Id); } } } - if (executorish.ExecutorType == ExecutorIsh.Type.RequestPort) + if (registration is RequestPortBinding portRegistration) { - RequestPort port = executorish._requestPortValue!; - this._inputPorts[port.Id] = port; + RequestPort port = portRegistration.Port; + this._requestPorts[port.Id] = port; } - return executorish; + return registration; } /// @@ -106,9 +105,9 @@ public class WorkflowBuilder /// /// /// - public WorkflowBuilder WithOutputFrom(params ExecutorIsh[] executors) + public WorkflowBuilder WithOutputFrom(params ExecutorBinding[] executors) { - foreach (ExecutorIsh executor in executors) + foreach (ExecutorBinding executor in executors) { this._outputExecutors.Add(this.Track(executor).Id); } @@ -139,21 +138,21 @@ public class WorkflowBuilder } /// - /// Binds the specified executor to the workflow, allowing it to participate in workflow execution. + /// Binds the specified executor (via registration) to the workflow, allowing it to participate in workflow execution. /// - /// The executor instance to bind. The executor must exist in the workflow and not be already bound. + /// The executor instance to bind. The executor must exist in the workflow and not be already bound. /// The current instance, enabling fluent configuration. /// Thrown if the specified executor is already bound or does not exist in the workflow. - public WorkflowBuilder BindExecutor(Executor executor) + public WorkflowBuilder BindExecutor(ExecutorBinding registration) { - if (!this._unboundExecutors.Contains(executor.Id)) + if (Throw.IfNull(registration) is ExecutorPlaceholder) { throw new InvalidOperationException( - $"Executor with ID '{executor.Id}' is already bound or does not exist in the workflow."); + $"Cannot bind executor with ID '{registration.Id}' because it is a placeholder registration. " + + "You must provide a concrete executor instance or registration."); } - this._executors[executor.Id] = new ExecutorIsh(executor).Registration; - this._unboundExecutors.Remove(executor.Id); + this.Track(registration); return this; } @@ -180,7 +179,7 @@ public class WorkflowBuilder /// The current instance of . /// Thrown if an unconditional edge between the specified source and target /// executors already exists. - public WorkflowBuilder AddEdge(ExecutorIsh source, ExecutorIsh target, bool idempotent = false) + public WorkflowBuilder AddEdge(ExecutorBinding source, ExecutorBinding target, bool idempotent = false) => this.AddEdge(source, target, null, idempotent); internal static Func? CreateConditionFunc(Func? condition) @@ -236,7 +235,7 @@ public class WorkflowBuilder /// The current instance of . /// Thrown if an unconditional edge between the specified source and target /// executors already exists. - public WorkflowBuilder AddEdge(ExecutorIsh source, ExecutorIsh target, Func? condition = null, bool idempotent = false) + public WorkflowBuilder AddEdge(ExecutorBinding source, ExecutorBinding target, Func? condition = null, bool idempotent = false) { // Add an edge from source to target with an optional condition. // This is a low-level builder method that does not enforce any specific executor type. @@ -273,7 +272,7 @@ public class WorkflowBuilder /// The source executor from which the fan-out edge originates. Cannot be null. /// One or more target executors that will receive the fan-out edge. Cannot be null or empty. /// The current instance of . - public WorkflowBuilder AddFanOutEdge(ExecutorIsh source, params IEnumerable targets) + public WorkflowBuilder AddFanOutEdge(ExecutorBinding source, params IEnumerable targets) => this.AddFanOutEdge(source, null, targets); internal static Func>? CreateEdgeAssignerFunc(Func>? partitioner) @@ -305,7 +304,7 @@ public class WorkflowBuilder /// If null, messages will route to all targets. /// One or more target executors that will receive the fan-out edge. Cannot be null or empty. /// The current instance of . - public WorkflowBuilder AddFanOutEdge(ExecutorIsh source, Func>? partitioner = null, params IEnumerable targets) + public WorkflowBuilder AddFanOutEdge(ExecutorBinding source, Func>? partitioner = null, params IEnumerable targets) { Throw.IfNull(source); Throw.IfNull(targets); @@ -339,7 +338,7 @@ public class WorkflowBuilder /// The target executor that receives input from the specified source executors. Cannot be null. /// One or more source executors that provide input to the target. Cannot be null or empty. /// The current instance of . - public WorkflowBuilder AddFanInEdge(ExecutorIsh target, params IEnumerable sources) + public WorkflowBuilder AddFanInEdge(ExecutorBinding target, params IEnumerable sources) { Throw.IfNull(target); Throw.IfNull(sources); @@ -398,9 +397,9 @@ public class WorkflowBuilder var workflow = new Workflow(this._startExecutorId, this._name, this._description) { - Registrations = this._executors, + ExecutorBindings = this._executors, Edges = this._edges, - Ports = this._inputPorts, + Ports = this._requestPorts, OutputExecutors = this._outputExecutors }; diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilderExtensions.cs index a0d42ac35f..e338f73fc6 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilderExtensions.cs @@ -24,7 +24,7 @@ public static class WorkflowBuilderExtensions /// The source executor from which messages will be forwarded. /// The target executors to which messages will be forwarded. /// The updated instance. - public static WorkflowBuilder ForwardMessage(this WorkflowBuilder builder, ExecutorIsh source, params IEnumerable executors) + public static WorkflowBuilder ForwardMessage(this WorkflowBuilder builder, ExecutorBinding source, params IEnumerable executors) => builder.ForwardMessage(source, condition: null, executors); /// @@ -38,7 +38,7 @@ public static class WorkflowBuilderExtensions /// all messages of type will be forwarded. /// The target executors to which messages will be forwarded. /// The updated instance. - public static WorkflowBuilder ForwardMessage(this WorkflowBuilder builder, ExecutorIsh source, Func? condition = null, params IEnumerable executors) + public static WorkflowBuilder ForwardMessage(this WorkflowBuilder builder, ExecutorBinding source, Func? condition = null, params IEnumerable executors) { Throw.IfNull(executors); @@ -47,7 +47,7 @@ public static class WorkflowBuilderExtensions #if NET if (executors.TryGetNonEnumeratedCount(out int count) && count == 1) #else - if (executors is ICollection { Count: 1 }) + if (executors is ICollection { Count: 1 }) #endif { return builder.AddEdge(source, executors.First(), predicate); @@ -68,7 +68,7 @@ public static class WorkflowBuilderExtensions /// The source executor from which messages will be forwarded. /// The target executors to which messages, except those of type , will be forwarded. /// The updated instance with the added edges. - public static WorkflowBuilder ForwardExcept(this WorkflowBuilder builder, ExecutorIsh source, params IEnumerable executors) + public static WorkflowBuilder ForwardExcept(this WorkflowBuilder builder, ExecutorBinding source, params IEnumerable executors) { Throw.IfNull(executors); @@ -77,7 +77,7 @@ public static class WorkflowBuilderExtensions #if NET if (executors.TryGetNonEnumeratedCount(out int count) && count == 1) #else - if (executors is ICollection { Count: 1 }) + if (executors is ICollection { Count: 1 }) #endif { return builder.AddEdge(source, executors.First(), predicate); @@ -102,7 +102,7 @@ public static class WorkflowBuilderExtensions /// An ordered array of executors to be added to the chain after the source. /// The original workflow builder instance with the specified executor chain added. /// Thrown if there is a cycle in the chain. - public static WorkflowBuilder AddChain(this WorkflowBuilder builder, ExecutorIsh source, bool allowRepetition = false, params IEnumerable executors) + public static WorkflowBuilder AddChain(this WorkflowBuilder builder, ExecutorBinding source, bool allowRepetition = false, params IEnumerable executors) { Throw.IfNull(builder); Throw.IfNull(source); @@ -139,7 +139,7 @@ public static class WorkflowBuilderExtensions /// The source executor representing the external system or process to connect. Cannot be null. /// The unique identifier for the input port that will handle the external call. Cannot be null. /// The original workflow builder instance with the external call added. - public static WorkflowBuilder AddExternalCall(this WorkflowBuilder builder, ExecutorIsh source, string portId) + public static WorkflowBuilder AddExternalCall(this WorkflowBuilder builder, ExecutorBinding source, string portId) { Throw.IfNull(builder); Throw.IfNull(source); @@ -160,7 +160,7 @@ public static class WorkflowBuilderExtensions /// The source executor that determines the branching condition for the switch. Cannot be null. /// An action used to configure the switch builder, specifying the branches and their conditions. Cannot be null. /// The workflow builder instance with the configured switch step added. - public static WorkflowBuilder AddSwitch(this WorkflowBuilder builder, ExecutorIsh source, Action configureSwitch) + public static WorkflowBuilder AddSwitch(this WorkflowBuilder builder, ExecutorBinding source, Action configureSwitch) { Throw.IfNull(builder); Throw.IfNull(source); diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/JsonSerializationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/JsonSerializationTests.cs index 1c20a7a091..f55185a78f 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/JsonSerializationTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/JsonSerializationTests.cs @@ -167,7 +167,7 @@ public class JsonSerializationTests builder.AddEdge(forwardString, stringToInt) .AddEdge(stringToInt, forwardInt) .AddEdge(forwardInt, intToString) - .AddEdge(intToString, StreamingAggregators.Last().AsExecutor("Aggregate")); + .AddEdge(intToString, StreamingAggregators.Last().BindAsExecutor("Aggregate")); return builder.Build(); } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/RepresentationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/RepresentationTests.cs index fa5ef22903..9cf460e658 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/RepresentationTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/RepresentationTests.cs @@ -2,6 +2,8 @@ using System; using System.Collections.Generic; +using System.Linq; +using System.Reflection; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -38,34 +40,40 @@ public class RepresentationTests private static RequestPort TestRequestPort => RequestPort.Create("ExternalFunction"); - private static async ValueTask RunExecutorishInfoMatchTestAsync(ExecutorIsh target) + private static async ValueTask RunExecutorBindingInfoMatchTestAsync(ExecutorBinding binding) { - ExecutorRegistration registration = target.Registration; - ExecutorInfo info = registration.ToExecutorInfo(); + ExecutorInfo info = binding.ToExecutorInfo(); - info.IsMatch(await registration.CreateInstanceAsync(runId: string.Empty)).Should().BeTrue(); + info.IsMatch(await binding.CreateInstanceAsync(runId: string.Empty)).Should().BeTrue(); } [Fact] - public async Task Test_Executorish_InfosAsync() + public async Task Test_ExecutorBinding_InfosAsync() { int testsRun = 0; - await RunExecutorishTestAsync(new TestExecutor()); - await RunExecutorishTestAsync(TestRequestPort); - await RunExecutorishTestAsync(new TestAgent()); - await RunExecutorishTestAsync(Step1EntryPoint.WorkflowInstance.ConfigureSubWorkflow(nameof(Step1EntryPoint))); + await RunExecutorBindingTestAsync(new TestExecutor()); + await RunExecutorBindingTestAsync(TestRequestPort); + await RunExecutorBindingTestAsync(new TestAgent()); + await RunExecutorBindingTestAsync(Step1EntryPoint.WorkflowInstance.BindAsExecutor(nameof(Step1EntryPoint))); Func function = MessageHandlerAsync; - await RunExecutorishTestAsync(function.AsExecutor("FunctionExecutor")); + await RunExecutorBindingTestAsync(function.BindAsExecutor("FunctionExecutor")); - if (Enum.GetValues(typeof(ExecutorIsh.Type)).Length > testsRun + 1) + Type bindingBaseType = typeof(ExecutorBinding); + Assembly workflowAssembly = bindingBaseType.Assembly; + int expectedTests = workflowAssembly.GetTypes() + .Count(type => type != bindingBaseType + && bindingBaseType.IsAssignableFrom(type)); + expectedTests.Should().BePositive(); + + if (expectedTests > testsRun + 1) { - Assert.Fail("Not all ExecutorIsh types were tested."); + Assert.Fail("Not all ExecutorBinding types were tested."); } - async ValueTask RunExecutorishTestAsync(ExecutorIsh executorish) + async ValueTask RunExecutorBindingTestAsync(ExecutorBinding binding) { - await RunExecutorishInfoMatchTestAsync(executorish); + await RunExecutorBindingInfoMatchTestAsync(binding); testsRun++; } @@ -77,8 +85,8 @@ public class RepresentationTests [Fact] public async Task Test_SpecializedExecutor_InfosAsync() { - await RunExecutorishInfoMatchTestAsync(new AIAgentHostExecutor(new TestAgent())); - await RunExecutorishInfoMatchTestAsync(new RequestInfoExecutor(TestRequestPort)); + await RunExecutorBindingInfoMatchTestAsync(new AIAgentHostExecutor(new TestAgent())); + await RunExecutorBindingInfoMatchTestAsync(new RequestInfoExecutor(TestRequestPort)); } private static string Source(int id) => $"Source/{id}"; diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/07_GroupChat_Workflow_HostAsAgent.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/07_GroupChat_Workflow_HostAsAgent.cs index 8144d1bd11..ffc4d0ba35 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/07_GroupChat_Workflow_HostAsAgent.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/07_GroupChat_Workflow_HostAsAgent.cs @@ -11,21 +11,23 @@ internal static class Step7EntryPoint public static string EchoAgentId => Step6EntryPoint.EchoAgentId; public static string EchoPrefix => Step6EntryPoint.EchoPrefix; - public static async ValueTask RunAsync(TextWriter writer, IWorkflowExecutionEnvironment environment, int maxSteps = 2) + public static async ValueTask RunAsync(TextWriter writer, IWorkflowExecutionEnvironment environment, int maxSteps = 2, int numIterations = 2) { Workflow workflow = Step6EntryPoint.CreateWorkflow(maxSteps); AIAgent agent = workflow.AsAgent("group-chat-agent", "Group Chat Agent"); - AgentThread thread = agent.GetNewThread(); - - await foreach (AgentRunResponseUpdate update in agent.RunStreamingAsync(thread).ConfigureAwait(false)) + for (int i = 0; i < numIterations; i++) { - string updateText = $"{update.AuthorName - ?? update.AgentId - ?? update.Role.ToString() - ?? ChatRole.Assistant.ToString()}: {update.Text}"; - writer.WriteLine(updateText); + AgentThread thread = agent.GetNewThread(); + await foreach (AgentRunResponseUpdate update in agent.RunStreamingAsync(thread).ConfigureAwait(false)) + { + string updateText = $"{update.AuthorName + ?? update.AgentId + ?? update.Role.ToString() + ?? ChatRole.Assistant.ToString()}: {update.Text}"; + writer.WriteLine(updateText); + } } } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/08_Subworkflow_Simple.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/08_Subworkflow_Simple.cs index 8ec7978606..58372103f4 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/08_Subworkflow_Simple.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/08_Subworkflow_Simple.cs @@ -29,13 +29,13 @@ internal static class Step8EntryPoint public static async ValueTask> RunAsync(TextWriter writer, IWorkflowExecutionEnvironment environment, List textsToProcess) { Func processTextAsyncFunc = ProcessTextAsync; - ExecutorIsh processText = processTextAsyncFunc.AsExecutor("TextProcessor", threadsafe: true); + ExecutorBinding processText = processTextAsyncFunc.BindAsExecutor("TextProcessor", threadsafe: true); Workflow subWorkflow = new WorkflowBuilder(processText).WithOutputFrom(processText).Build(); - ExecutorIsh textProcessor = subWorkflow.ConfigureSubWorkflow("TextProcessor"); + ExecutorBinding textProcessor = subWorkflow.BindAsExecutor("TextProcessor"); Func> createOrchestrator = (id, _) => new(new TextProcessingOrchestrator(id)); - var orchestrator = createOrchestrator.ConfigureFactory(); + var orchestrator = createOrchestrator.BindExecutor(); Workflow workflow = new WorkflowBuilder(orchestrator) .AddEdge(orchestrator, textProcessor) diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/09_Subworkflow_ExternalRequest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/09_Subworkflow_ExternalRequest.cs index 7f9b6189bc..aac3ed1d94 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/09_Subworkflow_ExternalRequest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/09_Subworkflow_ExternalRequest.cs @@ -62,7 +62,7 @@ internal sealed record class RequestFinished(string Id, string RequestType, Reso internal static class Step9EntryPoint { - public static WorkflowBuilder AddPassthroughRequestHandler(this WorkflowBuilder builder, ExecutorIsh source, ExecutorIsh filter, string? id = null) + public static WorkflowBuilder AddPassthroughRequestHandler(this WorkflowBuilder builder, ExecutorBinding source, ExecutorBinding filter, string? id = null) { id ??= typeof(TRequest).Name; @@ -74,10 +74,10 @@ internal static class Step9EntryPoint .ForwardMessage(filter, executors: [source], condition: message => message.DataIs()); } - public static WorkflowBuilder AddExternalRequest(this WorkflowBuilder builder, ExecutorIsh source, string? id = null) + public static WorkflowBuilder AddExternalRequest(this WorkflowBuilder builder, ExecutorBinding source, string? id = null) => builder.AddExternalRequest(source, out RequestPort _, id); - public static WorkflowBuilder AddExternalRequest(this WorkflowBuilder builder, ExecutorIsh source, out RequestPort inputPort, string? id = null) + public static WorkflowBuilder AddExternalRequest(this WorkflowBuilder builder, ExecutorBinding source, out RequestPort inputPort, string? id = null) { id = id ?? $"{source.Id}.Requests[{typeof(TRequest).Name}=>{typeof(TResponse).Name}]"; @@ -86,7 +86,7 @@ internal static class Step9EntryPoint return builder.AddExternalRequest(source, inputPort); } - public static WorkflowBuilder AddExternalRequest(this WorkflowBuilder builder, ExecutorIsh source, RequestPort inputPort) + public static WorkflowBuilder AddExternalRequest(this WorkflowBuilder builder, ExecutorBinding source, RequestPort inputPort) { return builder.ForwardMessage(source, inputPort) .ForwardMessage(source, inputPort) @@ -110,7 +110,7 @@ internal static class Step9EntryPoint Coordinator coordinator = new(); ResourceCache cache = new(); QuotaPolicyEngine policyEngine = new(); - ExecutorIsh subworkflow = CreateSubWorkflow().ConfigureSubWorkflow("ResourceWorkflow"); + ExecutorBinding subworkflow = CreateSubWorkflow().BindAsExecutor("ResourceWorkflow"); return new WorkflowBuilder(coordinator) .AddChain(coordinator, allowRepetition: true, subworkflow, coordinator) @@ -522,4 +522,26 @@ internal sealed class Coordinator() : Executor(nameof(Coordinator), declareCross return state + requests.Count; } } + + internal async ValueTask RunWorkflowHandleEventsAsync(Workflow workflow, TInput input) where TInput : notnull + { + StreamingRun run = await InProcessExecution.StreamAsync(workflow, input); + await foreach (WorkflowEvent evt in run.WatchStreamAsync()) + { + switch (evt) + { + case ExecutorInvokedEvent invoked: + Console.WriteLine($"Executor invoked: {invoked.ExecutorId}"); + break; + case ExecutorCompletedEvent completed: + Console.WriteLine($"Executor completed: {completed.ExecutorId}"); + break; + + // Other event types can be handled here as needed + + default: + break; + } + } + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/SampleSmokeTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/SampleSmokeTest.cs index 47733ea6af..dbe3a56d06 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/SampleSmokeTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/SampleSmokeTest.cs @@ -212,6 +212,8 @@ public class SampleSmokeTest string[] lines = result.Split([Environment.NewLine], StringSplitOptions.RemoveEmptyEntries); Assert.Collection(lines, + line => Assert.Contains($"{HelloAgent.DefaultId}: {HelloAgent.Greeting}", line), + line => Assert.Contains($"{Step7EntryPoint.EchoAgentId}: {Step7EntryPoint.EchoPrefix}{HelloAgent.Greeting}", line), line => Assert.Contains($"{HelloAgent.DefaultId}: {HelloAgent.Greeting}", line), line => Assert.Contains($"{Step7EntryPoint.EchoAgentId}: {Step7EntryPoint.EchoPrefix}{HelloAgent.Greeting}", line) ); diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowBuilderSmokeTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowBuilderSmokeTests.cs index c1ba97b0e9..b5485043b3 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowBuilderSmokeTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowBuilderSmokeTests.cs @@ -30,9 +30,9 @@ public partial class WorkflowBuilderSmokeTests workflow.StartExecutorId.Should().Be("start"); - workflow.Registrations.Should().HaveCount(1); - workflow.Registrations.Should().ContainKey("start"); - workflow.Registrations["start"].ExecutorType.Should().Be(); + workflow.ExecutorBindings.Should().HaveCount(1); + workflow.ExecutorBindings.Should().ContainKey("start"); + workflow.ExecutorBindings["start"].ExecutorType.Should().Be(); } [Fact] @@ -45,9 +45,9 @@ public partial class WorkflowBuilderSmokeTests workflow.StartExecutorId.Should().Be("start"); - workflow.Registrations.Should().HaveCount(1); - workflow.Registrations.Should().ContainKey("start"); - workflow.Registrations["start"].ExecutorType.Should().Be(); + workflow.ExecutorBindings.Should().HaveCount(1); + workflow.ExecutorBindings.Should().ContainKey("start"); + workflow.ExecutorBindings["start"].ExecutorType.Should().Be(); } [Fact] @@ -77,9 +77,9 @@ public partial class WorkflowBuilderSmokeTests workflow.StartExecutorId.Should().Be("start"); - workflow.Registrations.Should().HaveCount(1); - workflow.Registrations.Should().ContainKey("start"); - workflow.Registrations["start"].ExecutorType.Should().Be(); + workflow.ExecutorBindings.Should().HaveCount(1); + workflow.ExecutorBindings.Should().ContainKey("start"); + workflow.ExecutorBindings["start"].ExecutorType.Should().Be(); } [Fact] From 0b843d2b3e52918f6ecc67e56ef4ceb0f6457959 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Date: Mon, 3 Nov 2025 18:25:16 +0000 Subject: [PATCH 04/15] .NET: Add simple rag sample for catalog (#1834) * add simple rag sample for catalog * Update dotnet/samples/Catalog/AgentWithTextSearchRag/AgentWithTextSearchRag.csproj Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- dotnet/agent-framework-dotnet.slnx | 1 + .../AgentWithTextSearchRag.csproj | 21 +++++ .../Catalog/AgentWithTextSearchRag/Program.cs | 82 +++++++++++++++++++ .../Catalog/AgentWithTextSearchRag/README.md | 41 ++++++++++ 4 files changed, 145 insertions(+) create mode 100644 dotnet/samples/Catalog/AgentWithTextSearchRag/AgentWithTextSearchRag.csproj create mode 100644 dotnet/samples/Catalog/AgentWithTextSearchRag/Program.cs create mode 100644 dotnet/samples/Catalog/AgentWithTextSearchRag/README.md diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index cbda6c2809..d342c06be2 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -137,6 +137,7 @@ + diff --git a/dotnet/samples/Catalog/AgentWithTextSearchRag/AgentWithTextSearchRag.csproj b/dotnet/samples/Catalog/AgentWithTextSearchRag/AgentWithTextSearchRag.csproj new file mode 100644 index 0000000000..c6bab8327e --- /dev/null +++ b/dotnet/samples/Catalog/AgentWithTextSearchRag/AgentWithTextSearchRag.csproj @@ -0,0 +1,21 @@ + + + + Exe + net9.0 + + enable + enable + + + + + + + + + + + + + diff --git a/dotnet/samples/Catalog/AgentWithTextSearchRag/Program.cs b/dotnet/samples/Catalog/AgentWithTextSearchRag/Program.cs new file mode 100644 index 0000000000..931ce50014 --- /dev/null +++ b/dotnet/samples/Catalog/AgentWithTextSearchRag/Program.cs @@ -0,0 +1,82 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample shows how to use TextSearchProvider to add retrieval augmented generation (RAG) +// capabilities to an AI agent. The provider runs a search against an external knowledge base +// before each model invocation and injects the results into the model context. + +using Azure.AI.OpenAI; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Data; +using Microsoft.Extensions.AI; +using OpenAI; + +var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); +var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; + +TextSearchProviderOptions textSearchOptions = new() +{ + // Run the search prior to every model invocation and keep a short rolling window of conversation context. + SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke, + RecentMessageMemoryLimit = 6, +}; + +AIAgent agent = new AzureOpenAIClient( + new Uri(endpoint), + new AzureCliCredential()) + .GetChatClient(deploymentName) + .CreateAIAgent(new ChatClientAgentOptions + { + Instructions = "You are a helpful support specialist for Contoso Outdoors. Answer questions using the provided context and cite the source document when available.", + AIContextProviderFactory = _ => new TextSearchProvider(MockSearchAsync, textSearchOptions) + }); + +AgentThread thread = agent.GetNewThread(); + +Console.WriteLine(">> Asking about returns\n"); +Console.WriteLine(await agent.RunAsync("Hi! I need help understanding the return policy.", thread)); + +Console.WriteLine("\n>> Asking about shipping\n"); +Console.WriteLine(await agent.RunAsync("How long does standard shipping usually take?", thread)); + +Console.WriteLine("\n>> Asking about product care\n"); +Console.WriteLine(await agent.RunAsync("What is the best way to maintain the TrailRunner tent fabric?", thread)); + +static Task> MockSearchAsync(string query, CancellationToken cancellationToken) +{ + // The mock search inspects the user's question and returns pre-defined snippets + // that resemble documents stored in an external knowledge source. + List results = new(); + + if (query.Contains("return", StringComparison.OrdinalIgnoreCase) || query.Contains("refund", StringComparison.OrdinalIgnoreCase)) + { + results.Add(new() + { + Name = "Contoso Outdoors Return Policy", + Link = "https://contoso.com/policies/returns", + Value = "Customers may return any item within 30 days of delivery. Items should be unused and include original packaging. Refunds are issued to the original payment method within 5 business days of inspection." + }); + } + + if (query.Contains("shipping", StringComparison.OrdinalIgnoreCase)) + { + results.Add(new() + { + Name = "Contoso Outdoors Shipping Guide", + Link = "https://contoso.com/help/shipping", + Value = "Standard shipping is free on orders over $50 and typically arrives in 3-5 business days within the continental United States. Expedited options are available at checkout." + }); + } + + if (query.Contains("tent", StringComparison.OrdinalIgnoreCase) || query.Contains("fabric", StringComparison.OrdinalIgnoreCase)) + { + results.Add(new() + { + Name = "TrailRunner Tent Care Instructions", + Link = "https://contoso.com/manuals/trailrunner-tent", + Value = "Clean the tent fabric with lukewarm water and a non-detergent soap. Allow it to air dry completely before storage and avoid prolonged UV exposure to extend the lifespan of the waterproof coating." + }); + } + + return Task.FromResult>(results); +} diff --git a/dotnet/samples/Catalog/AgentWithTextSearchRag/README.md b/dotnet/samples/Catalog/AgentWithTextSearchRag/README.md new file mode 100644 index 0000000000..614597bed9 --- /dev/null +++ b/dotnet/samples/Catalog/AgentWithTextSearchRag/README.md @@ -0,0 +1,41 @@ +# What this sample demonstrates + +This sample demonstrates how to use TextSearchProvider to add retrieval augmented generation (RAG) capabilities to an AI agent. The provider runs a search against an external knowledge base before each model invocation and injects the results into the model context. + +Key features: +- Configuring TextSearchProvider with custom search behavior +- Running searches before AI invocations to provide relevant context +- Managing conversation memory with a rolling window approach +- Citing source documents in AI responses + +## Prerequisites + +Before running this sample, ensure you have: + +1. An Azure OpenAI endpoint configured +2. A deployment of a chat model (e.g., gpt-4o-mini) +3. Azure CLI installed and authenticated + +## Environment Variables + +Set the following environment variables: + +```powershell +# Replace with your Azure OpenAI endpoint +$env:AZURE_OPENAI_ENDPOINT="https://your-openai-resource.openai.azure.com/" + +# Optional, defaults to gpt-4o-mini +$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini" +``` + +## How It Works + +The sample uses a mock search function that demonstrates the RAG pattern: + +1. When the user asks a question, the TextSearchProvider intercepts it +2. The search function looks for relevant documents based on the query +3. Retrieved documents are injected into the model's context +4. The AI responds using both its training and the provided context +5. The agent can cite specific source documents in its answers + +The mock search function returns pre-defined snippets for demonstration purposes. In a production scenario, you would replace this with actual searches against your knowledge base (e.g., Azure AI Search, vector database, etc.). From e462d209fdbe87e2e3e7c3905b2ab061ff5bb15e Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Mon, 3 Nov 2025 19:42:59 +0100 Subject: [PATCH 05/15] Python: fix middleware and cleanup confusing function (#1865) * fix middleware and cleanup redundant function * added test to validate --- .../tests/test_azure_ai_agent_client.py | 13 ---- .../packages/core/agent_framework/_clients.py | 66 ++++--------------- .../core/agent_framework/_middleware.py | 6 +- .../packages/core/agent_framework/_tools.py | 4 +- .../packages/core/agent_framework/_types.py | 21 ++++++ .../core/agent_framework/observability.py | 2 +- .../azure/test_azure_assistants_client.py | 13 ---- .../tests/azure/test_azure_chat_client.py | 13 ---- .../azure/test_azure_responses_client.py | 14 ---- .../packages/core/tests/core/test_clients.py | 20 ++++++ .../openai/test_openai_assistants_client.py | 12 ---- .../tests/openai/test_openai_chat_client.py | 13 ---- .../openai/test_openai_responses_client.py | 13 ---- 13 files changed, 61 insertions(+), 149 deletions(-) diff --git a/python/packages/azure-ai/tests/test_azure_ai_agent_client.py b/python/packages/azure-ai/tests/test_azure_ai_agent_client.py index 3658f549ce..9f3a06ebee 100644 --- a/python/packages/azure-ai/tests/test_azure_ai_agent_client.py +++ b/python/packages/azure-ai/tests/test_azure_ai_agent_client.py @@ -559,19 +559,6 @@ async def test_azure_ai_chat_client_create_run_options_with_messages(mock_ai_pro assert len(run_options["additional_messages"]) == 1 # Only user message -async def test_azure_ai_chat_client_instructions_sent_once(mock_ai_project_client: MagicMock) -> None: - """Ensure instructions are only sent once for AzureAIAgentClient.""" - chat_client = create_test_azure_ai_chat_client(mock_ai_project_client) - - instructions = "You are a helpful assistant." - chat_options = ChatOptions(instructions=instructions) - messages = chat_client.prepare_messages([ChatMessage(role=Role.USER, text="Hello")], chat_options) - - run_options, _ = await chat_client._create_run_options(messages, chat_options) # type: ignore - - assert run_options.get("instructions") == instructions - - async def test_azure_ai_chat_client_inner_get_response(mock_ai_project_client: MagicMock) -> None: """Test _inner_get_response method.""" chat_client = create_test_azure_ai_chat_client(mock_ai_project_client, agent_id="test-agent") diff --git a/python/packages/core/agent_framework/_clients.py b/python/packages/core/agent_framework/_clients.py index 4089aea9e3..0b36b486c8 100644 --- a/python/packages/core/agent_framework/_clients.py +++ b/python/packages/core/agent_framework/_clients.py @@ -20,13 +20,7 @@ from ._middleware import ( from ._serialization import SerializationMixin from ._threads import ChatMessageStoreProtocol from ._tools import ToolProtocol -from ._types import ( - ChatMessage, - ChatOptions, - ChatResponse, - ChatResponseUpdate, - ToolMode, -) +from ._types import ChatMessage, ChatOptions, ChatResponse, ChatResponseUpdate, ToolMode, prepare_messages if TYPE_CHECKING: from ._agents import ChatAgent @@ -216,28 +210,7 @@ class ChatClientProtocol(Protocol): # region ChatClientBase -def prepare_messages(messages: str | ChatMessage | list[str] | list[ChatMessage]) -> list[ChatMessage]: - """Convert various message input formats into a list of ChatMessage objects. - - Args: - messages: The input messages in various supported formats. - - Returns: - A list of ChatMessage objects. - """ - if isinstance(messages, str): - return [ChatMessage(role="user", text=messages)] - if isinstance(messages, ChatMessage): - return [messages] - return_messages: list[ChatMessage] = [] - for msg in messages: - if isinstance(msg, str): - msg = ChatMessage(role="user", text=msg) - return_messages.append(msg) - return return_messages - - -def merge_chat_options( +def _merge_chat_options( *, base_chat_options: ChatOptions | Any | None, model_id: str | None = None, @@ -405,25 +378,6 @@ class BaseChatClient(SerializationMixin, ABC): return result - def prepare_messages( - self, messages: str | ChatMessage | list[str] | list[ChatMessage], chat_options: ChatOptions - ) -> MutableSequence[ChatMessage]: - """Convert various message input formats into a list of ChatMessage objects. - - Prepends system instructions if present in chat_options. - - Args: - messages: The input messages in various supported formats. - chat_options: The chat options containing instructions and other settings. - - Returns: - A mutable sequence of ChatMessage objects. - """ - if chat_options.instructions: - system_msg = ChatMessage(role="system", text=chat_options.instructions) - return [system_msg, *prepare_messages(messages)] - return prepare_messages(messages) - def _filter_internal_kwargs(self, kwargs: dict[str, Any]) -> dict[str, Any]: """Filter out internal framework parameters that shouldn't be passed to chat client implementations. @@ -584,7 +538,7 @@ class BaseChatClient(SerializationMixin, ABC): """ # Normalize tools and merge with base chat_options normalized_tools = await self._normalize_tools(tools) - chat_options = merge_chat_options( + chat_options = _merge_chat_options( base_chat_options=kwargs.pop("chat_options", None), model_id=model_id, frequency_penalty=frequency_penalty, @@ -612,7 +566,11 @@ class BaseChatClient(SerializationMixin, ABC): ) chat_options.store = True - prepped_messages = self.prepare_messages(messages, chat_options) + if chat_options.instructions: + system_msg = ChatMessage(role="system", text=chat_options.instructions) + prepped_messages = [system_msg, *prepare_messages(messages)] + else: + prepped_messages = prepare_messages(messages) self._prepare_tool_choice(chat_options=chat_options) filtered_kwargs = self._filter_internal_kwargs(kwargs) @@ -679,7 +637,7 @@ class BaseChatClient(SerializationMixin, ABC): """ # Normalize tools and merge with base chat_options normalized_tools = await self._normalize_tools(tools) - chat_options = merge_chat_options( + chat_options = _merge_chat_options( base_chat_options=kwargs.pop("chat_options", None), model_id=model_id, frequency_penalty=frequency_penalty, @@ -707,7 +665,11 @@ class BaseChatClient(SerializationMixin, ABC): ) chat_options.store = True - prepped_messages = self.prepare_messages(messages, chat_options) + if chat_options.instructions: + system_msg = ChatMessage(role="system", text=chat_options.instructions) + prepped_messages = [system_msg, *prepare_messages(messages)] + else: + prepped_messages = prepare_messages(messages) self._prepare_tool_choice(chat_options=chat_options) filtered_kwargs = self._filter_internal_kwargs(kwargs) diff --git a/python/packages/core/agent_framework/_middleware.py b/python/packages/core/agent_framework/_middleware.py index d61f9e9ee8..9bb730ba62 100644 --- a/python/packages/core/agent_framework/_middleware.py +++ b/python/packages/core/agent_framework/_middleware.py @@ -8,7 +8,7 @@ from functools import update_wrapper from typing import TYPE_CHECKING, Any, ClassVar, Generic, TypeAlias, TypeVar from ._serialization import SerializationMixin -from ._types import AgentRunResponse, AgentRunResponseUpdate, ChatMessage +from ._types import AgentRunResponse, AgentRunResponseUpdate, ChatMessage, prepare_messages from .exceptions import MiddlewareException if TYPE_CHECKING: @@ -1375,7 +1375,7 @@ def use_chat_middleware(chat_client_class: type[TChatClient]) -> type[TChatClien pipeline = ChatMiddlewarePipeline(chat_middleware_list) # type: ignore[arg-type] context = ChatContext( chat_client=self, - messages=self.prepare_messages(messages, chat_options), + messages=prepare_messages(messages), chat_options=chat_options, is_streaming=False, kwargs=kwargs, @@ -1425,7 +1425,7 @@ def use_chat_middleware(chat_client_class: type[TChatClient]) -> type[TChatClien pipeline = ChatMiddlewarePipeline(all_middleware) # type: ignore[arg-type] context = ChatContext( chat_client=self, - messages=self.prepare_messages(messages, chat_options), + messages=prepare_messages(messages), chat_options=chat_options, is_streaming=True, kwargs=kwargs, diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index 22f3dee18a..22b9921e49 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -1318,13 +1318,13 @@ def _handle_function_calls_response( messages: "str | ChatMessage | list[str] | list[ChatMessage]", **kwargs: Any, ) -> "ChatResponse": - from ._clients import prepare_messages from ._middleware import extract_and_merge_function_middleware from ._types import ( ChatMessage, FunctionApprovalRequestContent, FunctionCallContent, FunctionResultContent, + prepare_messages, ) # Extract and merge function middleware from chat client with kwargs pipeline @@ -1465,7 +1465,6 @@ def _handle_function_calls_streaming_response( **kwargs: Any, ) -> AsyncIterable["ChatResponseUpdate"]: """Wrap the inner get streaming response method to handle tool calls.""" - from ._clients import prepare_messages from ._middleware import extract_and_merge_function_middleware from ._types import ( ChatMessage, @@ -1473,6 +1472,7 @@ def _handle_function_calls_streaming_response( ChatResponseUpdate, FunctionCallContent, FunctionResultContent, + prepare_messages, ) # Extract and merge function middleware from chat client with kwargs pipeline diff --git a/python/packages/core/agent_framework/_types.py b/python/packages/core/agent_framework/_types.py index 3294f78a5d..f1a12f813e 100644 --- a/python/packages/core/agent_framework/_types.py +++ b/python/packages/core/agent_framework/_types.py @@ -2052,6 +2052,27 @@ class ChatMessage(SerializationMixin): return " ".join(content.text for content in self.contents if isinstance(content, TextContent)) +def prepare_messages(messages: str | ChatMessage | list[str] | list[ChatMessage]) -> list[ChatMessage]: + """Convert various message input formats into a list of ChatMessage objects. + + Args: + messages: The input messages in various supported formats. + + Returns: + A list of ChatMessage objects. + """ + if isinstance(messages, str): + return [ChatMessage(role="user", text=messages)] + if isinstance(messages, ChatMessage): + return [messages] + return_messages: list[ChatMessage] = [] + for msg in messages: + if isinstance(msg, str): + msg = ChatMessage(role="user", text=msg) + return_messages.append(msg) + return return_messages + + # region ChatResponse diff --git a/python/packages/core/agent_framework/observability.py b/python/packages/core/agent_framework/observability.py index 2bdaa90206..c17ce12666 100644 --- a/python/packages/core/agent_framework/observability.py +++ b/python/packages/core/agent_framework/observability.py @@ -1413,7 +1413,7 @@ def _capture_messages( finish_reason: "FinishReason | None" = None, ) -> None: """Log messages with extra information.""" - from ._clients import prepare_messages + from ._types import prepare_messages prepped = prepare_messages(messages) otel_messages: list[dict[str, Any]] = [] diff --git a/python/packages/core/tests/azure/test_azure_assistants_client.py b/python/packages/core/tests/azure/test_azure_assistants_client.py index 307e6c7ac1..758be68d3b 100644 --- a/python/packages/core/tests/azure/test_azure_assistants_client.py +++ b/python/packages/core/tests/azure/test_azure_assistants_client.py @@ -15,7 +15,6 @@ from agent_framework import ( ChatAgent, ChatClientProtocol, ChatMessage, - ChatOptions, ChatResponse, ChatResponseUpdate, HostedCodeInterpreterTool, @@ -155,18 +154,6 @@ def test_azure_assistants_client_init_with_default_headers(azure_openai_unit_tes assert chat_client.client.default_headers[key] == value -def test_azure_assistants_client_instructions_sent_once(mock_async_azure_openai: MagicMock) -> None: - """Ensure instructions are only included once for Azure OpenAI Assistants requests.""" - chat_client = create_test_azure_assistants_client(mock_async_azure_openai) - instructions = "You are a helpful assistant." - chat_options = ChatOptions(instructions=instructions) - - prepared_messages = chat_client.prepare_messages([ChatMessage(role="user", text="Hello")], chat_options) - run_options, _ = chat_client._prepare_options(prepared_messages, chat_options) # type: ignore[reportPrivateUsage] - - assert run_options.get("instructions") == instructions - - async def test_azure_assistants_client_get_assistant_id_or_create_existing_assistant( mock_async_azure_openai: MagicMock, ) -> None: diff --git a/python/packages/core/tests/azure/test_azure_chat_client.py b/python/packages/core/tests/azure/test_azure_chat_client.py index d43302d472..1b7dbb904b 100644 --- a/python/packages/core/tests/azure/test_azure_chat_client.py +++ b/python/packages/core/tests/azure/test_azure_chat_client.py @@ -23,7 +23,6 @@ from agent_framework import ( ChatAgent, ChatClientProtocol, ChatMessage, - ChatOptions, ChatResponse, ChatResponseUpdate, TextContent, @@ -84,18 +83,6 @@ def test_init_base_url(azure_openai_unit_test_env: dict[str, str]) -> None: assert azure_chat_client.client.default_headers[key] == value -def test_azure_openai_chat_client_instructions_sent_once(azure_openai_unit_test_env: dict[str, str]) -> None: - """Ensure instructions are only included once when preparing Azure OpenAI chat requests.""" - client = AzureOpenAIChatClient() - instructions = "You are a helpful assistant." - chat_options = ChatOptions(instructions=instructions) - - prepared_messages = client.prepare_messages([ChatMessage(role="user", text="Hello")], chat_options) - request_options = client._prepare_options(prepared_messages, chat_options) # type: ignore[reportPrivateUsage] - - assert json.dumps(request_options).count(instructions) == 1 - - @pytest.mark.parametrize("exclude_list", [["AZURE_OPENAI_BASE_URL"]], indirect=True) def test_init_endpoint(azure_openai_unit_test_env: dict[str, str]) -> None: azure_chat_client = AzureOpenAIChatClient() diff --git a/python/packages/core/tests/azure/test_azure_responses_client.py b/python/packages/core/tests/azure/test_azure_responses_client.py index 658aa21457..a495d05837 100644 --- a/python/packages/core/tests/azure/test_azure_responses_client.py +++ b/python/packages/core/tests/azure/test_azure_responses_client.py @@ -1,6 +1,5 @@ # Copyright (c) Microsoft. All rights reserved. -import json import os from typing import Annotated @@ -15,7 +14,6 @@ from agent_framework import ( ChatAgent, ChatClientProtocol, ChatMessage, - ChatOptions, ChatResponse, ChatResponseUpdate, HostedCodeInterpreterTool, @@ -114,18 +112,6 @@ def test_init_with_default_header(azure_openai_unit_test_env: dict[str, str]) -> assert azure_responses_client.client.default_headers[key] == value -def test_azure_responses_client_instructions_sent_once(azure_openai_unit_test_env: dict[str, str]) -> None: - """Ensure instructions are only included once for Azure OpenAI Responses requests.""" - client = AzureOpenAIResponsesClient() - instructions = "You are a helpful assistant." - chat_options = ChatOptions(instructions=instructions) - - prepared_messages = client.prepare_messages([ChatMessage(role="user", text="Hello")], chat_options) - request_options = client._prepare_options(prepared_messages, chat_options) # type: ignore[reportPrivateUsage] - - assert json.dumps(request_options).count(instructions) == 1 - - @pytest.mark.parametrize("exclude_list", [["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"]], indirect=True) def test_init_with_empty_model_id(azure_openai_unit_test_env: dict[str, str]) -> None: with pytest.raises(ServiceInitializationError): diff --git a/python/packages/core/tests/core/test_clients.py b/python/packages/core/tests/core/test_clients.py index c0e319e34b..423a7e42b5 100644 --- a/python/packages/core/tests/core/test_clients.py +++ b/python/packages/core/tests/core/test_clients.py @@ -1,10 +1,13 @@ # Copyright (c) Microsoft. All rights reserved. +from unittest.mock import patch + from agent_framework import ( BaseChatClient, ChatClientProtocol, ChatMessage, + ChatOptions, Role, ) @@ -39,3 +42,20 @@ async def test_base_client_get_response(chat_client_base: ChatClientProtocol): async def test_base_client_get_streaming_response(chat_client_base: ChatClientProtocol): async for update in chat_client_base.get_streaming_response(ChatMessage(role="user", text="Hello")): assert update.text == "update - Hello" or update.text == "another update" + + +async def test_chat_client_instructions_handling(chat_client_base: ChatClientProtocol): + instructions = "You are a helpful assistant." + with patch.object( + chat_client_base, + "_inner_get_response", + ) as mock_inner_get_response: + await chat_client_base.get_response("hello", chat_options=ChatOptions(instructions=instructions)) + mock_inner_get_response.assert_called_once() + _, kwargs = mock_inner_get_response.call_args + messages = kwargs.get("messages", []) + assert len(messages) == 2 + assert messages[0].role == Role.SYSTEM + assert messages[0].text == instructions + assert messages[1].role == Role.USER + assert messages[1].text == "hello" diff --git a/python/packages/core/tests/openai/test_openai_assistants_client.py b/python/packages/core/tests/openai/test_openai_assistants_client.py index be1a059b58..90947dd437 100644 --- a/python/packages/core/tests/openai/test_openai_assistants_client.py +++ b/python/packages/core/tests/openai/test_openai_assistants_client.py @@ -193,18 +193,6 @@ def test_openai_assistants_client_init_with_default_headers(openai_unit_test_env assert chat_client.client.default_headers[key] == value -def test_openai_assistants_client_instructions_sent_once(mock_async_openai: MagicMock) -> None: - """Ensure instructions are only included once for OpenAI Assistants requests.""" - chat_client = create_test_openai_assistants_client(mock_async_openai) - instructions = "You are a helpful assistant." - chat_options = ChatOptions(instructions=instructions) - - prepared_messages = chat_client.prepare_messages([ChatMessage(role=Role.USER, text="Hello")], chat_options) - run_options, _ = chat_client._prepare_options(prepared_messages, chat_options) # type: ignore[reportPrivateUsage] - - assert run_options.get("instructions") == instructions - - async def test_openai_assistants_client_get_assistant_id_or_create_existing_assistant( mock_async_openai: MagicMock, ) -> None: 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 63db8c071e..d159091311 100644 --- a/python/packages/core/tests/openai/test_openai_chat_client.py +++ b/python/packages/core/tests/openai/test_openai_chat_client.py @@ -1,6 +1,5 @@ # Copyright (c) Microsoft. All rights reserved. -import json import os from typing import Annotated from unittest.mock import MagicMock, patch @@ -100,18 +99,6 @@ def test_init_base_url_from_settings_env() -> None: assert str(client.client.base_url) == "https://custom-openai-endpoint.com/v1/" -def test_openai_chat_client_instructions_sent_once(openai_unit_test_env: dict[str, str]) -> None: - """Ensure instructions are only included once for OpenAI chat requests.""" - client = OpenAIChatClient() - instructions = "You are a helpful assistant." - chat_options = ChatOptions(instructions=instructions) - - prepared_messages = client.prepare_messages([ChatMessage(role="user", text="Hello")], chat_options) - request_options = client._prepare_options(prepared_messages, chat_options) # type: ignore[reportPrivateUsage] - - assert json.dumps(request_options).count(instructions) == 1 - - @pytest.mark.parametrize("exclude_list", [["OPENAI_CHAT_MODEL_ID"]], indirect=True) def test_init_with_empty_model_id(openai_unit_test_env: dict[str, str]) -> None: with pytest.raises(ServiceInitializationError): diff --git a/python/packages/core/tests/openai/test_openai_responses_client.py b/python/packages/core/tests/openai/test_openai_responses_client.py index c6ce05c2a3..cb4f0dc0d3 100644 --- a/python/packages/core/tests/openai/test_openai_responses_client.py +++ b/python/packages/core/tests/openai/test_openai_responses_client.py @@ -2,7 +2,6 @@ import asyncio import base64 -import json import os from typing import Annotated from unittest.mock import MagicMock, patch @@ -138,18 +137,6 @@ def test_init_with_default_header(openai_unit_test_env: dict[str, str]) -> None: assert openai_responses_client.client.default_headers[key] == value -def test_openai_responses_client_instructions_sent_once(openai_unit_test_env: dict[str, str]) -> None: - """Ensure instructions are only included once for OpenAI Responses requests.""" - client = OpenAIResponsesClient() - instructions = "You are a helpful assistant." - chat_options = ChatOptions(instructions=instructions) - - prepared_messages = client.prepare_messages([ChatMessage(role="user", text="Hello")], chat_options) - request_options = client._prepare_options(prepared_messages, chat_options) # type: ignore[reportPrivateUsage] - - assert json.dumps(request_options).count(instructions) == 1 - - @pytest.mark.parametrize("exclude_list", [["OPENAI_RESPONSES_MODEL_ID"]], indirect=True) def test_init_with_empty_model_id(openai_unit_test_env: dict[str, str]) -> None: with pytest.raises(ServiceInitializationError): From a766a81243149ee902c025069a0966c293a6ee71 Mon Sep 17 00:00:00 2001 From: Giles Odigwe <79032838+giles17@users.noreply.github.com> Date: Mon, 3 Nov 2025 10:47:09 -0800 Subject: [PATCH 06/15] a2a workflows fix (#1860) --- .../a2a/agent_framework_a2a/_agent.py | 16 ++++++- python/packages/a2a/tests/test_a2a_agent.py | 43 ++++++++++++++++++- 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/python/packages/a2a/agent_framework_a2a/_agent.py b/python/packages/a2a/agent_framework_a2a/_agent.py index e353a7c7fa..68694e3db4 100644 --- a/python/packages/a2a/agent_framework_a2a/_agent.py +++ b/python/packages/a2a/agent_framework_a2a/_agent.py @@ -127,7 +127,21 @@ class A2AAgent(BaseAgent): ) factory = ClientFactory(config) interceptors = [auth_interceptor] if auth_interceptor is not None else None - self.client = factory.create(agent_card, interceptors=interceptors) # type: ignore + + # Attempt transport negotiation with the provided agent card + try: + self.client = factory.create(agent_card, interceptors=interceptors) # type: ignore + except Exception as transport_error: + # Transport negotiation failed - fall back to minimal agent card with JSONRPC + fallback_card = minimal_agent_card(agent_card.url, [TransportProtocol.jsonrpc]) + try: + self.client = factory.create(fallback_card, interceptors=interceptors) # type: ignore + except Exception as fallback_error: + raise RuntimeError( + f"A2A transport negotiation failed. " + f"Primary error: {transport_error}. " + f"Fallback error: {fallback_error}" + ) from transport_error async def __aenter__(self) -> "A2AAgent": """Async context manager entry.""" diff --git a/python/packages/a2a/tests/test_a2a_agent.py b/python/packages/a2a/tests/test_a2a_agent.py index b8fe97be60..f703c92a96 100644 --- a/python/packages/a2a/tests/test_a2a_agent.py +++ b/python/packages/a2a/tests/test_a2a_agent.py @@ -2,10 +2,22 @@ from collections.abc import AsyncIterator from typing import Any -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock, MagicMock, patch from uuid import uuid4 -from a2a.types import Artifact, DataPart, FilePart, FileWithUri, Message, Part, Task, TaskState, TaskStatus, TextPart +from a2a.types import ( + AgentCard, + Artifact, + DataPart, + FilePart, + FileWithUri, + Message, + Part, + Task, + TaskState, + TaskStatus, + TextPart, +) from a2a.types import Role as A2ARole from agent_framework import ( AgentRunResponse, @@ -515,3 +527,30 @@ def test_auth_interceptor_parameter() -> None: # Verify the agent was created successfully assert agent.name == "test-agent" assert agent.client is not None + + +def test_transport_negotiation_both_fail() -> None: + """Test that RuntimeError is raised when both primary and fallback transport negotiation fail.""" + # Create a mock agent card + mock_agent_card = MagicMock(spec=AgentCard) + mock_agent_card.url = "http://test-agent.example.com" + + # Mock the factory to simulate both primary and fallback failures + mock_factory = MagicMock() + + # Both calls to factory.create() fail + primary_error = Exception("no compatible transports found") + fallback_error = Exception("fallback also failed") + mock_factory.create.side_effect = [primary_error, fallback_error] + + with ( + patch("agent_framework_a2a._agent.ClientFactory", return_value=mock_factory), + patch("agent_framework_a2a._agent.minimal_agent_card"), + patch("agent_framework_a2a._agent.httpx.AsyncClient"), + raises(RuntimeError, match="A2A transport negotiation failed"), + ): + # Attempt to create A2AAgent - should raise RuntimeError + A2AAgent( + name="test-agent", + agent_card=mock_agent_card, + ) From 12d17acdc07dd54b7bb5ad15644b4be5e3296aa5 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Mon, 3 Nov 2025 20:32:28 +0100 Subject: [PATCH 07/15] Python: Introducing the Anthropic Client (#1819) * initial version of anthropic connector * updated implementation and added tests * fix type and readme * mypy fix and int tests enabled * add integration test setup * updated based on comments * improved function result handling * added extra unordered test * updated from review * fix tool choice handling * same fix for chat client --- .github/workflows/python-merge-tests.yml | 2 + python/packages/anthropic/LICENSE | 21 + python/packages/anthropic/README.md | 18 + .../agent_framework_anthropic/__init__.py | 15 + .../agent_framework_anthropic/_chat_client.py | 658 +++++++++++++++ python/packages/anthropic/pyproject.toml | 88 ++ python/packages/anthropic/tests/conftest.py | 56 ++ .../anthropic/tests/test_anthropic_client.py | 777 ++++++++++++++++++ .../agent_framework_azure_ai/_chat_client.py | 4 +- .../packages/core/agent_framework/_clients.py | 6 +- .../packages/core/agent_framework/_types.py | 30 +- .../agent_framework/anthropic/__init__.py | 23 + .../agent_framework/anthropic/__init__.pyi | 5 + .../openai/_assistants_client.py | 2 +- .../agent_framework/openai/_chat_client.py | 2 + .../openai/_responses_client.py | 2 + python/packages/core/pyproject.toml | 2 + python/pyproject.toml | 6 +- python/samples/README.md | 3 +- .../agents/anthropic/README.md | 6 +- .../agents/anthropic/anthropic_advanced.py | 58 ++ ...enai_chat_client.py => anthropic_basic.py} | 24 +- python/uv.lock | 81 +- 23 files changed, 1826 insertions(+), 63 deletions(-) create mode 100644 python/packages/anthropic/LICENSE create mode 100644 python/packages/anthropic/README.md create mode 100644 python/packages/anthropic/agent_framework_anthropic/__init__.py create mode 100644 python/packages/anthropic/agent_framework_anthropic/_chat_client.py create mode 100644 python/packages/anthropic/pyproject.toml create mode 100644 python/packages/anthropic/tests/conftest.py create mode 100644 python/packages/anthropic/tests/test_anthropic_client.py create mode 100644 python/packages/core/agent_framework/anthropic/__init__.py create mode 100644 python/packages/core/agent_framework/anthropic/__init__.pyi create mode 100644 python/samples/getting_started/agents/anthropic/anthropic_advanced.py rename python/samples/getting_started/agents/anthropic/{anthropic_with_openai_chat_client.py => anthropic_basic.py} (67%) diff --git a/.github/workflows/python-merge-tests.yml b/.github/workflows/python-merge-tests.yml index 92a5934f0e..bd5768b968 100644 --- a/.github/workflows/python-merge-tests.yml +++ b/.github/workflows/python-merge-tests.yml @@ -60,6 +60,8 @@ jobs: OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI__CHATMODELID }} OPENAI_RESPONSES_MODEL_ID: ${{ vars.OPENAI__RESPONSESMODELID }} OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + ANTHROPIC_CHAT_MODEL_ID: ${{ vars.ANTHROPIC_CHAT_MODEL_ID }} AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }} AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }} diff --git a/python/packages/anthropic/LICENSE b/python/packages/anthropic/LICENSE new file mode 100644 index 0000000000..9e841e7a26 --- /dev/null +++ b/python/packages/anthropic/LICENSE @@ -0,0 +1,21 @@ + MIT License + + Copyright (c) Microsoft Corporation. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE diff --git a/python/packages/anthropic/README.md b/python/packages/anthropic/README.md new file mode 100644 index 0000000000..f8c8af674f --- /dev/null +++ b/python/packages/anthropic/README.md @@ -0,0 +1,18 @@ +# Get Started with Microsoft Agent Framework Anthropic + +Please install this package via pip: + +```bash +pip install agent-framework-anthropic --pre +``` + +## Anthropic Integration + +The Anthropic integration enables communication with the Anthropic API, allowing your Agent Framework applications to leverage Anthropic's capabilities. + +### Basic Usage Example + +See the [Anthropic agent examples](https://github.com/microsoft/agent-framework/tree/main/python/samples/getting_started/agents/anthropic/) which demonstrate: + +- Connecting to a Anthropic endpoint with an agent +- Streaming and non-streaming responses diff --git a/python/packages/anthropic/agent_framework_anthropic/__init__.py b/python/packages/anthropic/agent_framework_anthropic/__init__.py new file mode 100644 index 0000000000..e81064b213 --- /dev/null +++ b/python/packages/anthropic/agent_framework_anthropic/__init__.py @@ -0,0 +1,15 @@ +# Copyright (c) Microsoft. All rights reserved. + +import importlib.metadata + +from ._chat_client import AnthropicClient + +try: + __version__ = importlib.metadata.version(__name__) +except importlib.metadata.PackageNotFoundError: + __version__ = "0.0.0" # Fallback for development mode + +__all__ = [ + "AnthropicClient", + "__version__", +] diff --git a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py new file mode 100644 index 0000000000..d7b0334934 --- /dev/null +++ b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py @@ -0,0 +1,658 @@ +# Copyright (c) Microsoft. All rights reserved. + +from collections.abc import AsyncIterable, MutableMapping, MutableSequence, Sequence +from typing import Any, ClassVar, Final, TypeVar + +from agent_framework import ( + AGENT_FRAMEWORK_USER_AGENT, + AIFunction, + Annotations, + BaseChatClient, + ChatMessage, + ChatOptions, + ChatResponse, + ChatResponseUpdate, + CitationAnnotation, + Contents, + FinishReason, + FunctionCallContent, + FunctionResultContent, + HostedCodeInterpreterTool, + HostedMCPTool, + HostedWebSearchTool, + Role, + TextContent, + TextReasoningContent, + TextSpanRegion, + ToolProtocol, + UsageContent, + UsageDetails, + get_logger, + prepare_function_call_results, + use_chat_middleware, + use_function_invocation, +) +from agent_framework._pydantic import AFBaseSettings +from agent_framework.exceptions import ServiceInitializationError +from agent_framework.observability import use_observability +from anthropic import AsyncAnthropic +from anthropic.types.beta import ( + BetaContentBlock, + BetaMessage, + BetaMessageDeltaUsage, + BetaRawContentBlockDelta, + BetaRawMessageStreamEvent, + BetaTextBlock, + BetaUsage, +) +from pydantic import SecretStr, ValidationError + +logger = get_logger("agent_framework.anthropic") + +ANTHROPIC_DEFAULT_MAX_TOKENS: Final[int] = 1024 +BETA_FLAGS: Final[list[str]] = ["mcp-client-2025-04-04", "code-execution-2025-08-25"] + +ROLE_MAP: dict[Role, str] = { + Role.USER: "user", + Role.ASSISTANT: "assistant", + Role.SYSTEM: "user", + Role.TOOL: "user", +} + +FINISH_REASON_MAP: dict[str, FinishReason] = { + "stop_sequence": FinishReason.STOP, + "max_tokens": FinishReason.LENGTH, + "tool_use": FinishReason.TOOL_CALLS, + "end_turn": FinishReason.STOP, + "refusal": FinishReason.CONTENT_FILTER, + "pause_turn": FinishReason.STOP, +} + + +class AnthropicSettings(AFBaseSettings): + """Anthropic Project settings. + + The settings are first loaded from environment variables with the prefix 'ANTHROPIC_'. + If the environment variables are not found, the settings can be loaded from a .env file + with the encoding 'utf-8'. If the settings are not found in the .env file, the settings + are ignored; however, validation will fail alerting that the settings are missing. + + Keyword Args: + api_key: The Anthropic API key. + chat_model_id: The Anthropic chat model ID. + env_file_path: If provided, the .env settings are read from this file path location. + env_file_encoding: The encoding of the .env file, defaults to 'utf-8'. + + Examples: + .. code-block:: python + + from agent_framework.anthropic import AnthropicSettings + + # Using environment variables + # Set ANTHROPIC_API_KEY=your_anthropic_api_key + # ANTHROPIC_CHAT_MODEL_ID=claude-sonnet-4-5-20250929 + + # Or passing parameters directly + settings = AnthropicSettings(chat_model_id="claude-sonnet-4-5-20250929") + + # Or loading from a .env file + settings = AnthropicSettings(env_file_path="path/to/.env") + """ + + env_prefix: ClassVar[str] = "ANTHROPIC_" + + api_key: SecretStr | None = None + chat_model_id: str | None = None + + +TAnthropicClient = TypeVar("TAnthropicClient", bound="AnthropicClient") + + +@use_function_invocation +@use_observability +@use_chat_middleware +class AnthropicClient(BaseChatClient): + """Anthropic Chat client.""" + + OTEL_PROVIDER_NAME: ClassVar[str] = "anthropic" # type: ignore[reportIncompatibleVariableOverride, misc] + + def __init__( + self, + *, + api_key: str | None = None, + model_id: str | None = None, + anthropic_client: AsyncAnthropic | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + **kwargs: Any, + ) -> None: + """Initialize an Anthropic Agent client. + + Keyword Args: + api_key: The Anthropic API key to use for authentication. + model_id: The ID of the model to use. + anthropic_client: An existing Anthropic client to use. If not provided, one will be created. + This can be used to further configure the client before passing it in. + For instance if you need to set a different base_url for testing or private deployments. + env_file_path: Path to environment file for loading settings. + env_file_encoding: Encoding of the environment file. + kwargs: Additional keyword arguments passed to the parent class. + + Examples: + .. code-block:: python + + from agent_framework.anthropic import AnthropicClient + from azure.identity.aio import DefaultAzureCredential + + # Using environment variables + # Set ANTHROPIC_API_KEY=your_anthropic_api_key + # ANTHROPIC_CHAT_MODEL_ID=claude-sonnet-4-5-20250929 + + # Or passing parameters directly + client = AnthropicClient( + model_id="claude-sonnet-4-5-20250929", + api_key="your_anthropic_api_key", + ) + + # Or loading from a .env file + client = AnthropicClient(env_file_path="path/to/.env") + + # Or passing in an existing client + from anthropic import AsyncAnthropic + + anthropic_client = AsyncAnthropic( + api_key="your_anthropic_api_key", base_url="https://custom-anthropic-endpoint.com" + ) + client = AnthropicClient( + model_id="claude-sonnet-4-5-20250929", + anthropic_client=anthropic_client, + ) + + """ + try: + anthropic_settings = AnthropicSettings( + api_key=api_key, # type: ignore[arg-type] + chat_model_id=model_id, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + ) + except ValidationError as ex: + raise ServiceInitializationError("Failed to create Anthropic settings.", ex) from ex + + if anthropic_client is None: + if not anthropic_settings.api_key: + raise ServiceInitializationError( + "Anthropic API key is required. Set via 'api_key' parameter " + "or 'ANTHROPIC_API_KEY' environment variable." + ) + + anthropic_client = AsyncAnthropic( + api_key=anthropic_settings.api_key.get_secret_value(), + default_headers={"User-Agent": AGENT_FRAMEWORK_USER_AGENT}, + ) + + # Initialize parent + super().__init__(**kwargs) + + # Initialize instance variables + self.anthropic_client = anthropic_client + self.model_id = anthropic_settings.chat_model_id + # streaming requires tracking the last function call ID and name + self._last_call_id_name: tuple[str, str] | None = None + + # region Get response methods + + async def _inner_get_response( + self, + *, + messages: MutableSequence[ChatMessage], + chat_options: ChatOptions, + **kwargs: Any, + ) -> ChatResponse: + # Extract necessary state from messages and options + run_options = self._create_run_options(messages, chat_options, **kwargs) + message = await self.anthropic_client.beta.messages.create(**run_options, stream=False) + return self._process_message(message) + + async def _inner_get_streaming_response( + self, + *, + messages: MutableSequence[ChatMessage], + chat_options: ChatOptions, + **kwargs: Any, + ) -> AsyncIterable[ChatResponseUpdate]: + # Extract necessary state from messages and options + run_options = self._create_run_options(messages, chat_options, **kwargs) + async for chunk in await self.anthropic_client.beta.messages.create(**run_options, stream=True): + parsed_chunk = self._process_stream_event(chunk) + if parsed_chunk: + yield parsed_chunk + + # region Create Run Options and Helpers + + def _create_run_options( + self, + messages: MutableSequence[ChatMessage], + chat_options: ChatOptions, + **kwargs: Any, + ) -> dict[str, Any]: + """Create run options for the Anthropic client based on messages and chat options. + + Args: + messages: The list of chat messages. + chat_options: The chat options. + kwargs: Additional keyword arguments. + + Returns: + A dictionary of run options for the Anthropic client. + """ + run_options: dict[str, Any] = { + "model": chat_options.model_id or self.model_id, + "messages": self._convert_messages_to_anthropic_format(messages), + "max_tokens": chat_options.max_tokens or ANTHROPIC_DEFAULT_MAX_TOKENS, + "extra_headers": {"User-Agent": AGENT_FRAMEWORK_USER_AGENT}, + "betas": BETA_FLAGS, + } + + # Add any additional options from chat_options or kwargs + if chat_options.temperature is not None: + run_options["temperature"] = chat_options.temperature + if chat_options.top_p is not None: + run_options["top_p"] = chat_options.top_p + if chat_options.stop is not None: + run_options["stop_sequences"] = chat_options.stop + if messages and isinstance(messages[0], ChatMessage) and messages[0].role == Role.SYSTEM: + # first system message is passed as instructions + run_options["system"] = messages[0].text + if chat_options.tool_choice is not None: + match ( + chat_options.tool_choice if isinstance(chat_options.tool_choice, str) else chat_options.tool_choice.mode + ): + case "auto": + run_options["tool_choice"] = {"type": "auto"} + if chat_options.allow_multiple_tool_calls is not None: + run_options["tool_choice"][ # type:ignore[reportArgumentType] + "disable_parallel_tool_use" + ] = not chat_options.allow_multiple_tool_calls + case "required": + if chat_options.tool_choice.required_function_name: + run_options["tool_choice"] = { + "type": "tool", + "name": chat_options.tool_choice.required_function_name, + } + if chat_options.allow_multiple_tool_calls is not None: + run_options["tool_choice"][ # type:ignore[reportArgumentType] + "disable_parallel_tool_use" + ] = not chat_options.allow_multiple_tool_calls + else: + run_options["tool_choice"] = {"type": "any"} + if chat_options.allow_multiple_tool_calls is not None: + run_options["tool_choice"][ # type:ignore[reportArgumentType] + "disable_parallel_tool_use" + ] = not chat_options.allow_multiple_tool_calls + case "none": + run_options["tool_choice"] = {"type": "none"} + case _: + logger.debug(f"Ignoring unsupported tool choice mode: {chat_options.tool_choice.mode} for now") + if tools_and_mcp := self._convert_tools_to_anthropic_format(chat_options.tools): + run_options.update(tools_and_mcp) + if chat_options.additional_properties: + run_options.update(chat_options.additional_properties) + run_options.update(kwargs) + return run_options + + def _convert_messages_to_anthropic_format(self, messages: MutableSequence[ChatMessage]) -> list[dict[str, Any]]: + """Convert a list of ChatMessages to the format expected by the Anthropic client. + + This skips the first message if it is a system message, + as Anthropic expects system instructions as a separate parameter. + """ + # first system message is passed as instructions + if messages and isinstance(messages[0], ChatMessage) and messages[0].role == Role.SYSTEM: + return [self._convert_message_to_anthropic_format(msg) for msg in messages[1:]] + return [self._convert_message_to_anthropic_format(msg) for msg in messages] + + def _convert_message_to_anthropic_format(self, message: ChatMessage) -> dict[str, Any]: + """Convert a ChatMessage to the format expected by the Anthropic client. + + Args: + message: The ChatMessage to convert. + + Returns: + A dictionary representing the message in Anthropic format. + """ + a_content: list[dict[str, Any]] = [] + for content in message.contents: + match content.type: + case "text": + a_content.append({"type": "text", "text": content.text}) + case "data": + if content.has_top_level_media_type("image"): + a_content.append({ + "type": "image", + "source": {"data": content.uri, "media_type": content.media_type}, + }) + case "uri": + if content.has_top_level_media_type("image"): + a_content.append({"type": "image", "source": {"type": "url", "url": content.uri}}) + case "function_call": + a_content.append({ + "type": "tool_use", + "id": content.call_id, + "name": content.name, + "input": content.parse_arguments(), + }) + case "function_result": + a_content.append({ + "type": "tool_result", + "tool_use_id": content.call_id, + "content": prepare_function_call_results(content.result), + "is_error": content.exception is not None, + }) + case "text_reasoning": + a_content.append({"type": "thinking", "thinking": content.text}) + case _: + logger.debug(f"Ignoring unsupported content type: {content.type} for now") + + return { + "role": ROLE_MAP.get(message.role, "user"), + "content": a_content, + } + + def _convert_tools_to_anthropic_format( + self, tools: list[ToolProtocol | MutableMapping[str, Any]] | None + ) -> dict[str, Any] | None: + if not tools: + return None + tool_list: list[MutableMapping[str, Any]] = [] + mcp_server_list: list[MutableMapping[str, Any]] = [] + for tool in tools: + match tool: + case MutableMapping(): + tool_list.append(tool) + case AIFunction(): + tool_list.append({ + "type": "custom", + "name": tool.name, + "description": tool.description, + "input_schema": tool.parameters(), + }) + case HostedWebSearchTool(): + search_tool: dict[str, Any] = { + "type": "web_search_20250305", + "name": "web_search", + } + if tool.additional_properties: + search_tool.update(tool.additional_properties) + tool_list.append(search_tool) + case HostedCodeInterpreterTool(): + code_tool: dict[str, Any] = { + "type": "code_execution_20250825", + "name": "code_interpreter", + } + tool_list.append(code_tool) + case HostedMCPTool(): + server_def: dict[str, Any] = { + "type": "url", + "name": tool.name, + "url": str(tool.url), + } + if tool.allowed_tools: + server_def["tool_configuration"] = {"allowed_tools": list(tool.allowed_tools)} + if tool.headers and (auth := tool.headers.get("authorization")): + server_def["authorization_token"] = auth + mcp_server_list.append(server_def) + case _: + logger.debug(f"Ignoring unsupported tool type: {type(tool)} for now") + + all_tools: dict[str, list[MutableMapping[str, Any]]] = {} + if tool_list: + all_tools["tools"] = tool_list + if mcp_server_list: + all_tools["mcp_servers"] = mcp_server_list + return all_tools + + # region Response Processing Methods + + def _process_message(self, message: BetaMessage) -> ChatResponse: + """Process the response from the Anthropic client. + + Args: + message: The message returned by the Anthropic client. + + Returns: + A ChatResponse object containing the processed response. + """ + return ChatResponse( + response_id=message.id, + messages=[ + ChatMessage( + role=Role.ASSISTANT, + contents=self._parse_message_contents(message.content), + raw_representation=message, + ) + ], + usage_details=self._parse_message_usage(message.usage), + model_id=message.model, + finish_reason=FINISH_REASON_MAP.get(message.stop_reason) if message.stop_reason else None, + raw_response=message, + ) + + def _process_stream_event(self, event: BetaRawMessageStreamEvent) -> ChatResponseUpdate | None: + """Process a streaming event from the Anthropic client. + + Args: + event: The streaming event returned by the Anthropic client. + + Returns: + A ChatResponseUpdate object containing the processed update. + """ + match event.type: + case "message_start": + usage_details: list[UsageContent] = [] + if event.message.usage and (details := self._parse_message_usage(event.message.usage)): + usage_details.append(UsageContent(details=details)) + + return ChatResponseUpdate( + response_id=event.message.id, + contents=[*self._parse_message_contents(event.message.content), *usage_details], + model_id=event.message.model, + finish_reason=FINISH_REASON_MAP.get(event.message.stop_reason) + if event.message.stop_reason + else None, + raw_response=event, + ) + case "message_delta": + usage = self._parse_message_usage(event.usage) + return ChatResponseUpdate( + contents=[UsageContent(details=usage, raw_representation=event.usage)] if usage else [], + raw_response=event, + ) + case "message_stop": + logger.debug("Received message_stop event; no content to process.") + case "content_block_start": + contents = self._parse_message_contents([event.content_block]) + return ChatResponseUpdate( + contents=contents, + raw_response=event, + ) + case "content_block_delta": + contents = self._parse_message_contents([event.delta]) + return ChatResponseUpdate( + contents=contents, + raw_response=event, + ) + case "content_block_stop": + logger.debug("Received content_block_stop event; no content to process.") + case _: + logger.debug(f"Ignoring unsupported event type: {event.type}") + return None + + def _parse_message_usage(self, usage: BetaUsage | BetaMessageDeltaUsage | None) -> UsageDetails | None: + """Parse usage details from the Anthropic message usage.""" + if not usage: + return None + usage_details = UsageDetails(output_token_count=usage.output_tokens) + if usage.input_tokens is not None: + usage_details.input_token_count = usage.input_tokens + if usage.cache_creation_input_tokens is not None: + usage_details.additional_counts["anthropic.cache_creation_input_tokens"] = usage.cache_creation_input_tokens + if usage.cache_read_input_tokens is not None: + usage_details.additional_counts["anthropic.cache_read_input_tokens"] = usage.cache_read_input_tokens + return usage_details + + def _parse_message_contents( + self, content: Sequence[BetaContentBlock | BetaRawContentBlockDelta | BetaTextBlock] + ) -> list[Contents]: + """Parse contents from the Anthropic message.""" + contents: list[Contents] = [] + for content_block in content: + match content_block.type: + case "text" | "text_delta": + contents.append( + TextContent( + text=content_block.text, + raw_representation=content_block, + annotations=self._parse_citations(content_block), + ) + ) + case "tool_use": + self._last_call_id_name = (content_block.id, content_block.name) + contents.append( + FunctionCallContent( + call_id=content_block.id, + name=content_block.name, + arguments=content_block.input, + raw_representation=content_block, + ) + ) + case "mcp_tool_use" | "server_tool_use": + self._last_call_id_name = (content_block.id, content_block.name) + contents.append( + FunctionCallContent( + call_id=content_block.id, + name=content_block.name, + arguments=content_block.input, + raw_representation=content_block, + ) + ) + case "mcp_tool_result": + call_id, name = self._last_call_id_name or (None, None) + contents.append( + FunctionResultContent( + call_id=content_block.tool_use_id, + name=name if name and call_id == content_block.tool_use_id else "mcp_tool", + result=self._parse_message_contents(content_block.content) + if isinstance(content_block.content, list) + else content_block.content, + raw_representation=content_block, + ) + ) + case "web_search_tool_result" | "web_fetch_tool_result": + call_id, name = self._last_call_id_name or (None, None) + contents.append( + FunctionResultContent( + call_id=content_block.tool_use_id, + name=name if name and call_id == content_block.tool_use_id else "web_tool", + result=content_block.content, + raw_representation=content_block, + ) + ) + case ( + "code_execution_tool_result" + | "bash_code_execution_tool_result" + | "text_editor_code_execution_tool_result" + ): + call_id, name = self._last_call_id_name or (None, None) + contents.append( + FunctionResultContent( + call_id=content_block.tool_use_id, + name=name if name and call_id == content_block.tool_use_id else "code_execution_tool", + result=content_block.content, + raw_representation=content_block, + ) + ) + case "input_json_delta": + call_id, name = self._last_call_id_name if self._last_call_id_name else ("", "") + contents.append( + FunctionCallContent( + call_id=call_id, + name=name, + arguments=content_block.partial_json, + raw_representation=content_block, + ) + ) + case "thinking" | "thinking_delta": + contents.append(TextReasoningContent(text=content_block.thinking, raw_representation=content_block)) + case _: + logger.debug(f"Ignoring unsupported content type: {content_block.type} for now") + return contents + + def _parse_citations( + self, content_block: BetaContentBlock | BetaRawContentBlockDelta | BetaTextBlock + ) -> list[Annotations] | None: + content_citations = getattr(content_block, "citations", None) + if not content_citations: + return None + annotations: list[Annotations] = [] + for citation in content_citations: + cit = CitationAnnotation(raw_representation=citation) + match citation.type: + case "char_location": + cit.title = citation.title + cit.snippet = citation.cited_text + if citation.file_id: + cit.file_id = citation.file_id + if not cit.annotated_regions: + cit.annotated_regions = [] + cit.annotated_regions.append( + TextSpanRegion(start_index=citation.start_char_index, end_index=citation.end_char_index) + ) + case "page_location": + cit.title = citation.document_title + cit.snippet = citation.cited_text + if citation.file_id: + cit.file_id = citation.file_id + if not cit.annotated_regions: + cit.annotated_regions = [] + cit.annotated_regions.append( + TextSpanRegion( + start_index=citation.start_page_number, + end_index=citation.end_page_number, + ) + ) + case "content_block_location": + cit.title = citation.document_title + cit.snippet = citation.cited_text + if citation.file_id: + cit.file_id = citation.file_id + if not cit.annotated_regions: + cit.annotated_regions = [] + cit.annotated_regions.append( + TextSpanRegion(start_index=citation.start_block_index, end_index=citation.end_block_index) + ) + case "web_search_result_location": + cit.title = citation.title + cit.snippet = citation.cited_text + cit.url = citation.url + case "search_result_location": + cit.title = citation.title + cit.snippet = citation.cited_text + cit.url = citation.source + if not cit.annotated_regions: + cit.annotated_regions = [] + cit.annotated_regions.append( + TextSpanRegion(start_index=citation.start_block_index, end_index=citation.end_block_index) + ) + case _: + logger.debug(f"Unknown citation type encountered: {citation.type}") + annotations.append(cit) + return annotations or None + + def service_url(self) -> str: + """Get the service URL for the chat client. + + Returns: + The service URL for the chat client, or None if not set. + """ + return str(self.anthropic_client.base_url) diff --git a/python/packages/anthropic/pyproject.toml b/python/packages/anthropic/pyproject.toml new file mode 100644 index 0000000000..4e620c7595 --- /dev/null +++ b/python/packages/anthropic/pyproject.toml @@ -0,0 +1,88 @@ +[project] +name = "agent-framework-anthropic" +description = "Anthropic integration for Microsoft Agent Framework." +authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] +readme = "README.md" +requires-python = ">=3.10" +version = "1.0.0b251028" +license-files = ["LICENSE"] +urls.homepage = "https://aka.ms/agent-framework" +urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" +urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true" +urls.issues = "https://github.com/microsoft/agent-framework/issues" +classifiers = [ + "License :: OSI Approved :: MIT License", + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Typing :: Typed", +] +dependencies = [ + "agent-framework-core", + "anthropic>=0.70.0,<1", +] + +[tool.uv] +prerelease = "if-necessary-or-explicit" +environments = [ + "sys_platform == 'darwin'", + "sys_platform == 'linux'", + "sys_platform == 'win32'" +] + +[tool.uv-dynamic-versioning] +fallback-version = "0.0.0" +[tool.pytest.ini_options] +testpaths = 'tests' +addopts = "-ra -q -r fEX" +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" +filterwarnings = [ + "ignore:Support for class-based `config` is deprecated:DeprecationWarning:pydantic.*" +] +timeout = 120 + +[tool.ruff] +extend = "../../pyproject.toml" + +[tool.coverage.run] +omit = [ + "**/__init__.py" +] + +[tool.pyright] +extends = "../../pyproject.toml" +exclude = ['tests'] + +[tool.mypy] +plugins = ['pydantic.mypy'] +strict = true +python_version = "3.10" +ignore_missing_imports = true +disallow_untyped_defs = true +no_implicit_optional = true +check_untyped_defs = true +warn_return_any = true +show_error_codes = true +warn_unused_ignores = false +disallow_incomplete_defs = true +disallow_untyped_decorators = true + +[tool.bandit] +targets = ["agent_framework_anthropic"] +exclude_dirs = ["tests"] + +[tool.poe] +executor.type = "uv" +include = "../../shared_tasks.toml" +[tool.poe.tasks] +mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_anthropic" +test = "pytest --cov=agent_framework_anthropic --cov-report=term-missing:skip-covered tests" + +[build-system] +requires = ["flit-core >= 3.11,<4.0"] +build-backend = "flit_core.buildapi" diff --git a/python/packages/anthropic/tests/conftest.py b/python/packages/anthropic/tests/conftest.py new file mode 100644 index 0000000000..a2313f4d39 --- /dev/null +++ b/python/packages/anthropic/tests/conftest.py @@ -0,0 +1,56 @@ +# Copyright (c) Microsoft. All rights reserved. +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +from pytest import fixture + + +@fixture +def exclude_list(request: Any) -> list[str]: + """Fixture that returns a list of environment variables to exclude.""" + return request.param if hasattr(request, "param") else [] + + +@fixture +def override_env_param_dict(request: Any) -> dict[str, str]: + """Fixture that returns a dict of environment variables to override.""" + return request.param if hasattr(request, "param") else {} + + +@fixture +def anthropic_unit_test_env(monkeypatch, exclude_list, override_env_param_dict): # type: ignore + """Fixture to set environment variables for AnthropicSettings.""" + if exclude_list is None: + exclude_list = [] + + if override_env_param_dict is None: + override_env_param_dict = {} + + env_vars = { + "ANTHROPIC_API_KEY": "test-api-key-12345", + "ANTHROPIC_CHAT_MODEL_ID": "claude-3-5-sonnet-20241022", + } + + env_vars.update(override_env_param_dict) # type: ignore + + for key, value in env_vars.items(): + if key in exclude_list: + monkeypatch.delenv(key, raising=False) # type: ignore + continue + monkeypatch.setenv(key, value) # type: ignore + + return env_vars + + +@fixture +def mock_anthropic_client() -> MagicMock: + """Fixture that provides a mock AsyncAnthropic client.""" + mock_client = MagicMock() + mock_client.base_url = "https://api.anthropic.com" + + # Mock beta.messages property + mock_client.beta = MagicMock() + mock_client.beta.messages = MagicMock() + mock_client.beta.messages.create = AsyncMock() + + return mock_client diff --git a/python/packages/anthropic/tests/test_anthropic_client.py b/python/packages/anthropic/tests/test_anthropic_client.py new file mode 100644 index 0000000000..deff519594 --- /dev/null +++ b/python/packages/anthropic/tests/test_anthropic_client.py @@ -0,0 +1,777 @@ +# Copyright (c) Microsoft. All rights reserved. +import os +from typing import Annotated +from unittest.mock import MagicMock, patch + +import pytest +from agent_framework import ( + ChatClientProtocol, + ChatMessage, + ChatOptions, + ChatResponseUpdate, + FinishReason, + FunctionCallContent, + FunctionResultContent, + HostedCodeInterpreterTool, + HostedMCPTool, + HostedWebSearchTool, + Role, + TextContent, + TextReasoningContent, + ai_function, +) +from agent_framework.exceptions import ServiceInitializationError +from anthropic.types.beta import ( + BetaMessage, + BetaTextBlock, + BetaToolUseBlock, + BetaUsage, +) +from pydantic import Field, ValidationError + +from agent_framework_anthropic import AnthropicClient +from agent_framework_anthropic._chat_client import AnthropicSettings + +skip_if_anthropic_integration_tests_disabled = pytest.mark.skipif( + os.getenv("RUN_INTEGRATION_TESTS", "false").lower() != "true" + or os.getenv("ANTHROPIC_API_KEY", "") in ("", "test-api-key-12345"), + reason="No real ANTHROPIC_API_KEY provided; skipping integration tests." + if os.getenv("RUN_INTEGRATION_TESTS", "false").lower() == "true" + else "Integration tests are disabled.", +) + + +def create_test_anthropic_client( + mock_anthropic_client: MagicMock, + model_id: str | None = None, + anthropic_settings: AnthropicSettings | None = None, +) -> AnthropicClient: + """Helper function to create AnthropicClient instances for testing, bypassing normal validation.""" + if anthropic_settings is None: + anthropic_settings = AnthropicSettings(api_key="test-api-key-12345", chat_model_id="claude-3-5-sonnet-20241022") + + # Create client instance directly + client = object.__new__(AnthropicClient) + + # Set attributes directly + client.anthropic_client = mock_anthropic_client + client.model_id = model_id or anthropic_settings.chat_model_id + client._last_call_id_name = None + client.additional_properties = {} + client.middleware = None + + return client + + +# Settings Tests + + +def test_anthropic_settings_init(anthropic_unit_test_env: dict[str, str]) -> None: + """Test AnthropicSettings initialization.""" + settings = AnthropicSettings() + + assert settings.api_key is not None + assert settings.api_key.get_secret_value() == anthropic_unit_test_env["ANTHROPIC_API_KEY"] + assert settings.chat_model_id == anthropic_unit_test_env["ANTHROPIC_CHAT_MODEL_ID"] + + +def test_anthropic_settings_init_with_explicit_values() -> None: + """Test AnthropicSettings initialization with explicit values.""" + settings = AnthropicSettings( + api_key="custom-api-key", + chat_model_id="claude-3-opus-20240229", + ) + + assert settings.api_key is not None + assert settings.api_key.get_secret_value() == "custom-api-key" + assert settings.chat_model_id == "claude-3-opus-20240229" + + +@pytest.mark.parametrize("exclude_list", [["ANTHROPIC_API_KEY"]], indirect=True) +def test_anthropic_settings_missing_api_key(anthropic_unit_test_env: dict[str, str]) -> None: + """Test AnthropicSettings when API key is missing.""" + settings = AnthropicSettings() + assert settings.api_key is None + assert settings.chat_model_id == anthropic_unit_test_env["ANTHROPIC_CHAT_MODEL_ID"] + + +# Client Initialization Tests + + +def test_anthropic_client_init_with_client(mock_anthropic_client: MagicMock) -> None: + """Test AnthropicClient initialization with existing anthropic_client.""" + chat_client = create_test_anthropic_client(mock_anthropic_client, model_id="claude-3-5-sonnet-20241022") + + assert chat_client.anthropic_client is mock_anthropic_client + assert chat_client.model_id == "claude-3-5-sonnet-20241022" + assert isinstance(chat_client, ChatClientProtocol) + + +def test_anthropic_client_init_auto_create_client(anthropic_unit_test_env: dict[str, str]) -> None: + """Test AnthropicClient initialization with auto-created anthropic_client.""" + client = AnthropicClient( + api_key=anthropic_unit_test_env["ANTHROPIC_API_KEY"], + model_id=anthropic_unit_test_env["ANTHROPIC_CHAT_MODEL_ID"], + ) + + assert client.anthropic_client is not None + assert client.model_id == anthropic_unit_test_env["ANTHROPIC_CHAT_MODEL_ID"] + + +def test_anthropic_client_init_missing_api_key() -> None: + """Test AnthropicClient initialization when API key is missing.""" + with patch("agent_framework_anthropic._chat_client.AnthropicSettings") as mock_settings: + mock_settings.return_value.api_key = None + mock_settings.return_value.chat_model_id = "claude-3-5-sonnet-20241022" + + with pytest.raises(ServiceInitializationError, match="Anthropic API key is required"): + AnthropicClient() + + +def test_anthropic_client_init_validation_error() -> None: + """Test that ValidationError in AnthropicSettings is properly handled.""" + with patch("agent_framework_anthropic._chat_client.AnthropicSettings") as mock_settings: + mock_settings.side_effect = ValidationError.from_exception_data("test", []) + + with pytest.raises(ServiceInitializationError, match="Failed to create Anthropic settings"): + AnthropicClient() + + +def test_anthropic_client_service_url(mock_anthropic_client: MagicMock) -> None: + """Test service_url method.""" + chat_client = create_test_anthropic_client(mock_anthropic_client) + assert chat_client.service_url() == "https://api.anthropic.com" + + +# Message Conversion Tests + + +def test_convert_message_to_anthropic_format_text(mock_anthropic_client: MagicMock) -> None: + """Test converting text message to Anthropic format.""" + chat_client = create_test_anthropic_client(mock_anthropic_client) + message = ChatMessage(role=Role.USER, text="Hello, world!") + + result = chat_client._convert_message_to_anthropic_format(message) + + assert result["role"] == "user" + assert len(result["content"]) == 1 + assert result["content"][0]["type"] == "text" + assert result["content"][0]["text"] == "Hello, world!" + + +def test_convert_message_to_anthropic_format_function_call(mock_anthropic_client: MagicMock) -> None: + """Test converting function call message to Anthropic format.""" + chat_client = create_test_anthropic_client(mock_anthropic_client) + message = ChatMessage( + role=Role.ASSISTANT, + contents=[ + FunctionCallContent( + call_id="call_123", + name="get_weather", + arguments={"location": "San Francisco"}, + ) + ], + ) + + result = chat_client._convert_message_to_anthropic_format(message) + + assert result["role"] == "assistant" + assert len(result["content"]) == 1 + assert result["content"][0]["type"] == "tool_use" + assert result["content"][0]["id"] == "call_123" + assert result["content"][0]["name"] == "get_weather" + assert result["content"][0]["input"] == {"location": "San Francisco"} + + +def test_convert_message_to_anthropic_format_function_result(mock_anthropic_client: MagicMock) -> None: + """Test converting function result message to Anthropic format.""" + chat_client = create_test_anthropic_client(mock_anthropic_client) + message = ChatMessage( + role=Role.TOOL, + contents=[ + FunctionResultContent( + call_id="call_123", + name="get_weather", + result="Sunny, 72°F", + ) + ], + ) + + result = chat_client._convert_message_to_anthropic_format(message) + + assert result["role"] == "user" + assert len(result["content"]) == 1 + assert result["content"][0]["type"] == "tool_result" + assert result["content"][0]["tool_use_id"] == "call_123" + # The degree symbol might be escaped differently depending on JSON encoder + assert "Sunny" in result["content"][0]["content"] + assert "72" in result["content"][0]["content"] + assert result["content"][0]["is_error"] is False + + +def test_convert_message_to_anthropic_format_text_reasoning(mock_anthropic_client: MagicMock) -> None: + """Test converting text reasoning message to Anthropic format.""" + chat_client = create_test_anthropic_client(mock_anthropic_client) + message = ChatMessage( + role=Role.ASSISTANT, + contents=[TextReasoningContent(text="Let me think about this...")], + ) + + result = chat_client._convert_message_to_anthropic_format(message) + + assert result["role"] == "assistant" + assert len(result["content"]) == 1 + assert result["content"][0]["type"] == "thinking" + assert result["content"][0]["thinking"] == "Let me think about this..." + + +def test_convert_messages_to_anthropic_format_with_system(mock_anthropic_client: MagicMock) -> None: + """Test converting messages list with system message.""" + chat_client = create_test_anthropic_client(mock_anthropic_client) + messages = [ + ChatMessage(role=Role.SYSTEM, text="You are a helpful assistant."), + ChatMessage(role=Role.USER, text="Hello!"), + ] + + result = chat_client._convert_messages_to_anthropic_format(messages) + + # System message should be skipped + assert len(result) == 1 + assert result[0]["role"] == "user" + assert result[0]["content"][0]["text"] == "Hello!" + + +def test_convert_messages_to_anthropic_format_without_system(mock_anthropic_client: MagicMock) -> None: + """Test converting messages list without system message.""" + chat_client = create_test_anthropic_client(mock_anthropic_client) + messages = [ + ChatMessage(role=Role.USER, text="Hello!"), + ChatMessage(role=Role.ASSISTANT, text="Hi there!"), + ] + + result = chat_client._convert_messages_to_anthropic_format(messages) + + assert len(result) == 2 + assert result[0]["role"] == "user" + assert result[1]["role"] == "assistant" + + +# Tool Conversion Tests + + +def test_convert_tools_to_anthropic_format_ai_function(mock_anthropic_client: MagicMock) -> None: + """Test converting AIFunction to Anthropic format.""" + chat_client = create_test_anthropic_client(mock_anthropic_client) + + @ai_function + def get_weather(location: Annotated[str, Field(description="Location to get weather for")]) -> str: + """Get weather for a location.""" + return f"Weather for {location}" + + tools = [get_weather] + + result = chat_client._convert_tools_to_anthropic_format(tools) + + assert result is not None + assert "tools" in result + assert len(result["tools"]) == 1 + assert result["tools"][0]["type"] == "custom" + assert result["tools"][0]["name"] == "get_weather" + assert "Get weather for a location" in result["tools"][0]["description"] + + +def test_convert_tools_to_anthropic_format_web_search(mock_anthropic_client: MagicMock) -> None: + """Test converting HostedWebSearchTool to Anthropic format.""" + chat_client = create_test_anthropic_client(mock_anthropic_client) + tools = [HostedWebSearchTool()] + + result = chat_client._convert_tools_to_anthropic_format(tools) + + assert result is not None + assert "tools" in result + assert len(result["tools"]) == 1 + assert result["tools"][0]["type"] == "web_search_20250305" + assert result["tools"][0]["name"] == "web_search" + + +def test_convert_tools_to_anthropic_format_code_interpreter(mock_anthropic_client: MagicMock) -> None: + """Test converting HostedCodeInterpreterTool to Anthropic format.""" + chat_client = create_test_anthropic_client(mock_anthropic_client) + tools = [HostedCodeInterpreterTool()] + + result = chat_client._convert_tools_to_anthropic_format(tools) + + assert result is not None + assert "tools" in result + assert len(result["tools"]) == 1 + assert result["tools"][0]["type"] == "code_execution_20250825" + assert result["tools"][0]["name"] == "code_interpreter" + + +def test_convert_tools_to_anthropic_format_mcp_tool(mock_anthropic_client: MagicMock) -> None: + """Test converting HostedMCPTool to Anthropic format.""" + chat_client = create_test_anthropic_client(mock_anthropic_client) + tools = [HostedMCPTool(name="test-mcp", url="https://example.com/mcp")] + + result = chat_client._convert_tools_to_anthropic_format(tools) + + assert result is not None + assert "mcp_servers" in result + assert len(result["mcp_servers"]) == 1 + assert result["mcp_servers"][0]["type"] == "url" + assert result["mcp_servers"][0]["name"] == "test-mcp" + assert result["mcp_servers"][0]["url"] == "https://example.com/mcp" + + +def test_convert_tools_to_anthropic_format_mcp_with_auth(mock_anthropic_client: MagicMock) -> None: + """Test converting HostedMCPTool with authorization headers.""" + chat_client = create_test_anthropic_client(mock_anthropic_client) + tools = [ + HostedMCPTool( + name="test-mcp", + url="https://example.com/mcp", + headers={"authorization": "Bearer token123"}, + ) + ] + + result = chat_client._convert_tools_to_anthropic_format(tools) + + assert result is not None + assert "mcp_servers" in result + # The authorization header is converted to authorization_token + assert "authorization_token" in result["mcp_servers"][0] + assert result["mcp_servers"][0]["authorization_token"] == "Bearer token123" + + +def test_convert_tools_to_anthropic_format_dict_tool(mock_anthropic_client: MagicMock) -> None: + """Test converting dict tool to Anthropic format.""" + chat_client = create_test_anthropic_client(mock_anthropic_client) + tools = [{"type": "custom", "name": "custom_tool", "description": "A custom tool"}] + + result = chat_client._convert_tools_to_anthropic_format(tools) + + assert result is not None + assert "tools" in result + assert len(result["tools"]) == 1 + assert result["tools"][0]["name"] == "custom_tool" + + +def test_convert_tools_to_anthropic_format_none(mock_anthropic_client: MagicMock) -> None: + """Test converting None tools.""" + chat_client = create_test_anthropic_client(mock_anthropic_client) + + result = chat_client._convert_tools_to_anthropic_format(None) + + assert result is None + + +# Run Options Tests + + +async def test_create_run_options_basic(mock_anthropic_client: MagicMock) -> None: + """Test _create_run_options with basic ChatOptions.""" + chat_client = create_test_anthropic_client(mock_anthropic_client) + + messages = [ChatMessage(role=Role.USER, text="Hello")] + chat_options = ChatOptions(max_tokens=100, temperature=0.7) + + run_options = chat_client._create_run_options(messages, chat_options) + + assert run_options["model"] == chat_client.model_id + assert run_options["max_tokens"] == 100 + assert run_options["temperature"] == 0.7 + assert "messages" in run_options + + +async def test_create_run_options_with_system_message(mock_anthropic_client: MagicMock) -> None: + """Test _create_run_options with system message.""" + chat_client = create_test_anthropic_client(mock_anthropic_client) + + messages = [ + ChatMessage(role=Role.SYSTEM, text="You are helpful."), + ChatMessage(role=Role.USER, text="Hello"), + ] + chat_options = ChatOptions() + + run_options = chat_client._create_run_options(messages, chat_options) + + assert run_options["system"] == "You are helpful." + assert len(run_options["messages"]) == 1 # System message not in messages list + + +async def test_create_run_options_with_tool_choice_auto(mock_anthropic_client: MagicMock) -> None: + """Test _create_run_options with auto tool choice.""" + chat_client = create_test_anthropic_client(mock_anthropic_client) + + messages = [ChatMessage(role=Role.USER, text="Hello")] + chat_options = ChatOptions(tool_choice="auto") + + run_options = chat_client._create_run_options(messages, chat_options) + + assert run_options["tool_choice"]["type"] == "auto" + + +async def test_create_run_options_with_tool_choice_required(mock_anthropic_client: MagicMock) -> None: + """Test _create_run_options with required tool choice.""" + chat_client = create_test_anthropic_client(mock_anthropic_client) + + messages = [ChatMessage(role=Role.USER, text="Hello")] + # For required with specific function, need to pass as dict + chat_options = ChatOptions(tool_choice={"mode": "required", "required_function_name": "get_weather"}) + + run_options = chat_client._create_run_options(messages, chat_options) + + assert run_options["tool_choice"]["type"] == "tool" + assert run_options["tool_choice"]["name"] == "get_weather" + + +async def test_create_run_options_with_tool_choice_none(mock_anthropic_client: MagicMock) -> None: + """Test _create_run_options with none tool choice.""" + chat_client = create_test_anthropic_client(mock_anthropic_client) + + messages = [ChatMessage(role=Role.USER, text="Hello")] + chat_options = ChatOptions(tool_choice="none") + + run_options = chat_client._create_run_options(messages, chat_options) + + assert run_options["tool_choice"]["type"] == "none" + + +async def test_create_run_options_with_tools(mock_anthropic_client: MagicMock) -> None: + """Test _create_run_options with tools.""" + chat_client = create_test_anthropic_client(mock_anthropic_client) + + @ai_function + def get_weather(location: str) -> str: + """Get weather for a location.""" + return f"Weather for {location}" + + messages = [ChatMessage(role=Role.USER, text="Hello")] + chat_options = ChatOptions(tools=[get_weather]) + + run_options = chat_client._create_run_options(messages, chat_options) + + assert "tools" in run_options + assert len(run_options["tools"]) == 1 + + +async def test_create_run_options_with_stop_sequences(mock_anthropic_client: MagicMock) -> None: + """Test _create_run_options with stop sequences.""" + chat_client = create_test_anthropic_client(mock_anthropic_client) + + messages = [ChatMessage(role=Role.USER, text="Hello")] + chat_options = ChatOptions(stop=["STOP", "END"]) + + run_options = chat_client._create_run_options(messages, chat_options) + + assert run_options["stop_sequences"] == ["STOP", "END"] + + +async def test_create_run_options_with_top_p(mock_anthropic_client: MagicMock) -> None: + """Test _create_run_options with top_p.""" + chat_client = create_test_anthropic_client(mock_anthropic_client) + + messages = [ChatMessage(role=Role.USER, text="Hello")] + chat_options = ChatOptions(top_p=0.9) + + run_options = chat_client._create_run_options(messages, chat_options) + + assert run_options["top_p"] == 0.9 + + +# Response Processing Tests + + +def test_process_message_basic(mock_anthropic_client: MagicMock) -> None: + """Test _process_message with basic text response.""" + chat_client = create_test_anthropic_client(mock_anthropic_client) + + mock_message = MagicMock(spec=BetaMessage) + mock_message.id = "msg_123" + mock_message.model = "claude-3-5-sonnet-20241022" + mock_message.content = [BetaTextBlock(type="text", text="Hello there!")] + mock_message.usage = BetaUsage(input_tokens=10, output_tokens=5) + mock_message.stop_reason = "end_turn" + + response = chat_client._process_message(mock_message) + + assert response.response_id == "msg_123" + assert response.model_id == "claude-3-5-sonnet-20241022" + assert len(response.messages) == 1 + assert response.messages[0].role == Role.ASSISTANT + assert len(response.messages[0].contents) == 1 + assert isinstance(response.messages[0].contents[0], TextContent) + assert response.messages[0].contents[0].text == "Hello there!" + assert response.finish_reason == FinishReason.STOP + assert response.usage_details is not None + assert response.usage_details.input_token_count == 10 + assert response.usage_details.output_token_count == 5 + + +def test_process_message_with_tool_use(mock_anthropic_client: MagicMock) -> None: + """Test _process_message with tool use.""" + chat_client = create_test_anthropic_client(mock_anthropic_client) + + mock_message = MagicMock(spec=BetaMessage) + mock_message.id = "msg_123" + mock_message.model = "claude-3-5-sonnet-20241022" + mock_message.content = [ + BetaToolUseBlock( + type="tool_use", + id="call_123", + name="get_weather", + input={"location": "San Francisco"}, + ) + ] + mock_message.usage = BetaUsage(input_tokens=10, output_tokens=5) + mock_message.stop_reason = "tool_use" + + response = chat_client._process_message(mock_message) + + assert len(response.messages[0].contents) == 1 + assert isinstance(response.messages[0].contents[0], FunctionCallContent) + assert response.messages[0].contents[0].call_id == "call_123" + assert response.messages[0].contents[0].name == "get_weather" + assert response.finish_reason == FinishReason.TOOL_CALLS + + +def test_parse_message_usage_basic(mock_anthropic_client: MagicMock) -> None: + """Test _parse_message_usage with basic usage.""" + chat_client = create_test_anthropic_client(mock_anthropic_client) + + usage = BetaUsage(input_tokens=10, output_tokens=5) + result = chat_client._parse_message_usage(usage) + + assert result is not None + assert result.input_token_count == 10 + assert result.output_token_count == 5 + + +def test_parse_message_usage_none(mock_anthropic_client: MagicMock) -> None: + """Test _parse_message_usage with None usage.""" + chat_client = create_test_anthropic_client(mock_anthropic_client) + + result = chat_client._parse_message_usage(None) + + assert result is None + + +def test_parse_message_contents_text(mock_anthropic_client: MagicMock) -> None: + """Test _parse_message_contents with text content.""" + chat_client = create_test_anthropic_client(mock_anthropic_client) + + content = [BetaTextBlock(type="text", text="Hello!")] + result = chat_client._parse_message_contents(content) + + assert len(result) == 1 + assert isinstance(result[0], TextContent) + assert result[0].text == "Hello!" + + +def test_parse_message_contents_tool_use(mock_anthropic_client: MagicMock) -> None: + """Test _parse_message_contents with tool use.""" + chat_client = create_test_anthropic_client(mock_anthropic_client) + + content = [ + BetaToolUseBlock( + type="tool_use", + id="call_123", + name="get_weather", + input={"location": "SF"}, + ) + ] + result = chat_client._parse_message_contents(content) + + assert len(result) == 1 + assert isinstance(result[0], FunctionCallContent) + assert result[0].call_id == "call_123" + assert result[0].name == "get_weather" + + +# Stream Processing Tests + + +def test_process_stream_event_simple(mock_anthropic_client: MagicMock) -> None: + """Test _process_stream_event with simple mock event.""" + chat_client = create_test_anthropic_client(mock_anthropic_client) + + # Test with a basic mock event - the actual implementation will handle real events + mock_event = MagicMock() + mock_event.type = "message_stop" + + result = chat_client._process_stream_event(mock_event) + + # message_stop events return None + assert result is None + + +async def test_inner_get_response(mock_anthropic_client: MagicMock) -> None: + """Test _inner_get_response method.""" + chat_client = create_test_anthropic_client(mock_anthropic_client) + + # Create a mock message response + mock_message = MagicMock(spec=BetaMessage) + mock_message.id = "msg_test" + mock_message.model = "claude-3-5-sonnet-20241022" + mock_message.content = [BetaTextBlock(type="text", text="Hello!")] + mock_message.usage = BetaUsage(input_tokens=5, output_tokens=3) + mock_message.stop_reason = "end_turn" + + mock_anthropic_client.beta.messages.create.return_value = mock_message + + messages = [ChatMessage(role=Role.USER, text="Hi")] + chat_options = ChatOptions(max_tokens=10) + + response = await chat_client._inner_get_response( # type: ignore[attr-defined] + messages=messages, chat_options=chat_options + ) + + assert response is not None + assert response.response_id == "msg_test" + assert len(response.messages) == 1 + + +async def test_inner_get_streaming_response(mock_anthropic_client: MagicMock) -> None: + """Test _inner_get_streaming_response method.""" + chat_client = create_test_anthropic_client(mock_anthropic_client) + + # Create mock streaming response + async def mock_stream(): + mock_event = MagicMock() + mock_event.type = "message_stop" + yield mock_event + + mock_anthropic_client.beta.messages.create.return_value = mock_stream() + + messages = [ChatMessage(role=Role.USER, text="Hi")] + chat_options = ChatOptions(max_tokens=10) + + chunks: list[ChatResponseUpdate] = [] + async for chunk in chat_client._inner_get_streaming_response( # type: ignore[attr-defined] + messages=messages, chat_options=chat_options + ): + if chunk: + chunks.append(chunk) + + # We should get at least some response (even if empty due to message_stop) + assert isinstance(chunks, list) + + +# Integration Tests + + +@ai_function +def get_weather( + location: Annotated[str, Field(description="The location to get the weather for.")], +) -> str: + """Get the weather for a location.""" + return f"The weather in {location} is sunny and 72°F" + + +@pytest.mark.flaky +@skip_if_anthropic_integration_tests_disabled +async def test_anthropic_client_integration_basic_chat() -> None: + """Integration test for basic chat completion.""" + client = AnthropicClient() + + messages = [ChatMessage(role=Role.USER, text="Say 'Hello, World!' and nothing else.")] + + response = await client.get_response(messages=messages, chat_options=ChatOptions(max_tokens=50)) + + assert response is not None + assert len(response.messages) > 0 + assert response.messages[0].role == Role.ASSISTANT + assert len(response.messages[0].text) > 0 + assert response.usage_details is not None + + +@pytest.mark.flaky +@skip_if_anthropic_integration_tests_disabled +async def test_anthropic_client_integration_streaming_chat() -> None: + """Integration test for streaming chat completion.""" + client = AnthropicClient() + + messages = [ChatMessage(role=Role.USER, text="Count from 1 to 5.")] + + chunks = [] + async for chunk in client.get_streaming_response(messages=messages, chat_options=ChatOptions(max_tokens=50)): + chunks.append(chunk) + + assert len(chunks) > 0 + assert any(chunk.contents for chunk in chunks) + + +@pytest.mark.flaky +@skip_if_anthropic_integration_tests_disabled +async def test_anthropic_client_integration_function_calling() -> None: + """Integration test for function calling.""" + client = AnthropicClient() + + messages = [ChatMessage(role=Role.USER, text="What's the weather in San Francisco?")] + tools = [get_weather] + + response = await client.get_response( + messages=messages, + chat_options=ChatOptions(tools=tools, max_tokens=100), + ) + + assert response is not None + # Should contain function call + has_function_call = any( + isinstance(content, FunctionCallContent) for msg in response.messages for content in msg.contents + ) + assert has_function_call + + +@pytest.mark.flaky +@skip_if_anthropic_integration_tests_disabled +async def test_anthropic_client_integration_with_system_message() -> None: + """Integration test with system message.""" + client = AnthropicClient() + + messages = [ + ChatMessage(role=Role.SYSTEM, text="You are a pirate. Always respond like a pirate."), + ChatMessage(role=Role.USER, text="Hello!"), + ] + + response = await client.get_response(messages=messages, chat_options=ChatOptions(max_tokens=50)) + + assert response is not None + assert len(response.messages) > 0 + + +@pytest.mark.flaky +@skip_if_anthropic_integration_tests_disabled +async def test_anthropic_client_integration_temperature_control() -> None: + """Integration test with temperature control.""" + client = AnthropicClient() + + messages = [ChatMessage(role=Role.USER, text="Say hello.")] + + response = await client.get_response( + messages=messages, + chat_options=ChatOptions(max_tokens=20, temperature=0.0), + ) + + assert response is not None + assert response.messages[0].text is not None + + +@pytest.mark.flaky +@skip_if_anthropic_integration_tests_disabled +async def test_anthropic_client_integration_ordering() -> None: + """Integration test with ordering.""" + client = AnthropicClient() + + messages = [ + ChatMessage(role=Role.USER, text="Say hello."), + ChatMessage(role=Role.USER, text="Then say goodbye."), + ChatMessage(role=Role.ASSISTANT, text="Thank you for chatting!"), + ChatMessage(role=Role.ASSISTANT, text="Let me know if I can help."), + ChatMessage(role=Role.USER, text="Just testing things."), + ] + + response = await client.get_response(messages=messages) + + assert response is not None + assert response.messages[0].text is not None diff --git a/python/packages/azure-ai/agent_framework_azure_ai/_chat_client.py b/python/packages/azure-ai/agent_framework_azure_ai/_chat_client.py index 3218d10a97..0f35158da7 100644 --- a/python/packages/azure-ai/agent_framework_azure_ai/_chat_client.py +++ b/python/packages/azure-ai/agent_framework_azure_ai/_chat_client.py @@ -735,10 +735,10 @@ class AzureAIAgentClient(BaseChatClient): chat_tool_mode = chat_options.tool_choice if chat_tool_mode is None or chat_tool_mode == ToolMode.NONE or chat_tool_mode == "none": chat_options.tools = None - chat_options.tool_choice = ToolMode.NONE.mode + chat_options.tool_choice = ToolMode.NONE return - chat_options.tool_choice = chat_tool_mode.mode if isinstance(chat_tool_mode, ToolMode) else chat_tool_mode + chat_options.tool_choice = chat_tool_mode async def _create_run_options( self, diff --git a/python/packages/core/agent_framework/_clients.py b/python/packages/core/agent_framework/_clients.py index 0b36b486c8..e4b2d53cc6 100644 --- a/python/packages/core/agent_framework/_clients.py +++ b/python/packages/core/agent_framework/_clients.py @@ -690,12 +690,12 @@ class BaseChatClient(SerializationMixin, ABC): chat_tool_mode = chat_options.tool_choice if chat_tool_mode is None or chat_tool_mode == ToolMode.NONE or chat_tool_mode == "none": chat_options.tools = None - chat_options.tool_choice = ToolMode.NONE.mode + chat_options.tool_choice = ToolMode.NONE return if not chat_options.tools: - chat_options.tool_choice = ToolMode.NONE.mode + chat_options.tool_choice = ToolMode.NONE else: - chat_options.tool_choice = chat_tool_mode.mode if isinstance(chat_tool_mode, ToolMode) else chat_tool_mode + chat_options.tool_choice = chat_tool_mode def service_url(self) -> str: """Get the URL of the service. diff --git a/python/packages/core/agent_framework/_types.py b/python/packages/core/agent_framework/_types.py index f1a12f813e..9f2ad10d85 100644 --- a/python/packages/core/agent_framework/_types.py +++ b/python/packages/core/agent_framework/_types.py @@ -561,7 +561,7 @@ class BaseContent(SerializationMixin): def __init__( self, *, - annotations: list[Annotations | MutableMapping[str, Any]] | None = None, + annotations: Sequence[Annotations | MutableMapping[str, Any]] | None = None, additional_properties: dict[str, Any] | None = None, raw_representation: Any | None = None, **kwargs: Any, @@ -651,7 +651,7 @@ class TextContent(BaseContent): *, additional_properties: dict[str, Any] | None = None, raw_representation: Any | None = None, - annotations: list[Annotations | MutableMapping[str, Any]] | None = None, + annotations: Sequence[Annotations | MutableMapping[str, Any]] | None = None, **kwargs: Any, ): """Initializes a TextContent instance. @@ -793,7 +793,7 @@ class TextReasoningContent(BaseContent): *, additional_properties: dict[str, Any] | None = None, raw_representation: Any | None = None, - annotations: list[Annotations | MutableMapping[str, Any]] | None = None, + annotations: Sequence[Annotations | MutableMapping[str, Any]] | None = None, **kwargs: Any, ): """Initializes a TextReasoningContent instance. @@ -936,7 +936,7 @@ class DataContent(BaseContent): self, *, uri: str, - annotations: list[Annotations | MutableMapping[str, Any]] | None = None, + annotations: Sequence[Annotations | MutableMapping[str, Any]] | None = None, additional_properties: dict[str, Any] | None = None, raw_representation: Any | None = None, **kwargs: Any, @@ -962,7 +962,7 @@ class DataContent(BaseContent): *, data: bytes, media_type: str, - annotations: list[Annotations | MutableMapping[str, Any]] | None = None, + annotations: Sequence[Annotations | MutableMapping[str, Any]] | None = None, additional_properties: dict[str, Any] | None = None, raw_representation: Any | None = None, **kwargs: Any, @@ -989,7 +989,7 @@ class DataContent(BaseContent): uri: str | None = None, data: bytes | None = None, media_type: str | None = None, - annotations: list[Annotations | MutableMapping[str, Any]] | None = None, + annotations: Sequence[Annotations | MutableMapping[str, Any]] | None = None, additional_properties: dict[str, Any] | None = None, raw_representation: Any | None = None, **kwargs: Any, @@ -1093,7 +1093,7 @@ class UriContent(BaseContent): uri: str, media_type: str, *, - annotations: list[Annotations | MutableMapping[str, Any]] | None = None, + annotations: Sequence[Annotations | MutableMapping[str, Any]] | None = None, additional_properties: dict[str, Any] | None = None, raw_representation: Any | None = None, **kwargs: Any, @@ -1187,7 +1187,7 @@ class ErrorContent(BaseContent): message: str | None = None, error_code: str | None = None, details: str | None = None, - annotations: list[Annotations | MutableMapping[str, Any]] | None = None, + annotations: Sequence[Annotations | MutableMapping[str, Any]] | None = None, additional_properties: dict[str, Any] | None = None, raw_representation: Any | None = None, **kwargs: Any, @@ -1271,7 +1271,7 @@ class FunctionCallContent(BaseContent): name: str, arguments: str | dict[str, Any | None] | None = None, exception: Exception | None = None, - annotations: list[Annotations | MutableMapping[str, Any]] | None = None, + annotations: Sequence[Annotations | MutableMapping[str, Any]] | None = None, additional_properties: dict[str, Any] | None = None, raw_representation: Any | None = None, **kwargs: Any, @@ -1380,7 +1380,7 @@ class FunctionResultContent(BaseContent): call_id: str, result: Any | None = None, exception: Exception | None = None, - annotations: list[Annotations | MutableMapping[str, Any]] | None = None, + annotations: Sequence[Annotations | MutableMapping[str, Any]] | None = None, additional_properties: dict[str, Any] | None = None, raw_representation: Any | None = None, **kwargs: Any, @@ -1438,7 +1438,7 @@ class UsageContent(BaseContent): self, details: UsageDetails | MutableMapping[str, Any], *, - annotations: list[Annotations | MutableMapping[str, Any]] | None = None, + annotations: Sequence[Annotations | MutableMapping[str, Any]] | None = None, additional_properties: dict[str, Any] | None = None, raw_representation: Any | None = None, **kwargs: Any, @@ -1556,7 +1556,7 @@ class BaseUserInputRequest(BaseContent): self, *, id: str, - annotations: list[Annotations | MutableMapping[str, Any]] | None = None, + annotations: Sequence[Annotations | MutableMapping[str, Any]] | None = None, additional_properties: dict[str, Any] | None = None, raw_representation: Any | None = None, **kwargs: Any, @@ -1610,7 +1610,7 @@ class FunctionApprovalResponseContent(BaseContent): *, id: str, function_call: FunctionCallContent | MutableMapping[str, Any], - annotations: list[Annotations | MutableMapping[str, Any]] | None = None, + annotations: Sequence[Annotations | MutableMapping[str, Any]] | None = None, additional_properties: dict[str, Any] | None = None, raw_representation: Any | None = None, **kwargs: Any, @@ -1674,7 +1674,7 @@ class FunctionApprovalRequestContent(BaseContent): *, id: str, function_call: FunctionCallContent | MutableMapping[str, Any], - annotations: list[Annotations | MutableMapping[str, Any]] | None = None, + annotations: Sequence[Annotations | MutableMapping[str, Any]] | None = None, additional_properties: dict[str, Any] | None = None, raw_representation: Any | None = None, **kwargs: Any, @@ -3146,7 +3146,7 @@ class ChatOptions(SerializationMixin): @classmethod def _validate_tool_mode( cls, tool_choice: ToolMode | Literal["auto", "required", "none"] | Mapping[str, Any] | None - ) -> ToolMode | str | None: + ) -> ToolMode | None: """Validates the tool_choice field to ensure it is a valid ToolMode.""" if not tool_choice: return None diff --git a/python/packages/core/agent_framework/anthropic/__init__.py b/python/packages/core/agent_framework/anthropic/__init__.py new file mode 100644 index 0000000000..bff9c278e5 --- /dev/null +++ b/python/packages/core/agent_framework/anthropic/__init__.py @@ -0,0 +1,23 @@ +# Copyright (c) Microsoft. All rights reserved. + +import importlib +from typing import Any + +PACKAGE_NAME = "agent_framework_anthropic" +PACKAGE_EXTRA = "anthropic" +_IMPORTS = ["__version__", "AnthropicClient"] + + +def __getattr__(name: str) -> Any: + if name in _IMPORTS: + try: + return getattr(importlib.import_module(PACKAGE_NAME), name) + except ModuleNotFoundError as exc: + raise ModuleNotFoundError( + f"The '{PACKAGE_EXTRA}' extra is not installed, please do `pip install agent-framework-{PACKAGE_EXTRA}`" + ) from exc + raise AttributeError(f"Module {PACKAGE_NAME} has no attribute {name}.") + + +def __dir__() -> list[str]: + return _IMPORTS diff --git a/python/packages/core/agent_framework/anthropic/__init__.pyi b/python/packages/core/agent_framework/anthropic/__init__.pyi new file mode 100644 index 0000000000..dead0816f9 --- /dev/null +++ b/python/packages/core/agent_framework/anthropic/__init__.pyi @@ -0,0 +1,5 @@ +# Copyright (c) Microsoft. All rights reserved. + +from agent_framework_anthropic import AnthropicClient, __version__ + +__all__ = ["AnthropicClient", "__version__"] diff --git a/python/packages/core/agent_framework/openai/_assistants_client.py b/python/packages/core/agent_framework/openai/_assistants_client.py index f5a57683db..239efb76e3 100644 --- a/python/packages/core/agent_framework/openai/_assistants_client.py +++ b/python/packages/core/agent_framework/openai/_assistants_client.py @@ -408,7 +408,7 @@ class OpenAIAssistantsClient(OpenAIConfigMixin, BaseChatClient): run_options["tools"] = tool_definitions if chat_options.tool_choice == "none" or chat_options.tool_choice == "auto": - run_options["tool_choice"] = chat_options.tool_choice + run_options["tool_choice"] = chat_options.tool_choice.mode elif ( isinstance(chat_options.tool_choice, ToolMode) and chat_options.tool_choice == "required" diff --git a/python/packages/core/agent_framework/openai/_chat_client.py b/python/packages/core/agent_framework/openai/_chat_client.py index fdb6a9717f..70a37894d4 100644 --- a/python/packages/core/agent_framework/openai/_chat_client.py +++ b/python/packages/core/agent_framework/openai/_chat_client.py @@ -191,6 +191,8 @@ class OpenAIBaseChatClient(OpenAIBase, BaseChatClient): for key, value in additional_properties.items(): if value is not None: options_dict[key] = value + if (tool_choice := options_dict.get("tool_choice")) and len(tool_choice.keys()) == 1: + options_dict["tool_choice"] = tool_choice["mode"] return options_dict def _create_chat_response(self, response: ChatCompletion, chat_options: ChatOptions) -> "ChatResponse": diff --git a/python/packages/core/agent_framework/openai/_responses_client.py b/python/packages/core/agent_framework/openai/_responses_client.py index ff3871f13e..0d422f33bc 100644 --- a/python/packages/core/agent_framework/openai/_responses_client.py +++ b/python/packages/core/agent_framework/openai/_responses_client.py @@ -345,6 +345,8 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient): options_dict[key] = value if "store" not in options_dict: options_dict["store"] = False + if (tool_choice := options_dict.get("tool_choice")) and len(tool_choice.keys()) == 1: + options_dict["tool_choice"] = tool_choice["mode"] return options_dict def _prepare_chat_messages_for_request(self, chat_messages: Sequence[ChatMessage]) -> list[dict[str, Any]]: diff --git a/python/packages/core/pyproject.toml b/python/packages/core/pyproject.toml index f5b2c23e7d..71855049a4 100644 --- a/python/packages/core/pyproject.toml +++ b/python/packages/core/pyproject.toml @@ -45,6 +45,8 @@ all = [ "agent-framework-mem0", "agent-framework-redis", "agent-framework-devui", + "agent-framework-purview", + "agent-framework-anthropic", ] [tool.uv] diff --git a/python/pyproject.toml b/python/pyproject.toml index c0522cd5bd..6280b3d37b 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -24,13 +24,14 @@ classifiers = [ dependencies = [ "agent-framework-core", "agent-framework-a2a", + "agent-framework-anthropic", "agent-framework-azure-ai", "agent-framework-copilotstudio", + "agent-framework-devui", "agent-framework-lab", "agent-framework-mem0", - "agent-framework-redis", - "agent-framework-devui", "agent-framework-purview", + "agent-framework-redis", ] [dependency-groups] @@ -94,6 +95,7 @@ agent-framework-mem0 = { workspace = true } agent-framework-redis = { workspace = true } agent-framework-devui = { workspace = true } agent-framework-purview = { workspace = true } +agent-framework-anthropic = { workspace = true } [tool.ruff] line-length = 120 diff --git a/python/samples/README.md b/python/samples/README.md index 04f7067e1f..f8602b3385 100644 --- a/python/samples/README.md +++ b/python/samples/README.md @@ -14,7 +14,8 @@ This directory contains samples demonstrating the capabilities of Microsoft Agen | File | Description | |------|-------------| -| [`getting_started/agents/anthropic/anthropic_with_openai_chat_client.py`](./getting_started/agents/anthropic/anthropic_with_openai_chat_client.py) | Anthropic with OpenAI Chat Client Example | +| [`getting_started/agents/anthropic/anthropic_basic.py`](./getting_started/agents/anthropic/anthropic_basic.py) | Agent with Anthropic Client | +| [`getting_started/agents/anthropic/anthropic_advanced.py`](./getting_started/agents/anthropic/anthropic_advanced.py) | Advanced sample with `thinking` and hosted tools. | ### Azure AI diff --git a/python/samples/getting_started/agents/anthropic/README.md b/python/samples/getting_started/agents/anthropic/README.md index be8944ae23..c0d15c3e02 100644 --- a/python/samples/getting_started/agents/anthropic/README.md +++ b/python/samples/getting_started/agents/anthropic/README.md @@ -6,12 +6,12 @@ This folder contains examples demonstrating how to use Anthropic's Claude models | File | Description | |------|-------------| -| [`anthropic_with_openai_chat_client.py`](anthropic_with_openai_chat_client.py) | Demonstrates how to configure OpenAI Chat Client to use Anthropic's Claude models. Shows both streaming and non-streaming responses with tool calling capabilities. | +| [`anthropic_basic.py`](anthropic_basic.py) | Demonstrates how to setup a simple agent using the AnthropicClient, with both streaming and non-streaming responses. | +| [`anthropic_advanced.py`](anthropic_advanced.py) | Shows advanced usage of the AnthropicClient, including hosted tools and `thinking`. | ## Environment Variables Set the following environment variables before running the examples: - `ANTHROPIC_API_KEY`: Your Anthropic API key (get one from [Anthropic Console](https://console.anthropic.com/)) -- `ANTHROPIC_MODEL`: The Claude model to use (e.g., `claude-3-5-sonnet-20241022`, `claude-3-haiku-20240307`) - +- `ANTHROPIC_MODEL`: The Claude model to use (e.g., `claude-haiku-4-5`, `claude-sonnet-4-5-20250929`) diff --git a/python/samples/getting_started/agents/anthropic/anthropic_advanced.py b/python/samples/getting_started/agents/anthropic/anthropic_advanced.py new file mode 100644 index 0000000000..a7f4ae2656 --- /dev/null +++ b/python/samples/getting_started/agents/anthropic/anthropic_advanced.py @@ -0,0 +1,58 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio + +from agent_framework import HostedMCPTool, HostedWebSearchTool, TextReasoningContent, UsageContent +from agent_framework.anthropic import AnthropicClient + +""" +Anthropic Chat Agent Example + +This sample demonstrates using Anthropic with: +- Setting up an Anthropic-based agent with hosted tools. +- Using the `thinking` feature. +- Displaying both thinking and usage information during streaming responses. +""" + + +async def streaming_example() -> None: + """Example of streaming response (get results as they are generated).""" + agent = AnthropicClient().create_agent( + name="DocsAgent", + instructions="You are a helpful agent for both Microsoft docs questions and general questions.", + tools=[ + HostedMCPTool( + name="Microsoft Learn MCP", + url="https://learn.microsoft.com/api/mcp", + ), + HostedWebSearchTool(), + ], + # anthropic needs a value for the max_tokens parameter + # we set it to 1024, but you can override like this: + max_tokens=20000, + additional_chat_options={"thinking": {"type": "enabled", "budget_tokens": 10000}}, + ) + + query = "Can you compare Python decorators with C# attributes?" + print(f"User: {query}") + print("Agent: ", end="", flush=True) + async for chunk in agent.run_stream(query): + for content in chunk.contents: + if isinstance(content, TextReasoningContent): + print(f"\033[32m{content.text}\033[0m", end="", flush=True) + if isinstance(content, UsageContent): + print(f"\n\033[34m[Usage so far: {content.details}]\033[0m\n", end="", flush=True) + if chunk.text: + print(chunk.text, end="", flush=True) + + print("\n") + + +async def main() -> None: + print("=== Anthropic Example ===") + + await streaming_example() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/getting_started/agents/anthropic/anthropic_with_openai_chat_client.py b/python/samples/getting_started/agents/anthropic/anthropic_basic.py similarity index 67% rename from python/samples/getting_started/agents/anthropic/anthropic_with_openai_chat_client.py rename to python/samples/getting_started/agents/anthropic/anthropic_basic.py index 5f59b6c8b4..9011483136 100644 --- a/python/samples/getting_started/agents/anthropic/anthropic_with_openai_chat_client.py +++ b/python/samples/getting_started/agents/anthropic/anthropic_basic.py @@ -1,17 +1,15 @@ # Copyright (c) Microsoft. All rights reserved. import asyncio -import os from random import randint from typing import Annotated -from agent_framework.openai import OpenAIChatClient +from agent_framework.anthropic import AnthropicClient """ -Anthropic with OpenAI Chat Client Example +Anthropic Chat Agent Example -This sample demonstrates using Anthropic models through OpenAI Chat Client by -configuring the base URL to point to Anthropic's API for cross-provider compatibility. +This sample demonstrates using Anthropic with an agent and a single custom tool. """ @@ -27,10 +25,7 @@ async def non_streaming_example() -> None: """Example of non-streaming response (get the complete result at once).""" print("=== Non-streaming Response Example ===") - agent = OpenAIChatClient( - api_key=os.getenv("ANTHROPIC_API_KEY"), - base_url="https://api.anthropic.com/v1/", - model_id=os.getenv("ANTHROPIC_MODEL"), + agent = AnthropicClient( ).create_agent( name="WeatherAgent", instructions="You are a helpful weather agent.", @@ -47,17 +42,14 @@ async def streaming_example() -> None: """Example of streaming response (get results as they are generated).""" print("=== Streaming Response Example ===") - agent = OpenAIChatClient( - api_key=os.getenv("ANTHROPIC_API_KEY"), - base_url="https://api.anthropic.com/v1/", - model_id=os.getenv("ANTHROPIC_MODEL"), + agent = AnthropicClient( ).create_agent( name="WeatherAgent", instructions="You are a helpful weather agent.", tools=get_weather, ) - query = "What's the weather like in Portland?" + query = "What's the weather like in Portland and in Paris?" print(f"User: {query}") print("Agent: ", end="", flush=True) async for chunk in agent.run_stream(query): @@ -67,10 +59,10 @@ async def streaming_example() -> None: async def main() -> None: - print("=== Anthropic with OpenAI Chat Client Agent Example ===") + print("=== Anthropic Example ===") - await non_streaming_example() await streaming_example() + await non_streaming_example() if __name__ == "__main__": diff --git a/python/uv.lock b/python/uv.lock index 0e11d3bd11..c19ba2cc89 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -31,6 +31,7 @@ supported-markers = [ members = [ "agent-framework", "agent-framework-a2a", + "agent-framework-anthropic", "agent-framework-azure-ai", "agent-framework-copilotstudio", "agent-framework-core", @@ -76,6 +77,7 @@ version = "1.0.0b251028" source = { virtual = "." } dependencies = [ { name = "agent-framework-a2a", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-anthropic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "agent-framework-azure-ai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "agent-framework-copilotstudio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -117,6 +119,7 @@ docs = [ [package.metadata] requires-dist = [ { name = "agent-framework-a2a", editable = "packages/a2a" }, + { name = "agent-framework-anthropic", editable = "packages/anthropic" }, { name = "agent-framework-azure-ai", editable = "packages/azure-ai" }, { name = "agent-framework-copilotstudio", editable = "packages/copilotstudio" }, { name = "agent-framework-core", editable = "packages/core" }, @@ -170,6 +173,21 @@ requires-dist = [ { name = "agent-framework-core", editable = "packages/core" }, ] +[[package]] +name = "agent-framework-anthropic" +version = "1.0.0b251028" +source = { editable = "packages/anthropic" } +dependencies = [ + { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "anthropic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] + +[package.metadata] +requires-dist = [ + { name = "agent-framework-core", editable = "packages/core" }, + { name = "anthropic", specifier = ">=0.70.0,<1" }, +] + [[package]] name = "agent-framework-azure-ai" version = "1.0.0b251028" @@ -222,20 +240,24 @@ dependencies = [ [package.optional-dependencies] all = [ { name = "agent-framework-a2a", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-anthropic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "agent-framework-azure-ai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "agent-framework-copilotstudio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "agent-framework-devui", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "agent-framework-mem0", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-purview", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "agent-framework-redis", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] [package.metadata] requires-dist = [ { name = "agent-framework-a2a", marker = "extra == 'all'", editable = "packages/a2a" }, + { name = "agent-framework-anthropic", marker = "extra == 'all'", editable = "packages/anthropic" }, { name = "agent-framework-azure-ai", marker = "extra == 'all'", editable = "packages/azure-ai" }, { name = "agent-framework-copilotstudio", marker = "extra == 'all'", editable = "packages/copilotstudio" }, { name = "agent-framework-devui", marker = "extra == 'all'", editable = "packages/devui" }, { name = "agent-framework-mem0", marker = "extra == 'all'", editable = "packages/mem0" }, + { name = "agent-framework-purview", marker = "extra == 'all'", editable = "packages/purview" }, { name = "agent-framework-redis", marker = "extra == 'all'", editable = "packages/redis" }, { name = "azure-identity", specifier = ">=1,<2" }, { name = "mcp", extras = ["ws"], specifier = ">=1.13" }, @@ -648,6 +670,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, ] +[[package]] +name = "anthropic" +version = "0.72.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "distro", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "docstring-parser", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "jiter", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "sniffio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/07/61f3ca8e69c5dcdaec31b36b79a53ea21c5b4ca5e93c7df58c71f43bf8d8/anthropic-0.72.0.tar.gz", hash = "sha256:8971fe76dcffc644f74ac3883069beb1527641115ae0d6eb8fa21c1ce4082f7a", size = 493721, upload-time = "2025-10-28T19:13:01.755Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/b7/160d4fb30080395b4143f1d1a4f6c646ba9105561108d2a434b606c03579/anthropic-0.72.0-py3-none-any.whl", hash = "sha256:0e9f5a7582f038cab8efbb4c959e49ef654a56bfc7ba2da51b5a7b8a84de2e4d", size = 357464, upload-time = "2025-10-28T19:13:00.215Z" }, +] + [[package]] name = "anyio" version = "4.11.0" @@ -1926,11 +1967,11 @@ wheels = [ [[package]] name = "fsspec" -version = "2025.9.0" +version = "2025.10.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/de/e0/bab50af11c2d75c9c4a2a26a5254573c0bd97cea152254401510950486fa/fsspec-2025.9.0.tar.gz", hash = "sha256:19fd429483d25d28b65ec68f9f4adc16c17ea2c7c7bf54ec61360d478fb19c19", size = 304847, upload-time = "2025-09-02T19:10:49.215Z" } +sdist = { url = "https://files.pythonhosted.org/packages/24/7f/2747c0d332b9acfa75dc84447a066fdf812b5a6b8d30472b74d309bfe8cb/fsspec-2025.10.0.tar.gz", hash = "sha256:b6789427626f068f9a83ca4e8a3cc050850b6c0f71f99ddb4f542b8266a26a59", size = 309285, upload-time = "2025-10-30T14:58:44.036Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/47/71/70db47e4f6ce3e5c37a607355f80da8860a33226be640226ac52cb05ef2e/fsspec-2025.9.0-py3-none-any.whl", hash = "sha256:530dc2a2af60a414a832059574df4a6e10cce927f6f4a78209390fe38955cfb7", size = 199289, upload-time = "2025-09-02T19:10:47.708Z" }, + { url = "https://files.pythonhosted.org/packages/eb/02/a6b21098b1d5d6249b7c5ab69dde30108a71e4e819d4a9778f1de1d5b70d/fsspec-2025.10.0-py3-none-any.whl", hash = "sha256:7c7712353ae7d875407f97715f0e1ffcc21e33d5b24556cb1e090ae9409ec61d", size = 200966, upload-time = "2025-10-30T14:58:42.53Z" }, ] [[package]] @@ -1951,16 +1992,16 @@ wheels = [ [[package]] name = "google-auth" -version = "2.42.0" +version = "2.42.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cachetools", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "pyasn1-modules", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "rsa", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/11/75/28881e9d7de9b3d61939bc9624bd8fa594eb787a00567aba87173c790f09/google_auth-2.42.0.tar.gz", hash = "sha256:9bbbeef3442586effb124d1ca032cfb8fb7acd8754ab79b55facd2b8f3ab2802", size = 295400, upload-time = "2025-10-28T17:38:08.599Z" } +sdist = { url = "https://files.pythonhosted.org/packages/25/6b/22a77135757c3a7854c9f008ffed6bf4e8851616d77faf13147e9ab5aae6/google_auth-2.42.1.tar.gz", hash = "sha256:30178b7a21aa50bffbdc1ffcb34ff770a2f65c712170ecd5446c4bef4dc2b94e", size = 295541, upload-time = "2025-10-30T16:42:19.381Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/87/24/ec82aee6ba1a076288818fe5cc5125f4d93fffdc68bb7b381c68286c8aaa/google_auth-2.42.0-py2.py3-none-any.whl", hash = "sha256:f8f944bcb9723339b0ef58a73840f3c61bc91b69bf7368464906120b55804473", size = 222550, upload-time = "2025-10-28T17:38:05.496Z" }, + { url = "https://files.pythonhosted.org/packages/92/05/adeb6c495aec4f9d93f9e2fc29eeef6e14d452bba11d15bdb874ce1d5b10/google_auth-2.42.1-py2.py3-none-any.whl", hash = "sha256:eb73d71c91fc95dbd221a2eb87477c278a355e7367a35c0d84e6b0e5f9b4ad11", size = 222550, upload-time = "2025-10-30T16:42:17.878Z" }, ] [[package]] @@ -3934,28 +3975,28 @@ wheels = [ [[package]] name = "polars" -version = "1.34.0" +version = "1.35.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "polars-runtime-32", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a1/3e/35fcf5bf51404371bb172b289a5065778dc97adca4416e199c294125eb05/polars-1.34.0.tar.gz", hash = "sha256:5de5f871027db4b11bcf39215a2d6b13b4a80baf8a55c5862d4ebedfd5cd4013", size = 684309, upload-time = "2025-10-02T18:31:04.396Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9b/5b/3caad788d93304026cbf0ab4c37f8402058b64a2f153b9c62f8b30f5d2ee/polars-1.35.1.tar.gz", hash = "sha256:06548e6d554580151d6ca7452d74bceeec4640b5b9261836889b8e68cfd7a62e", size = 694881, upload-time = "2025-10-30T12:12:52.294Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6b/80/1791ac226bb989bef30fe8fde752b2021b6ec5dfd6e880262596aedf4c05/polars-1.34.0-py3-none-any.whl", hash = "sha256:40d2f357b4d9e447ad28bd2c9923e4318791a7c18eb68f31f1fbf11180f41391", size = 772686, upload-time = "2025-10-02T18:29:59.492Z" }, + { url = "https://files.pythonhosted.org/packages/9f/4c/21a227b722534404241c2a76beceb7463469d50c775a227fc5c209eb8adc/polars-1.35.1-py3-none-any.whl", hash = "sha256:c29a933f28aa330d96a633adbd79aa5e6a6247a802a720eead9933f4613bdbf4", size = 783598, upload-time = "2025-10-30T12:11:54.668Z" }, ] [[package]] name = "polars-runtime-32" -version = "1.34.0" +version = "1.35.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/02/10/1189afb14cc47ed215ccf7fbd00ed21c48edfd89e51c16f8628a33ae4b1b/polars_runtime_32-1.34.0.tar.gz", hash = "sha256:ebe6f865128a0d833f53a3f6828360761ad86d1698bceb22bef9fd999500dc1c", size = 2634491, upload-time = "2025-10-02T18:31:05.502Z" } +sdist = { url = "https://files.pythonhosted.org/packages/df/3e/19c252e8eb4096300c1a36ec3e50a27e5fa9a1ccaf32d3927793c16abaee/polars_runtime_32-1.35.1.tar.gz", hash = "sha256:f6b4ec9cd58b31c87af1b8c110c9c986d82345f1d50d7f7595b5d447a19dc365", size = 2696218, upload-time = "2025-10-30T12:12:53.479Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/97/35/bc4f1a9dcef61845e8e4e5d2318470b002b93a3564026f0643f562761ecb/polars_runtime_32-1.34.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:2878f9951e91121afe60c25433ef270b9a221e6ebf3de5f6642346b38cab3f03", size = 39655423, upload-time = "2025-10-02T18:30:02.846Z" }, - { url = "https://files.pythonhosted.org/packages/a6/bb/d655a103e75b7c81c47a3c2d276be0200c0c15cfb6fd47f17932ddcf7519/polars_runtime_32-1.34.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:fbc329c7d34a924228cc5dcdbbd4696d94411a3a5b15ad8bb868634c204e1951", size = 35986049, upload-time = "2025-10-02T18:30:05.848Z" }, - { url = "https://files.pythonhosted.org/packages/9e/ce/11ca850b7862cb43605e5d86cdf655614376e0a059871cf8305af5406554/polars_runtime_32-1.34.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93fa51d88a2d12ea996a5747aad5647d22a86cce73c80f208e61f487b10bc448", size = 40261269, upload-time = "2025-10-02T18:30:08.48Z" }, - { url = "https://files.pythonhosted.org/packages/d8/25/77d12018c35489e19f7650b40679714a834effafc25d61e8dcee7c4fafce/polars_runtime_32-1.34.0-cp39-abi3-manylinux_2_24_aarch64.whl", hash = "sha256:79e4d696392c6d8d51f4347f0b167c52eef303c9d87093c0c68e8651198735b7", size = 37049077, upload-time = "2025-10-02T18:30:11.162Z" }, - { url = "https://files.pythonhosted.org/packages/e2/75/c30049d45ea1365151f86f650ed5354124ff3209f0abe588664c8eb13a31/polars_runtime_32-1.34.0-cp39-abi3-win_amd64.whl", hash = "sha256:2501d6b29d9001ea5ea2fd9b598787e10ddf45d8c4a87c2bead75159e8a15711", size = 40105782, upload-time = "2025-10-02T18:30:14.597Z" }, - { url = "https://files.pythonhosted.org/packages/a3/31/84efa27aa3478c8670bac1a720c8b1aee5c58c9c657c980e5e5c47fde883/polars_runtime_32-1.34.0-cp39-abi3-win_arm64.whl", hash = "sha256:f9ed1765378dfe0bcd1ac5ec570dd9eab27ea728bbc980cc9a76eebc55586559", size = 35873216, upload-time = "2025-10-02T18:30:17.439Z" }, + { url = "https://files.pythonhosted.org/packages/08/2c/da339459805a26105e9d9c2f07e43ca5b8baeee55acd5457e6881487a79a/polars_runtime_32-1.35.1-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:6f051a42f6ae2f26e3bc2cf1f170f2120602976e2a3ffb6cfba742eecc7cc620", size = 40525100, upload-time = "2025-10-30T12:11:58.098Z" }, + { url = "https://files.pythonhosted.org/packages/27/70/a0733568b3533481924d2ce68b279ab3d7334e5fa6ed259f671f650b7c5e/polars_runtime_32-1.35.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:c2232f9cf05ba59efc72d940b86c033d41fd2d70bf2742e8115ed7112a766aa9", size = 36701908, upload-time = "2025-10-30T12:12:02.166Z" }, + { url = "https://files.pythonhosted.org/packages/46/54/6c09137bef9da72fd891ba58c2962cc7c6c5cad4649c0e668d6b344a9d7b/polars_runtime_32-1.35.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42f9837348557fd674477ea40a6ac8a7e839674f6dd0a199df24be91b026024c", size = 41317692, upload-time = "2025-10-30T12:12:04.928Z" }, + { url = "https://files.pythonhosted.org/packages/22/55/81c5b266a947c339edd7fbaa9e1d9614012d02418453f48b76cc177d3dd9/polars_runtime_32-1.35.1-cp39-abi3-manylinux_2_24_aarch64.whl", hash = "sha256:c873aeb36fed182d5ebc35ca17c7eb193fe83ae2ea551ee8523ec34776731390", size = 37853058, upload-time = "2025-10-30T12:12:08.342Z" }, + { url = "https://files.pythonhosted.org/packages/6c/58/be8b034d559eac515f52408fd6537be9bea095bc0388946a4e38910d3d50/polars_runtime_32-1.35.1-cp39-abi3-win_amd64.whl", hash = "sha256:35cde9453ca7032933f0e58e9ed4388f5a1e415dd0db2dd1e442c81d815e630c", size = 41289554, upload-time = "2025-10-30T12:12:11.104Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7f/e0111b9e2a1169ea82cde3ded9c92683e93c26dfccd72aee727996a1ac5b/polars_runtime_32-1.35.1-cp39-abi3-win_arm64.whl", hash = "sha256:fd77757a6c9eb9865c4bfb7b07e22225207c6b7da382bd0b9bd47732f637105d", size = 36958878, upload-time = "2025-10-30T12:12:15.206Z" }, ] [[package]] @@ -5588,14 +5629,14 @@ wheels = [ [[package]] name = "sse-starlette" -version = "3.0.2" +version = "3.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/42/6f/22ed6e33f8a9e76ca0a412405f31abb844b779d52c5f96660766edcd737c/sse_starlette-3.0.2.tar.gz", hash = "sha256:ccd60b5765ebb3584d0de2d7a6e4f745672581de4f5005ab31c3a25d10b52b3a", size = 20985, upload-time = "2025-07-27T09:07:44.565Z" } +sdist = { url = "https://files.pythonhosted.org/packages/db/3c/fa6517610dc641262b77cc7bf994ecd17465812c1b0585fe33e11be758ab/sse_starlette-3.0.3.tar.gz", hash = "sha256:88cfb08747e16200ea990c8ca876b03910a23b547ab3bd764c0d8eb81019b971", size = 21943, upload-time = "2025-10-30T18:44:20.117Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/10/c78f463b4ef22eef8491f218f692be838282cd65480f6e423d7730dfd1fb/sse_starlette-3.0.2-py3-none-any.whl", hash = "sha256:16b7cbfddbcd4eaca11f7b586f3b8a080f1afe952c15813455b162edea619e5a", size = 11297, upload-time = "2025-07-27T09:07:43.268Z" }, + { url = "https://files.pythonhosted.org/packages/23/a0/984525d19ca5c8a6c33911a0c164b11490dd0f90ff7fd689f704f84e9a11/sse_starlette-3.0.3-py3-none-any.whl", hash = "sha256:af5bf5a6f3933df1d9c7f8539633dc8444ca6a97ab2e2a7cd3b6e431ac03a431", size = 11765, upload-time = "2025-10-30T18:44:18.834Z" }, ] [[package]] From b0ee7028a674c2106118e0b2d5304187ceec4284 Mon Sep 17 00:00:00 2001 From: Tao Chen Date: Mon, 3 Nov 2025 12:36:33 -0800 Subject: [PATCH 08/15] .NET: Add workflow as an agent with observability sample (#1612) * Add workflow as an agent with observability sample * Address comment * Fix formatting * enable sensitive data * enable sensitive data for sub agents * adjust aggregator handlers --- dotnet/agent-framework-dotnet.slnx | 1 + .../AgentOpenTelemetry/Program.cs | 6 +- .../Agents/WorkflowAsAnAgent/Program.cs | 6 +- .../WorkflowAsAnAgent/WorkflowFactory.cs | 41 +++-- .../Concurrent/Concurrent/Program.cs | 8 +- .../WorkflowAsAnAgent/Program.cs | 140 ++++++++++++++++++ .../WorkflowAsAnAgentObservability.csproj | 27 ++++ .../WorkflowAsAnAgent/WorkflowHelper.cs | 98 ++++++++++++ 8 files changed, 297 insertions(+), 30 deletions(-) create mode 100644 dotnet/samples/GettingStarted/Workflows/Observability/WorkflowAsAnAgent/Program.cs create mode 100644 dotnet/samples/GettingStarted/Workflows/Observability/WorkflowAsAnAgent/WorkflowAsAnAgentObservability.csproj create mode 100644 dotnet/samples/GettingStarted/Workflows/Observability/WorkflowAsAnAgent/WorkflowHelper.cs diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index d342c06be2..896a31c9fc 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -123,6 +123,7 @@ + diff --git a/dotnet/samples/GettingStarted/AgentOpenTelemetry/Program.cs b/dotnet/samples/GettingStarted/AgentOpenTelemetry/Program.cs index 5edfcf53d9..dd5c6f9c7d 100644 --- a/dotnet/samples/GettingStarted/AgentOpenTelemetry/Program.cs +++ b/dotnet/samples/GettingStarted/AgentOpenTelemetry/Program.cs @@ -125,7 +125,7 @@ var agent = new ChatClientAgent(instrumentedChatClient, instructions: "You are a helpful assistant that provides concise and informative responses.", tools: [AIFunctionFactory.Create(GetWeatherAsync)]) .AsBuilder() - .UseOpenTelemetry(SourceName) // enable telemetry at the agent level + .UseOpenTelemetry(SourceName, configure: (cfg) => cfg.EnableSensitiveData = true) // enable telemetry at the agent level .Build(); var thread = agent.GetNewThread(); @@ -134,6 +134,8 @@ appLogger.LogInformation("Agent created successfully with ID: {AgentId}", agent. // Create a parent span for the entire agent session using var sessionActivity = activitySource.StartActivity("Agent Session"); +Console.WriteLine($"Trace ID: {sessionActivity?.TraceId} "); + var sessionId = Guid.NewGuid().ToString("N"); sessionActivity? .SetTag("agent.name", "OpenTelemetryDemoAgent") @@ -147,7 +149,7 @@ using (appLogger.BeginScope(new Dictionary { ["SessionId"] = ses while (true) { - Console.Write("You: "); + Console.Write("You (or 'exit' to quit): "); var userInput = Console.ReadLine(); if (string.IsNullOrWhiteSpace(userInput) || userInput.Equals("exit", StringComparison.OrdinalIgnoreCase)) diff --git a/dotnet/samples/GettingStarted/Workflows/Agents/WorkflowAsAnAgent/Program.cs b/dotnet/samples/GettingStarted/Workflows/Agents/WorkflowAsAnAgent/Program.cs index 75f0beb2da..6aa65d56b5 100644 --- a/dotnet/samples/GettingStarted/Workflows/Agents/WorkflowAsAnAgent/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/Agents/WorkflowAsAnAgent/Program.cs @@ -6,7 +6,7 @@ using Microsoft.Agents.AI; using Microsoft.Agents.AI.Workflows; using Microsoft.Extensions.AI; -namespace WorkflowAsAnAgentsSample; +namespace WorkflowAsAnAgentSample; /// /// This sample introduces the concepts workflows as agents, where a workflow can be @@ -61,9 +61,9 @@ public static class Program Dictionary> buffer = []; await foreach (AgentRunResponseUpdate update in agent.RunStreamingAsync(input, thread)) { - if (update.MessageId is null) + if (update.MessageId is null || string.IsNullOrEmpty(update.Text)) { - // skip updates that don't have a message ID + // skip updates that don't have a message ID or text continue; } Console.Clear(); diff --git a/dotnet/samples/GettingStarted/Workflows/Agents/WorkflowAsAnAgent/WorkflowFactory.cs b/dotnet/samples/GettingStarted/Workflows/Agents/WorkflowAsAnAgent/WorkflowFactory.cs index 7d8768c226..736c51d555 100644 --- a/dotnet/samples/GettingStarted/Workflows/Agents/WorkflowAsAnAgent/WorkflowFactory.cs +++ b/dotnet/samples/GettingStarted/Workflows/Agents/WorkflowAsAnAgent/WorkflowFactory.cs @@ -4,7 +4,7 @@ using Microsoft.Agents.AI; using Microsoft.Agents.AI.Workflows; using Microsoft.Extensions.AI; -namespace WorkflowAsAnAgentsSample; +namespace WorkflowAsAnAgentSample; internal static class WorkflowFactory { @@ -41,44 +41,43 @@ internal static class WorkflowFactory /// /// Executor that starts the concurrent processing by sending messages to the agents. /// - private sealed class ConcurrentStartExecutor() : - Executor>("ConcurrentStartExecutor") + private sealed class ConcurrentStartExecutor() : Executor("ConcurrentStartExecutor") { - /// - /// Starts the concurrent processing by sending messages to the agents. - /// - /// The user message to process - /// Workflow context for accessing workflow services and adding events - /// The to monitor for cancellation requests. - /// The default is . - public override async ValueTask HandleAsync(List message, IWorkflowContext context, CancellationToken cancellationToken = default) + protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) { - // Broadcast the message to all connected agents. Receiving agents will queue - // the message but will not start processing until they receive a turn token. - await context.SendMessageAsync(message, cancellationToken: cancellationToken); - // Broadcast the turn token to kick off the agents. - await context.SendMessageAsync(new TurnToken(emitEvents: true), cancellationToken: cancellationToken); + return routeBuilder + .AddHandler>(this.RouteMessages) + .AddHandler(this.RouteTurnTokenAsync); + } + + private ValueTask RouteMessages(List messages, IWorkflowContext context, CancellationToken cancellationToken) + { + return context.SendMessageAsync(messages, cancellationToken: cancellationToken); + } + + private ValueTask RouteTurnTokenAsync(TurnToken token, IWorkflowContext context, CancellationToken cancellationToken) + { + return context.SendMessageAsync(token, cancellationToken: cancellationToken); } } /// /// Executor that aggregates the results from the concurrent agents. /// - private sealed class ConcurrentAggregationExecutor() : - Executor("ConcurrentAggregationExecutor") + private sealed class ConcurrentAggregationExecutor() : Executor>("ConcurrentAggregationExecutor") { private readonly List _messages = []; /// /// Handles incoming messages from the agents and aggregates their responses. /// - /// The message from the agent + /// The messages from the agent /// Workflow context for accessing workflow services and adding events /// The to monitor for cancellation requests. /// The default is . - public override async ValueTask HandleAsync(ChatMessage message, IWorkflowContext context, CancellationToken cancellationToken = default) + public override async ValueTask HandleAsync(List message, IWorkflowContext context, CancellationToken cancellationToken = default) { - this._messages.Add(message); + this._messages.AddRange(message); if (this._messages.Count == 2) { diff --git a/dotnet/samples/GettingStarted/Workflows/Concurrent/Concurrent/Program.cs b/dotnet/samples/GettingStarted/Workflows/Concurrent/Concurrent/Program.cs index dc91338987..e1f71e2311 100644 --- a/dotnet/samples/GettingStarted/Workflows/Concurrent/Concurrent/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/Concurrent/Concurrent/Program.cs @@ -97,21 +97,21 @@ internal sealed class ConcurrentStartExecutor() : /// Executor that aggregates the results from the concurrent agents. /// internal sealed class ConcurrentAggregationExecutor() : - Executor("ConcurrentAggregationExecutor") + Executor>("ConcurrentAggregationExecutor") { private readonly List _messages = []; /// /// Handles incoming messages from the agents and aggregates their responses. /// - /// The message from the agent + /// The messages from the agent /// Workflow context for accessing workflow services and adding events /// The to monitor for cancellation requests. /// The default is . /// A task representing the asynchronous operation - public override async ValueTask HandleAsync(ChatMessage message, IWorkflowContext context, CancellationToken cancellationToken = default) + public override async ValueTask HandleAsync(List message, IWorkflowContext context, CancellationToken cancellationToken = default) { - this._messages.Add(message); + this._messages.AddRange(message); if (this._messages.Count == 2) { diff --git a/dotnet/samples/GettingStarted/Workflows/Observability/WorkflowAsAnAgent/Program.cs b/dotnet/samples/GettingStarted/Workflows/Observability/WorkflowAsAnAgent/Program.cs new file mode 100644 index 0000000000..17d7d03b3f --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/Observability/WorkflowAsAnAgent/Program.cs @@ -0,0 +1,140 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics; +using Azure.AI.OpenAI; +using Azure.Identity; +using Azure.Monitor.OpenTelemetry.Exporter; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.AI; +using OpenTelemetry; +using OpenTelemetry.Resources; +using OpenTelemetry.Trace; + +namespace WorkflowAsAnAgentObservabilitySample; + +/// +/// This sample shows how to enable OpenTelemetry observability for workflows when +/// using them as s. +/// +/// In this example, we create a workflow that uses two language agents to process +/// input concurrently, one that responds in French and another that responds in English. +/// +/// You will interact with the workflow in an interactive loop, sending messages and receiving +/// streaming responses from the workflow as if it were an agent who responds in both languages. +/// +/// OpenTelemetry observability is enabled at multiple levels: +/// 1. At the chat client level, capturing telemetry for interactions with the Azure OpenAI service. +/// 2. At the agent level, capturing telemetry for agent operations. +/// 3. At the workflow level, capturing telemetry for workflow execution. +/// +/// Traces will be sent to an Aspire dashboard via an OTLP endpoint, and optionally to +/// Azure Monitor if an Application Insights connection string is provided. +/// +/// Learn how to set up an Aspire dashboard here: +/// https://learn.microsoft.com/en-us/dotnet/aspire/fundamentals/dashboard/standalone?tabs=bash +/// +/// +/// Pre-requisites: +/// - Foundational samples should be completed first. +/// - This sample uses concurrent processing. +/// - An Azure OpenAI endpoint and deployment name. +/// - An Application Insights resource for telemetry (optional). +/// +public static class Program +{ + private const string SourceName = "Workflow.ApplicationInsightsSample"; + private static readonly ActivitySource s_activitySource = new(SourceName); + + private static async Task Main() + { + // Set up observability + var applicationInsightsConnectionString = Environment.GetEnvironmentVariable("APPLICATIONINSIGHTS_CONNECTION_STRING"); + var otlpEndpoint = Environment.GetEnvironmentVariable("OTLP_ENDPOINT") ?? "http://localhost:4317"; + + var resourceBuilder = ResourceBuilder + .CreateDefault() + .AddService("WorkflowSample"); + + var traceProviderBuilder = Sdk.CreateTracerProviderBuilder() + .SetResourceBuilder(resourceBuilder) + .AddSource("Microsoft.Agents.AI.*") // Agent Framework telemetry + .AddSource("Microsoft.Extensions.AI.*") // Extensions AI telemetry + .AddSource(SourceName); + + traceProviderBuilder.AddOtlpExporter(options => options.Endpoint = new Uri(otlpEndpoint)); + if (!string.IsNullOrWhiteSpace(applicationInsightsConnectionString)) + { + traceProviderBuilder.AddAzureMonitorTraceExporter(options => options.ConnectionString = applicationInsightsConnectionString); + } + + using var traceProvider = traceProviderBuilder.Build(); + + // Set up the Azure OpenAI client + var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); + var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; + var chatClient = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()) + .GetChatClient(deploymentName) + .AsIChatClient() + .AsBuilder() + .UseOpenTelemetry(sourceName: SourceName, configure: (cfg) => cfg.EnableSensitiveData = true) // enable telemetry at the chat client level + .Build(); + + // Start a root activity for the application + using var activity = s_activitySource.StartActivity("main"); + Console.WriteLine($"Operation/Trace ID: {Activity.Current?.TraceId}"); + + // Create the workflow and turn it into an agent with OpenTelemetry instrumentation + var workflow = WorkflowHelper.GetWorkflow(chatClient, SourceName); + var agent = new OpenTelemetryAgent(workflow.AsAgent("workflow-agent", "Workflow Agent"), SourceName) + { + EnableSensitiveData = true // enable sensitive data at the agent level such as prompts and responses + }; + var thread = agent.GetNewThread(); + + // Start an interactive loop to interact with the workflow as if it were an agent + while (true) + { + Console.WriteLine(); + Console.Write("User (or 'exit' to quit): "); + string? input = Console.ReadLine(); + if (string.IsNullOrWhiteSpace(input) || input.Equals("exit", StringComparison.OrdinalIgnoreCase)) + { + break; + } + + await ProcessInputAsync(agent, thread, input); + } + + // Helper method to process user input and display streaming responses. To display + // multiple interleaved responses correctly, we buffer updates by message ID and + // re-render all messages on each update. + static async Task ProcessInputAsync(AIAgent agent, AgentThread thread, string input) + { + Dictionary> buffer = []; + await foreach (AgentRunResponseUpdate update in agent.RunStreamingAsync(input, thread)) + { + if (update.MessageId is null || string.IsNullOrEmpty(update.Text)) + { + // skip updates that don't have a message ID or text + continue; + } + Console.Clear(); + + if (!buffer.TryGetValue(update.MessageId, out List? value)) + { + value = []; + buffer[update.MessageId] = value; + } + value.Add(update); + + foreach (var (messageId, segments) in buffer) + { + string combinedText = string.Concat(segments); + Console.WriteLine($"{segments[0].AuthorName}: {combinedText}"); + Console.WriteLine(); + } + } + } + } +} diff --git a/dotnet/samples/GettingStarted/Workflows/Observability/WorkflowAsAnAgent/WorkflowAsAnAgentObservability.csproj b/dotnet/samples/GettingStarted/Workflows/Observability/WorkflowAsAnAgent/WorkflowAsAnAgentObservability.csproj new file mode 100644 index 0000000000..17c44eeb75 --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/Observability/WorkflowAsAnAgent/WorkflowAsAnAgentObservability.csproj @@ -0,0 +1,27 @@ + + + + Exe + net9.0 + + enable + enable + + + + + + + + + + + + + + + + + + + diff --git a/dotnet/samples/GettingStarted/Workflows/Observability/WorkflowAsAnAgent/WorkflowHelper.cs b/dotnet/samples/GettingStarted/Workflows/Observability/WorkflowAsAnAgent/WorkflowHelper.cs new file mode 100644 index 0000000000..816dce50d0 --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/Observability/WorkflowAsAnAgent/WorkflowHelper.cs @@ -0,0 +1,98 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.AI; + +namespace WorkflowAsAnAgentObservabilitySample; + +internal static class WorkflowHelper +{ + /// + /// Creates a workflow that uses two language agents to process input concurrently. + /// + /// The chat client to use for the agents + /// The source name for OpenTelemetry instrumentation + /// A workflow that processes input using two language agents + internal static Workflow GetWorkflow(IChatClient chatClient, string sourceName) + { + // Create executors + var startExecutor = new ConcurrentStartExecutor(); + var aggregationExecutor = new ConcurrentAggregationExecutor(); + AIAgent frenchAgent = GetLanguageAgent("French", chatClient, sourceName); + AIAgent englishAgent = GetLanguageAgent("English", chatClient, sourceName); + + // Build the workflow by adding executors and connecting them + return new WorkflowBuilder(startExecutor) + .AddFanOutEdge(startExecutor, targets: [frenchAgent, englishAgent]) + .AddFanInEdge(aggregationExecutor, sources: [frenchAgent, englishAgent]) + .WithOutputFrom(aggregationExecutor) + .Build(); + } + + /// + /// Creates a language agent for the specified target language. + /// + /// The target language for translation + /// The chat client to use for the agent + /// The source name for OpenTelemetry instrumentation + /// An AIAgent configured for the specified language + private static AIAgent GetLanguageAgent(string targetLanguage, IChatClient chatClient, string sourceName) => + new ChatClientAgent( + chatClient, + instructions: $"You're a helpful assistant who always responds in {targetLanguage}.", + name: $"{targetLanguage}Agent" + ) + .AsBuilder() + .UseOpenTelemetry(sourceName, configure: (cfg) => cfg.EnableSensitiveData = true) // enable telemetry at the agent level + .Build(); + + /// + /// Executor that starts the concurrent processing by sending messages to the agents. + /// + private sealed class ConcurrentStartExecutor() : Executor("ConcurrentStartExecutor") + { + protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) + { + return routeBuilder + .AddHandler>(this.RouteMessages) + .AddHandler(this.RouteTurnTokenAsync); + } + + private ValueTask RouteMessages(List messages, IWorkflowContext context, CancellationToken cancellationToken) + { + return context.SendMessageAsync(messages, cancellationToken: cancellationToken); + } + + private ValueTask RouteTurnTokenAsync(TurnToken token, IWorkflowContext context, CancellationToken cancellationToken) + { + return context.SendMessageAsync(token, cancellationToken: cancellationToken); + } + } + + /// + /// Executor that aggregates the results from the concurrent agents. + /// + private sealed class ConcurrentAggregationExecutor() : Executor>("ConcurrentAggregationExecutor") + { + private readonly List _messages = []; + + /// + /// Handles incoming messages from the agents and aggregates their responses. + /// + /// The message from the agent + /// Workflow context for accessing workflow services and adding events + /// The to monitor for cancellation requests. + /// The default is . + public override async ValueTask HandleAsync(List message, IWorkflowContext context, CancellationToken cancellationToken = default) + { + this._messages.AddRange(message); + + if (this._messages.Count == 2) + { + var formattedMessages = string.Join(Environment.NewLine, this._messages.Select(m => $"{m.Text}")); + await context.YieldOutputAsync(formattedMessages, cancellationToken); + } + } + } +} From 50d9b13bfc66573ac1d19e7ba25b0b4d7f8247a1 Mon Sep 17 00:00:00 2001 From: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Date: Tue, 4 Nov 2025 08:34:59 +0900 Subject: [PATCH 09/15] Python: [BREAKING] consolidate workflow run APIs (#1723) * consolidate workflow run apis * improve validation, add tests * Proper code tags for docs * Update sample output * Remove cycle validation * PR feedback * Validation * Cleanup --- .../core/agent_framework/_workflows/_agent.py | 9 +- .../agent_framework/_workflows/_handoff.py | 21 +- .../agent_framework/_workflows/_magentic.py | 36 -- .../_workflows/_orchestrator_helpers.py | 3 +- .../_workflows/_runner_context.py | 46 ++- .../agent_framework/_workflows/_validation.py | 95 ------ .../agent_framework/_workflows/_workflow.py | 318 ++++++++++++------ .../_workflows/_workflow_executor.py | 3 +- .../tests/workflow/test_agent_executor.py | 2 +- .../workflow/test_checkpoint_validation.py | 8 +- .../core/tests/workflow/test_concurrent.py | 73 +++- .../core/tests/workflow/test_group_chat.py | 70 ++++ .../core/tests/workflow/test_handoff.py | 25 -- .../core/tests/workflow/test_magentic.py | 96 +++++- .../test_request_info_and_response.py | 2 +- .../core/tests/workflow/test_sequential.py | 74 +++- .../core/tests/workflow/test_validation.py | 67 ---- .../core/tests/workflow/test_workflow.py | 201 ++++++----- .../custom_chat_message_store_thread.py | 14 +- .../_start-here/step1_executors_and_edges.py | 4 +- ...re_chat_agents_tool_calls_with_feedback.py | 16 +- .../checkpoint_with_human_in_the_loop.py | 2 +- .../checkpoint/checkpoint_with_resume.py | 4 +- .../checkpoint/sub_workflow_checkpoint.py | 2 +- .../guessing_game_with_human_input.py | 2 +- .../orchestration/magentic_checkpoint.py | 6 +- 26 files changed, 722 insertions(+), 477 deletions(-) diff --git a/python/packages/core/agent_framework/_workflows/_agent.py b/python/packages/core/agent_framework/_workflows/_agent.py index e7dba19eaa..f2a0ea9d75 100644 --- a/python/packages/core/agent_framework/_workflows/_agent.py +++ b/python/packages/core/agent_framework/_workflows/_agent.py @@ -60,10 +60,13 @@ class WorkflowAgent(BaseAgent): @classmethod def from_json(cls, raw: str) -> "WorkflowAgent.RequestInfoFunctionArgs": - data = json.loads(raw) - if not isinstance(data, dict): + try: + parsed: Any = json.loads(raw) + except json.JSONDecodeError as exc: + raise ValueError(f"RequestInfoFunctionArgs JSON payload is malformed: {exc}") from exc + if not isinstance(parsed, dict): raise ValueError("RequestInfoFunctionArgs JSON payload must decode to a mapping") - return cls.from_dict(data) + return cls.from_dict(cast(dict[str, Any], parsed)) def __init__( self, diff --git a/python/packages/core/agent_framework/_workflows/_handoff.py b/python/packages/core/agent_framework/_workflows/_handoff.py index 9d8f6c8467..c98d9e752c 100644 --- a/python/packages/core/agent_framework/_workflows/_handoff.py +++ b/python/packages/core/agent_framework/_workflows/_handoff.py @@ -336,10 +336,15 @@ class _HandoffCoordinator(BaseGroupChatOrchestrator): if await self._check_termination(): logger.info("Handoff workflow termination condition met. Ending conversation.") - await ctx.yield_output(list(conversation)) + # Clean the output conversation for display + cleaned_output = clean_conversation_for_handoff(conversation) + await ctx.yield_output(cleaned_output) return - await ctx.send_message(list(conversation), target_id=self._input_gateway_id) + # Clean conversation before sending to gateway for user input request + # This removes tool messages that shouldn't be shown to users + cleaned_for_display = clean_conversation_for_handoff(conversation) + await ctx.send_message(cleaned_for_display, target_id=self._input_gateway_id) @handler async def handle_user_input( @@ -1274,12 +1279,12 @@ class HandoffBuilder: updated_executor, tool_targets = self._prepare_agent_with_handoffs(executor, targets_map) self._executors[source_exec_id] = updated_executor handoff_tool_targets.update(tool_targets) - else: - # Default behavior: only coordinator gets handoff tools to all specialists - if isinstance(starting_executor, AgentExecutor) and specialists: - starting_executor, tool_targets = self._prepare_agent_with_handoffs(starting_executor, specialists) - self._executors[self._starting_agent_id] = starting_executor - handoff_tool_targets.update(tool_targets) # Update references after potential agent modifications + else: + # Default behavior: only coordinator gets handoff tools to all specialists + if isinstance(starting_executor, AgentExecutor) and specialists: + starting_executor, tool_targets = self._prepare_agent_with_handoffs(starting_executor, specialists) + self._executors[self._starting_agent_id] = starting_executor + handoff_tool_targets.update(tool_targets) # Update references after potential agent modifications starting_executor = self._executors[self._starting_agent_id] specialists = { exec_id: executor for exec_id, executor in self._executors.items() if exec_id != self._starting_agent_id diff --git a/python/packages/core/agent_framework/_workflows/_magentic.py b/python/packages/core/agent_framework/_workflows/_magentic.py index b9cf4258a1..9d21391ad8 100644 --- a/python/packages/core/agent_framework/_workflows/_magentic.py +++ b/python/packages/core/agent_framework/_workflows/_magentic.py @@ -2442,16 +2442,6 @@ class MagenticWorkflow: f"Missing names: {missing}; unexpected names: {unexpected}." ) - async def run_stream_from_checkpoint( - self, - checkpoint_id: str, - checkpoint_storage: CheckpointStorage | None = None, - ) -> AsyncIterable[WorkflowEvent]: - """Resume orchestration from a checkpoint and stream resulting events.""" - await self._validate_checkpoint_participants(checkpoint_id, checkpoint_storage) - async for event in self._workflow.run_stream_from_checkpoint(checkpoint_id, checkpoint_storage): - yield event - async def run_with_string(self, task_text: str) -> WorkflowRunResult: """Run the workflow with a task string and return all events. @@ -2495,32 +2485,6 @@ class MagenticWorkflow: events.append(event) return WorkflowRunResult(events) - async def run_from_checkpoint( - self, - checkpoint_id: str, - checkpoint_storage: CheckpointStorage | None = None, - ) -> WorkflowRunResult: - """Resume orchestration from a checkpoint and collect all resulting events.""" - events: list[WorkflowEvent] = [] - async for event in self.run_stream_from_checkpoint(checkpoint_id, checkpoint_storage): - events.append(event) - return WorkflowRunResult(events) - - async def send_responses_streaming(self, responses: dict[str, Any]) -> AsyncIterable[WorkflowEvent]: - """Forward responses to pending requests and stream resulting events. - - This delegates to the underlying Workflow implementation. - """ - async for event in self._workflow.send_responses_streaming(responses): - yield event - - async def send_responses(self, responses: dict[str, Any]) -> WorkflowRunResult: - """Forward responses to pending requests and return all resulting events. - - This delegates to the underlying Workflow implementation. - """ - return await self._workflow.send_responses(responses) - def __getattr__(self, name: str) -> Any: """Delegate unknown attributes to the underlying workflow.""" return getattr(self._workflow, name) diff --git a/python/packages/core/agent_framework/_workflows/_orchestrator_helpers.py b/python/packages/core/agent_framework/_workflows/_orchestrator_helpers.py index 85cde6abbb..4b17dda414 100644 --- a/python/packages/core/agent_framework/_workflows/_orchestrator_helpers.py +++ b/python/packages/core/agent_framework/_workflows/_orchestrator_helpers.py @@ -63,11 +63,12 @@ def clean_conversation_for_handoff(conversation: list[ChatMessage]) -> list[Chat # Has tool content - only keep if it also has text if msg.text and msg.text.strip(): - # Create fresh text-only message + # Create fresh text-only message while preserving additional_properties msg_copy = ChatMessage( role=msg.role, text=msg.text, author_name=msg.author_name, + additional_properties=dict(msg.additional_properties) if msg.additional_properties else None, ) cleaned.append(msg_copy) diff --git a/python/packages/core/agent_framework/_workflows/_runner_context.py b/python/packages/core/agent_framework/_workflows/_runner_context.py index 6decfc592b..00318d7021 100644 --- a/python/packages/core/agent_framework/_workflows/_runner_context.py +++ b/python/packages/core/agent_framework/_workflows/_runner_context.py @@ -171,6 +171,18 @@ class RunnerContext(Protocol): """ ... + def set_runtime_checkpoint_storage(self, storage: CheckpointStorage) -> None: + """Set runtime checkpoint storage to override build-time configuration. + + Args: + storage: The checkpoint storage to use for this run. + """ + ... + + def clear_runtime_checkpoint_storage(self) -> None: + """Clear runtime checkpoint storage override.""" + ... + # Checkpointing APIs (optional, enabled by storage) def set_workflow_id(self, workflow_id: str) -> None: """Set the workflow ID for the context.""" @@ -279,6 +291,7 @@ class InProcRunnerContext: # Checkpointing configuration/state self._checkpoint_storage = checkpoint_storage + self._runtime_checkpoint_storage: CheckpointStorage | None = None self._workflow_id: str | None = None # Streaming flag - set by workflow's run_stream() vs run() @@ -329,8 +342,28 @@ class InProcRunnerContext: # region Checkpointing + def _get_effective_checkpoint_storage(self) -> CheckpointStorage | None: + """Get the effective checkpoint storage (runtime override or build-time).""" + return self._runtime_checkpoint_storage or self._checkpoint_storage + + def set_runtime_checkpoint_storage(self, storage: CheckpointStorage) -> None: + """Set runtime checkpoint storage to override build-time configuration. + + Args: + storage: The checkpoint storage to use for this run. + """ + self._runtime_checkpoint_storage = storage + + def clear_runtime_checkpoint_storage(self) -> None: + """Clear runtime checkpoint storage override. + + This is called automatically by workflow execution methods after a run completes, + ensuring runtime storage doesn't leak across runs. + """ + self._runtime_checkpoint_storage = None + def has_checkpointing(self) -> bool: - return self._checkpoint_storage is not None + return self._get_effective_checkpoint_storage() is not None async def create_checkpoint( self, @@ -338,7 +371,8 @@ class InProcRunnerContext: iteration_count: int, metadata: dict[str, Any] | None = None, ) -> str: - if not self._checkpoint_storage: + storage = self._get_effective_checkpoint_storage() + if not storage: raise ValueError("Checkpoint storage not configured") self._workflow_id = self._workflow_id or str(uuid.uuid4()) @@ -352,19 +386,21 @@ class InProcRunnerContext: iteration_count=state["iteration_count"], metadata=metadata or {}, ) - checkpoint_id = await self._checkpoint_storage.save_checkpoint(checkpoint) + checkpoint_id = await storage.save_checkpoint(checkpoint) logger.info(f"Created checkpoint {checkpoint_id} for workflow {self._workflow_id}") return checkpoint_id async def load_checkpoint(self, checkpoint_id: str) -> WorkflowCheckpoint | None: - if not self._checkpoint_storage: + storage = self._get_effective_checkpoint_storage() + if not storage: raise ValueError("Checkpoint storage not configured") - return await self._checkpoint_storage.load_checkpoint(checkpoint_id) + return await storage.load_checkpoint(checkpoint_id) def reset_for_new_run(self) -> None: """Reset the context for a new workflow run. This clears messages, events, and resets streaming flag. + Runtime checkpoint storage is NOT cleared here as it's managed at the workflow level. """ self._messages.clear() # Clear any pending events (best-effort) by recreating the queue diff --git a/python/packages/core/agent_framework/_workflows/_validation.py b/python/packages/core/agent_framework/_workflows/_validation.py index 0d83218731..d6a246a3eb 100644 --- a/python/packages/core/agent_framework/_workflows/_validation.py +++ b/python/packages/core/agent_framework/_workflows/_validation.py @@ -12,10 +12,6 @@ from ._typing_utils import is_type_compatible logger = logging.getLogger(__name__) -# Track cycle signatures we've already reported to avoid spamming logs when workflows -# with intentional feedback loops are constructed multiple times in the same process. -_LOGGED_CYCLE_SIGNATURES: set[tuple[str, ...]] = set() - # region Enums and Base Classes class ValidationTypeEnum(Enum): @@ -168,7 +164,6 @@ class WorkflowGraphValidator: self._validate_graph_connectivity(start_executor_id) self._validate_self_loops() self._validate_dead_ends() - self._validate_cycles() def _validate_handler_output_annotations(self) -> None: """Validate that each handler's ctx parameter is annotated with WorkflowContext[T]. @@ -394,96 +389,6 @@ class WorkflowGraphValidator: f"Verify these are intended as final nodes in the workflow." ) - def _validate_cycles(self) -> None: - """Detect cycles in the workflow graph. - - Cycles might be intentional for iterative processing but should be flagged - for review to ensure proper termination conditions exist. We surface each - distinct cycle group only once per process to avoid noisy, repeated warnings - when rebuilding the same workflow. - """ - # Build adjacency list (ensure every executor appears even if it has no outgoing edges) - graph: dict[str, list[str]] = defaultdict(list) - for edge in self._edges: - graph[edge.source_id].append(edge.target_id) - graph.setdefault(edge.target_id, []) - for executor_id in self._executors: - graph.setdefault(executor_id, []) - - # Tarjan's algorithm to locate strongly-connected components that form cycles - index: dict[str, int] = {} - lowlink: dict[str, int] = {} - on_stack: set[str] = set() - stack: list[str] = [] - current_index = 0 - cycle_components: list[list[str]] = [] - - def strongconnect(node: str) -> None: - nonlocal current_index - - index[node] = current_index - lowlink[node] = current_index - current_index += 1 - stack.append(node) - on_stack.add(node) - - for neighbor in graph[node]: - if neighbor not in index: - strongconnect(neighbor) - lowlink[node] = min(lowlink[node], lowlink[neighbor]) - elif neighbor in on_stack: - lowlink[node] = min(lowlink[node], index[neighbor]) - - if lowlink[node] == index[node]: - component: list[str] = [] - while True: - member = stack.pop() - on_stack.discard(member) - component.append(member) - if member == node: - break - - # A strongly connected component represents a cycle if it has more than one - # node or if a single node references itself directly. - if len(component) > 1 or any(member in graph[member] for member in component): - cycle_components.append(component) - - for executor_id in graph: - if executor_id not in index: - strongconnect(executor_id) - - if not cycle_components: - return - - unseen_components: list[list[str]] = [] - for component in cycle_components: - signature = tuple(sorted(component)) - if signature in _LOGGED_CYCLE_SIGNATURES: - continue - _LOGGED_CYCLE_SIGNATURES.add(signature) - unseen_components.append(component) - - if not unseen_components: - # All cycles already reported in this process; keep noise low but retain traceability. - logger.debug( - "Cycle detected in workflow graph but previously reported. Components: %s", - [sorted(component) for component in cycle_components], - ) - return - - def _format_cycle(component: list[str]) -> str: - if not component: - return "" - ordered = list(component) - ordered.append(component[0]) - return " -> ".join(ordered) - - formatted_cycles = ", ".join(_format_cycle(component) for component in unseen_components) - logger.warning( - "Cycle detected in the workflow graph involving: %s. Ensure termination or iteration limits exist.", - formatted_cycles, - ) - # endregion diff --git a/python/packages/core/agent_framework/_workflows/_workflow.py b/python/packages/core/agent_framework/_workflows/_workflow.py index b7e8d8d785..e5fd02a611 100644 --- a/python/packages/core/agent_framework/_workflows/_workflow.py +++ b/python/packages/core/agent_framework/_workflows/_workflow.py @@ -131,10 +131,16 @@ class Workflow(DictConvertible): Access these via the input_types and output_types properties. ## Execution Methods - - run(): Execute to completion, returns WorkflowRunResult with all events - - run_stream(): Returns async generator yielding events as they occur - - run_from_checkpoint(): Resume from a saved checkpoint - - run_stream_from_checkpoint(): Resume from checkpoint with streaming + The workflow provides two primary execution APIs, each supporting multiple scenarios: + + - **run()**: Execute to completion, returns WorkflowRunResult with all events + - **run_stream()**: Returns async generator yielding events as they occur + + Both methods support: + - Initial workflow runs: Provide `message` parameter + - Checkpoint restoration: Provide `checkpoint_id` (and optionally `checkpoint_storage`) + - HIL continuation: Provide `responses` to continue after RequestInfoExecutor requests + - Runtime checkpointing: Provide `checkpoint_storage` to enable/override checkpointing for this run ## External Input Requests Executors within a workflow can request external input using `ctx.request_info()`: @@ -142,10 +148,18 @@ class Workflow(DictConvertible): 2. Executor implements `response_handler()` to process the response 3. Requests are emitted as RequestInfoEvent instances in the event stream 4. Workflow enters IDLE_WITH_PENDING_REQUESTS state - 5. Caller handles requests and uses send_responses()/send_responses_streaming() to continue - 6. Responses are routed back to the requesting executors and response handlers are invoked + 5. Caller handles requests and provides responses via the `send_responses` or `send_responses_streaming` methods + 6. Responses are routed to the requesting executors and response handlers are invoked ## Checkpointing + Checkpointing can be configured at build time or runtime: + + Build-time (via WorkflowBuilder): + workflow = WorkflowBuilder().with_checkpointing(storage).build() + + Runtime (via run/run_stream parameters): + result = await workflow.run(message, checkpoint_storage=runtime_storage) + When enabled, checkpoints are created at the end of each superstep, capturing: - Executor states - Messages in transit @@ -370,65 +384,146 @@ class Workflow(DictConvertible): capture_exception(span, exception=exc) raise - # region Streaming Run - async def run_stream(self, message: Any) -> AsyncIterable[WorkflowEvent]: - """Run the workflow with a starting message and stream events. + async def _execute_with_message_or_checkpoint( + self, + message: Any | None, + checkpoint_id: str | None, + checkpoint_storage: CheckpointStorage | None, + ) -> None: + """Internal handler for executing workflow with either initial message or checkpoint restoration. Args: - message: The message to be sent to the starting executor. + message: Initial message for the start executor (for new runs). + checkpoint_id: ID of checkpoint to restore from (for resuming runs). + checkpoint_storage: Runtime checkpoint storage. - Yields: - WorkflowEvent: The events generated during the workflow execution. + Raises: + ValueError: If both message and checkpoint_id are None (nothing to execute). """ - self._ensure_not_running() + # Validate that we have something to execute + if message is None and checkpoint_id is None: + raise ValueError("Must provide either 'message' or 'checkpoint_id'") - async def initial_execution() -> None: + # Handle checkpoint restoration + if checkpoint_id is not None: + has_checkpointing = self._runner.context.has_checkpointing() + + if not has_checkpointing and checkpoint_storage is None: + raise ValueError( + "Cannot restore from checkpoint: either provide checkpoint_storage parameter " + "or build workflow with WorkflowBuilder.with_checkpointing(checkpoint_storage)." + ) + + restored = await self._runner.restore_from_checkpoint(checkpoint_id, checkpoint_storage) + + if not restored: + raise RuntimeError(f"Failed to restore from checkpoint: {checkpoint_id}") + + # Handle initial message + elif message is not None: executor = self.get_start_executor() await executor.execute( message, - [self.__class__.__name__], # source_executor_ids - self._shared_state, # shared_state - self._runner.context, # runner_context - trace_contexts=None, # No parent trace context for workflow start - source_span_ids=None, # No source span for workflow start + [self.__class__.__name__], + self._shared_state, + self._runner.context, + trace_contexts=None, + source_span_ids=None, ) - try: - async for event in self._run_workflow_with_tracing( - initial_executor_fn=initial_execution, reset_context=True, streaming=True - ): - yield event - finally: - self._reset_running_flag() - - async def run_stream_from_checkpoint( + async def run_stream( self, - checkpoint_id: str, + message: Any | None = None, + *, + checkpoint_id: str | None = None, checkpoint_storage: CheckpointStorage | None = None, ) -> AsyncIterable[WorkflowEvent]: - """Resume workflow execution from a checkpoint and stream events. + """Run the workflow and stream events. + + Unified streaming interface supporting initial runs and checkpoint restoration. Args: - checkpoint_id: The ID of the checkpoint to restore from. - checkpoint_storage: Optional checkpoint storage to use for restoration. - If not provided, the workflow must have been built with checkpointing enabled. + message: Initial message for the start executor. Required for new workflow runs, + should be None when resuming from checkpoint. + checkpoint_id: ID of checkpoint to restore from. If provided, the workflow resumes + from this checkpoint instead of starting fresh. When resuming, checkpoint_storage + must be provided (either at build time or runtime) to load the checkpoint. + checkpoint_storage: Runtime checkpoint storage with two behaviors: + - With checkpoint_id: Used to load and restore the specified checkpoint + - Without checkpoint_id: Enables checkpointing for this run, overriding + build-time configuration Yields: WorkflowEvent: Events generated during workflow execution. Raises: - ValueError: If neither checkpoint_storage is provided nor checkpointing is enabled. + ValueError: If both message and checkpoint_id are provided, or if neither is provided. + ValueError: If checkpoint_id is provided but no checkpoint storage is available + (neither at build time nor runtime). RuntimeError: If checkpoint restoration fails. + + Examples: + Initial run: + + .. code-block:: python + + async for event in workflow.run_stream("start message"): + process(event) + + Enable checkpointing at runtime: + + .. code-block:: python + + storage = FileCheckpointStorage("./checkpoints") + async for event in workflow.run_stream("start", checkpoint_storage=storage): + process(event) + + Resume from checkpoint (storage provided at build time): + + .. code-block:: python + + async for event in workflow.run_stream(checkpoint_id="cp_123"): + process(event) + + Resume from checkpoint (storage provided at runtime): + + .. code-block:: python + + storage = FileCheckpointStorage("./checkpoints") + async for event in workflow.run_stream(checkpoint_id="cp_123", checkpoint_storage=storage): + process(event) """ + # Validate mutually exclusive parameters BEFORE setting running flag + if message is not None and checkpoint_id is not None: + raise ValueError("Cannot provide both 'message' and 'checkpoint_id'. Use one or the other.") + + if message is None and checkpoint_id is None: + raise ValueError("Must provide either 'message' (new run) or 'checkpoint_id' (resume).") + self._ensure_not_running() + + # Enable runtime checkpointing if storage provided + # Two cases: + # 1. checkpoint_storage + checkpoint_id: Load checkpoint from this storage and resume + # 2. checkpoint_storage without checkpoint_id: Enable checkpointing for this run + if checkpoint_storage is not None: + self._runner.context.set_runtime_checkpoint_storage(checkpoint_storage) + try: + # Reset context only for new runs (not checkpoint restoration) + reset_context = message is not None and checkpoint_id is None + async for event in self._run_workflow_with_tracing( - initial_executor_fn=functools.partial(self._checkpoint_restoration, checkpoint_id, checkpoint_storage), - reset_context=False, # Don't reset context when resuming from checkpoint + initial_executor_fn=functools.partial( + self._execute_with_message_or_checkpoint, message, checkpoint_id, checkpoint_storage + ), + reset_context=reset_context, streaming=True, ): yield event finally: + if checkpoint_storage is not None: + self._runner.context.clear_runtime_checkpoint_storage() self._reset_running_flag() async def send_responses_streaming(self, responses: dict[str, Any]) -> AsyncIterable[WorkflowEvent]: @@ -452,42 +547,96 @@ class Workflow(DictConvertible): finally: self._reset_running_flag() - # endregion: Streaming Run + async def run( + self, + message: Any | None = None, + *, + checkpoint_id: str | None = None, + checkpoint_storage: CheckpointStorage | None = None, + include_status_events: bool = False, + ) -> WorkflowRunResult: + """Run the workflow to completion and return all events. - # region: Run - - async def run(self, message: Any, *, include_status_events: bool = False) -> WorkflowRunResult: - """Run the workflow with the given message. + Unified non-streaming interface supporting initial runs and checkpoint restoration. Args: - message: The message to be processed by the workflow. + message: Initial message for the start executor. Required for new workflow runs, + should be None when resuming from checkpoint. + checkpoint_id: ID of checkpoint to restore from. If provided, the workflow resumes + from this checkpoint instead of starting fresh. When resuming, checkpoint_storage + must be provided (either at build time or runtime) to load the checkpoint. + checkpoint_storage: Runtime checkpoint storage with two behaviors: + - With checkpoint_id: Used to load and restore the specified checkpoint + - Without checkpoint_id: Enables checkpointing for this run, overriding + build-time configuration include_status_events: Whether to include WorkflowStatusEvent instances in the result list. Returns: - A WorkflowRunResult instance containing a list of events generated during the workflow execution. - """ - self._ensure_not_running() - try: + A WorkflowRunResult instance containing events generated during workflow execution. - async def initial_execution() -> None: - executor = self.get_start_executor() - await executor.execute( - message, - [self.__class__.__name__], # source_executor_ids - self._shared_state, # shared_state - self._runner.context, # runner_context - trace_contexts=None, # No parent trace context for workflow start - source_span_ids=None, # No source span for workflow start - ) + Raises: + ValueError: If both message and checkpoint_id are provided, or if neither is provided. + ValueError: If checkpoint_id is provided but no checkpoint storage is available + (neither at build time nor runtime). + RuntimeError: If checkpoint restoration fails. + + Examples: + Initial run: + + .. code-block:: python + + result = await workflow.run("start message") + outputs = result.get_outputs() + + Enable checkpointing at runtime: + + .. code-block:: python + + storage = FileCheckpointStorage("./checkpoints") + result = await workflow.run("start", checkpoint_storage=storage) + + Resume from checkpoint (storage provided at build time): + + .. code-block:: python + + result = await workflow.run(checkpoint_id="cp_123") + + Resume from checkpoint (storage provided at runtime): + + .. code-block:: python + + storage = FileCheckpointStorage("./checkpoints") + result = await workflow.run(checkpoint_id="cp_123", checkpoint_storage=storage) + """ + # Validate mutually exclusive parameters BEFORE setting running flag + if message is not None and checkpoint_id is not None: + raise ValueError("Cannot provide both 'message' and 'checkpoint_id'. Use one or the other.") + + if message is None and checkpoint_id is None: + raise ValueError("Must provide either 'message' (new run) or 'checkpoint_id' (resume).") + + self._ensure_not_running() + + # Enable runtime checkpointing if storage provided + if checkpoint_storage is not None: + self._runner.context.set_runtime_checkpoint_storage(checkpoint_storage) + + try: + # Reset context only for new runs (not checkpoint restoration) + reset_context = message is not None and checkpoint_id is None raw_events = [ event async for event in self._run_workflow_with_tracing( - initial_executor_fn=initial_execution, - reset_context=True, + initial_executor_fn=functools.partial( + self._execute_with_message_or_checkpoint, message, checkpoint_id, checkpoint_storage + ), + reset_context=reset_context, ) ] finally: + if checkpoint_storage is not None: + self._runner.context.clear_runtime_checkpoint_storage() self._reset_running_flag() # Filter events for non-streaming mode @@ -508,42 +657,6 @@ class Workflow(DictConvertible): return WorkflowRunResult(filtered, status_events) - async def run_from_checkpoint( - self, - checkpoint_id: str, - checkpoint_storage: CheckpointStorage | None = None, - ) -> WorkflowRunResult: - """Resume workflow execution from a checkpoint. - - Args: - checkpoint_id: The ID of the checkpoint to restore from. - checkpoint_storage: Optional checkpoint storage to use for restoration. - If not provided, the workflow must have been built with checkpointing enabled. - - Returns: - A WorkflowRunResult instance containing a list of events generated during the workflow execution. - - Raises: - ValueError: If neither checkpoint_storage is provided nor checkpointing is enabled. - RuntimeError: If checkpoint restoration fails. - """ - self._ensure_not_running() - try: - events = [ - event - async for event in self._run_workflow_with_tracing( - initial_executor_fn=functools.partial( - self._checkpoint_restoration, checkpoint_id, checkpoint_storage - ), - reset_context=False, # Don't reset context when resuming from checkpoint - ) - ] - status_events = [e for e in events if isinstance(e, WorkflowStatusEvent)] - filtered_events = [e for e in events if not isinstance(e, (WorkflowStatusEvent, WorkflowStartedEvent))] - return WorkflowRunResult(filtered_events, status_events) - finally: - self._reset_running_flag() - async def send_responses(self, responses: dict[str, Any]) -> WorkflowRunResult: """Send responses back to the workflow. @@ -568,8 +681,6 @@ class Workflow(DictConvertible): finally: self._reset_running_flag() - # endregion: Run - async def _send_responses_internal(self, responses: dict[str, Any]) -> None: """Internal method to validate and send responses to the executors.""" pending_requests = await self._runner_context.get_pending_request_info_events() @@ -592,21 +703,6 @@ class Workflow(DictConvertible): for request_id, response in responses.items() ]) - async def _checkpoint_restoration(self, checkpoint_id: str, checkpoint_storage: CheckpointStorage | None) -> None: - """Internal method to restore a run from a checkpoint.""" - has_checkpointing = self._runner.context.has_checkpointing() - - if not has_checkpointing and checkpoint_storage is None: - raise ValueError( - "Cannot restore from checkpoint: either provide checkpoint_storage parameter " - "or build workflow with WorkflowBuilder.with_checkpointing(checkpoint_storage)." - ) - - restored = await self._runner.restore_from_checkpoint(checkpoint_id, checkpoint_storage) - - if not restored: - raise RuntimeError(f"Failed to restore from checkpoint: {checkpoint_id}") - def _get_executor_by_id(self, executor_id: str) -> Executor: """Get an executor by its ID. diff --git a/python/packages/core/agent_framework/_workflows/_workflow_executor.py b/python/packages/core/agent_framework/_workflows/_workflow_executor.py index c656e36b72..77acbc5a58 100644 --- a/python/packages/core/agent_framework/_workflows/_workflow_executor.py +++ b/python/packages/core/agent_framework/_workflows/_workflow_executor.py @@ -525,7 +525,8 @@ class WorkflowExecutor(Executor): for request_info_event in execution_context.pending_requests.values() ] await asyncio.gather(*[ - self.workflow._runner_context.add_request_info_event(event) for event in request_info_events + self.workflow._runner_context.add_request_info_event(event) # pyright: ignore[reportPrivateUsage] + for event in request_info_events ]) self._state_loaded = True diff --git a/python/packages/core/tests/workflow/test_agent_executor.py b/python/packages/core/tests/workflow/test_agent_executor.py index 59ffbf8436..3bda2fcaad 100644 --- a/python/packages/core/tests/workflow/test_agent_executor.py +++ b/python/packages/core/tests/workflow/test_agent_executor.py @@ -125,7 +125,7 @@ async def test_agent_executor_checkpoint_stores_and_restores_state() -> None: # Resume from checkpoint resumed_output: AgentExecutorResponse | None = None - async for ev in wf_resume.run_stream_from_checkpoint(restore_checkpoint.checkpoint_id): + async for ev in wf_resume.run_stream(checkpoint_id=restore_checkpoint.checkpoint_id): if isinstance(ev, WorkflowOutputEvent): resumed_output = ev.data # type: ignore[assignment] if isinstance(ev, WorkflowStatusEvent) and ev.state in ( diff --git a/python/packages/core/tests/workflow/test_checkpoint_validation.py b/python/packages/core/tests/workflow/test_checkpoint_validation.py index 361758f3eb..9736660ed8 100644 --- a/python/packages/core/tests/workflow/test_checkpoint_validation.py +++ b/python/packages/core/tests/workflow/test_checkpoint_validation.py @@ -46,8 +46,8 @@ async def test_resume_fails_when_graph_mismatch() -> None: with pytest.raises(ValueError, match="Workflow graph has changed"): _ = [ event - async for event in mismatched_workflow.run_stream_from_checkpoint( - target_checkpoint.checkpoint_id, + async for event in mismatched_workflow.run_stream( + checkpoint_id=target_checkpoint.checkpoint_id, checkpoint_storage=storage, ) ] @@ -65,8 +65,8 @@ async def test_resume_succeeds_when_graph_matches() -> None: events = [ event - async for event in resumed_workflow.run_stream_from_checkpoint( - target_checkpoint.checkpoint_id, + async for event in resumed_workflow.run_stream( + checkpoint_id=target_checkpoint.checkpoint_id, checkpoint_storage=storage, ) ] diff --git a/python/packages/core/tests/workflow/test_concurrent.py b/python/packages/core/tests/workflow/test_concurrent.py index 3317685e5f..db70be3f38 100644 --- a/python/packages/core/tests/workflow/test_concurrent.py +++ b/python/packages/core/tests/workflow/test_concurrent.py @@ -195,7 +195,7 @@ async def test_concurrent_checkpoint_resume_round_trip() -> None: wf_resume = ConcurrentBuilder().participants(list(resumed_participants)).with_checkpointing(storage).build() resumed_output: list[ChatMessage] | None = None - async for ev in wf_resume.run_stream_from_checkpoint(resume_checkpoint.checkpoint_id): + async for ev in wf_resume.run_stream(checkpoint_id=resume_checkpoint.checkpoint_id): if isinstance(ev, WorkflowOutputEvent): resumed_output = ev.data # type: ignore[assignment] if isinstance(ev, WorkflowStatusEvent) and ev.state in ( @@ -207,3 +207,74 @@ async def test_concurrent_checkpoint_resume_round_trip() -> None: assert resumed_output is not None assert [m.role for m in resumed_output] == [m.role for m in baseline_output] assert [m.text for m in resumed_output] == [m.text for m in baseline_output] + + +async def test_concurrent_checkpoint_runtime_only() -> None: + """Test checkpointing configured ONLY at runtime, not at build time.""" + storage = InMemoryCheckpointStorage() + + agents = [_FakeAgentExec(id="agent1", reply_text="A1"), _FakeAgentExec(id="agent2", reply_text="A2")] + wf = ConcurrentBuilder().participants(agents).build() + + baseline_output: list[ChatMessage] | None = None + async for ev in wf.run_stream("runtime checkpoint test", checkpoint_storage=storage): + if isinstance(ev, WorkflowOutputEvent): + baseline_output = ev.data # type: ignore[assignment] + if isinstance(ev, WorkflowStatusEvent) and ev.state == WorkflowRunState.IDLE: + break + + assert baseline_output is not None + + checkpoints = await storage.list_checkpoints() + assert checkpoints + checkpoints.sort(key=lambda cp: cp.timestamp) + + resume_checkpoint = next( + (cp for cp in checkpoints if (cp.metadata or {}).get("checkpoint_type") == "superstep"), + checkpoints[-1], + ) + + resumed_agents = [_FakeAgentExec(id="agent1", reply_text="A1"), _FakeAgentExec(id="agent2", reply_text="A2")] + wf_resume = ConcurrentBuilder().participants(resumed_agents).build() + + resumed_output: list[ChatMessage] | None = None + async for ev in wf_resume.run_stream(checkpoint_id=resume_checkpoint.checkpoint_id, checkpoint_storage=storage): + if isinstance(ev, WorkflowOutputEvent): + resumed_output = ev.data # type: ignore[assignment] + if isinstance(ev, WorkflowStatusEvent) and ev.state in ( + WorkflowRunState.IDLE, + WorkflowRunState.IDLE_WITH_PENDING_REQUESTS, + ): + break + + assert resumed_output is not None + assert [m.role for m in resumed_output] == [m.role for m in baseline_output] + + +async def test_concurrent_checkpoint_runtime_overrides_buildtime() -> None: + """Test that runtime checkpoint storage overrides build-time configuration.""" + import tempfile + + with tempfile.TemporaryDirectory() as temp_dir1, tempfile.TemporaryDirectory() as temp_dir2: + from agent_framework._workflows._checkpoint import FileCheckpointStorage + + buildtime_storage = FileCheckpointStorage(temp_dir1) + runtime_storage = FileCheckpointStorage(temp_dir2) + + agents = [_FakeAgentExec(id="agent1", reply_text="A1"), _FakeAgentExec(id="agent2", reply_text="A2")] + wf = ConcurrentBuilder().participants(agents).with_checkpointing(buildtime_storage).build() + + baseline_output: list[ChatMessage] | None = None + async for ev in wf.run_stream("override test", checkpoint_storage=runtime_storage): + if isinstance(ev, WorkflowOutputEvent): + baseline_output = ev.data # type: ignore[assignment] + if isinstance(ev, WorkflowStatusEvent) and ev.state == WorkflowRunState.IDLE: + break + + assert baseline_output is not None + + buildtime_checkpoints = await buildtime_storage.list_checkpoints() + runtime_checkpoints = await runtime_storage.list_checkpoints() + + assert len(runtime_checkpoints) > 0, "Runtime storage should have checkpoints" + assert len(buildtime_checkpoints) == 0, "Build-time storage should have no checkpoints when overridden" diff --git a/python/packages/core/tests/workflow/test_group_chat.py b/python/packages/core/tests/workflow/test_group_chat.py index 01942a8703..ab920d0663 100644 --- a/python/packages/core/tests/workflow/test_group_chat.py +++ b/python/packages/core/tests/workflow/test_group_chat.py @@ -742,3 +742,73 @@ class TestRoundLimitEnforcement: # The last message should be about round limit final_output = outputs[-1] assert "round limit" in final_output.text.lower() + + +async def test_group_chat_checkpoint_runtime_only() -> None: + """Test checkpointing configured ONLY at runtime, not at build time.""" + from agent_framework import WorkflowRunState, WorkflowStatusEvent + + storage = InMemoryCheckpointStorage() + + agent_a = StubAgent("agentA", "Reply from A") + agent_b = StubAgent("agentB", "Reply from B") + selector = make_sequence_selector() + + wf = GroupChatBuilder().participants([agent_a, agent_b]).select_speakers(selector).build() + + baseline_output: list[ChatMessage] | None = None + async for ev in wf.run_stream("runtime checkpoint test", checkpoint_storage=storage): + if isinstance(ev, WorkflowOutputEvent): + baseline_output = ev.data # type: ignore[assignment] + if isinstance(ev, WorkflowStatusEvent) and ev.state in ( + WorkflowRunState.IDLE, + WorkflowRunState.IDLE_WITH_PENDING_REQUESTS, + ): + break + + assert baseline_output is not None + + checkpoints = await storage.list_checkpoints() + assert len(checkpoints) > 0, "Runtime-only checkpointing should have created checkpoints" + + +async def test_group_chat_checkpoint_runtime_overrides_buildtime() -> None: + """Test that runtime checkpoint storage overrides build-time configuration.""" + import tempfile + + with tempfile.TemporaryDirectory() as temp_dir1, tempfile.TemporaryDirectory() as temp_dir2: + from agent_framework import WorkflowRunState, WorkflowStatusEvent + from agent_framework._workflows._checkpoint import FileCheckpointStorage + + buildtime_storage = FileCheckpointStorage(temp_dir1) + runtime_storage = FileCheckpointStorage(temp_dir2) + + agent_a = StubAgent("agentA", "Reply from A") + agent_b = StubAgent("agentB", "Reply from B") + selector = make_sequence_selector() + + wf = ( + GroupChatBuilder() + .participants([agent_a, agent_b]) + .select_speakers(selector) + .with_checkpointing(buildtime_storage) + .build() + ) + + baseline_output: list[ChatMessage] | None = None + async for ev in wf.run_stream("override test", checkpoint_storage=runtime_storage): + if isinstance(ev, WorkflowOutputEvent): + baseline_output = ev.data # type: ignore[assignment] + if isinstance(ev, WorkflowStatusEvent) and ev.state in ( + WorkflowRunState.IDLE, + WorkflowRunState.IDLE_WITH_PENDING_REQUESTS, + ): + break + + assert baseline_output is not None + + buildtime_checkpoints = await buildtime_storage.list_checkpoints() + runtime_checkpoints = await runtime_storage.list_checkpoints() + + assert len(runtime_checkpoints) > 0, "Runtime storage should have checkpoints" + assert len(buildtime_checkpoints) == 0, "Build-time storage should have no checkpoints when overridden" diff --git a/python/packages/core/tests/workflow/test_handoff.py b/python/packages/core/tests/workflow/test_handoff.py index 12d115ad40..8042c68e08 100644 --- a/python/packages/core/tests/workflow/test_handoff.py +++ b/python/packages/core/tests/workflow/test_handoff.py @@ -155,31 +155,6 @@ async def _drain(stream: AsyncIterable[WorkflowEvent]) -> list[WorkflowEvent]: return [event async for event in stream] -async def test_handoff_routes_to_specialist_and_requests_user_input(): - triage = _RecordingAgent(name="triage", handoff_to="specialist") - specialist = _RecordingAgent(name="specialist") - - workflow = HandoffBuilder(participants=[triage, specialist]).set_coordinator("triage").build() - - events = await _drain(workflow.run_stream("Need help with a refund")) - - assert triage.calls, "Starting agent should receive initial conversation" - assert specialist.calls, "Specialist should be invoked after handoff" - assert len(specialist.calls[0]) == 2 # user + triage reply - - requests = [ev for ev in events if isinstance(ev, RequestInfoEvent)] - assert requests, "Workflow should request additional user input" - request_payload = requests[-1].data - assert isinstance(request_payload, HandoffUserInputRequest) - assert len(request_payload.conversation) == 4 # user, triage tool call, tool ack, specialist - assert request_payload.conversation[2].role == Role.TOOL - assert request_payload.conversation[3].role == Role.ASSISTANT - assert "specialist reply" in request_payload.conversation[3].text - - follow_up = await _drain(workflow.send_responses_streaming({requests[-1].request_id: "Thanks"})) - assert any(isinstance(ev, RequestInfoEvent) for ev in follow_up) - - async def test_specialist_to_specialist_handoff(): """Test that specialists can hand off to other specialists via .add_handoff() configuration.""" triage = _RecordingAgent(name="triage", handoff_to="specialist") diff --git a/python/packages/core/tests/workflow/test_magentic.py b/python/packages/core/tests/workflow/test_magentic.py index f7ae94759f..eda0675361 100644 --- a/python/packages/core/tests/workflow/test_magentic.py +++ b/python/packages/core/tests/workflow/test_magentic.py @@ -185,6 +185,7 @@ async def test_standard_manager_progress_ledger_and_fallback(): assert ledger2.is_request_satisfied.answer is False +@pytest.mark.skip(reason="Response handling refactored - responses no longer passed to run_stream()") async def test_magentic_workflow_plan_review_approval_to_completion(): manager = FakeManager(max_round_count=10) wf = ( @@ -203,9 +204,9 @@ async def test_magentic_workflow_plan_review_approval_to_completion(): completed = False output: ChatMessage | None = None - async for ev in wf.send_responses_streaming({ - req_event.request_id: MagenticPlanReviewReply(decision=MagenticPlanReviewDecision.APPROVE) - }): + async for ev in wf.run_stream( + responses={req_event.request_id: MagenticPlanReviewReply(decision=MagenticPlanReviewDecision.APPROVE)} + ): if isinstance(ev, WorkflowStatusEvent) and ev.state == WorkflowRunState.IDLE: completed = True elif isinstance(ev, WorkflowOutputEvent): @@ -217,6 +218,7 @@ async def test_magentic_workflow_plan_review_approval_to_completion(): assert isinstance(output, ChatMessage) +@pytest.mark.skip(reason="Response handling refactored - responses no longer passed to run_stream()") async def test_magentic_plan_review_approve_with_comments_replans_and_proceeds(): class CountingManager(FakeManager): # Declare as a model field so assignment is allowed under Pydantic @@ -248,12 +250,14 @@ async def test_magentic_plan_review_approve_with_comments_replans_and_proceeds() # Reply APPROVE with comments (no edited text). Expect one replan and no second review round. saw_second_review = False completed = False - async for ev in wf.send_responses_streaming({ - req_event.request_id: MagenticPlanReviewReply( - decision=MagenticPlanReviewDecision.APPROVE, - comments="Looks good; consider Z", - ) - }): + async for ev in wf.run_stream( + responses={ + req_event.request_id: MagenticPlanReviewReply( + decision=MagenticPlanReviewDecision.APPROVE, + comments="Looks good; consider Z", + ) + } + ): if isinstance(ev, RequestInfoEvent) and ev.request_type is MagenticPlanReviewRequest: saw_second_review = True if isinstance(ev, WorkflowStatusEvent) and ev.state == WorkflowRunState.IDLE: @@ -294,6 +298,7 @@ async def test_magentic_orchestrator_round_limit_produces_partial_result(): assert data.role == Role.ASSISTANT +@pytest.mark.skip(reason="Response handling refactored - send_responses_streaming no longer exists") async def test_magentic_checkpoint_resume_round_trip(): storage = InMemoryCheckpointStorage() @@ -334,7 +339,7 @@ async def test_magentic_checkpoint_resume_round_trip(): reply = MagenticPlanReviewReply(decision=MagenticPlanReviewDecision.APPROVE) completed: WorkflowOutputEvent | None = None req_event = None - async for event in wf_resume.run_stream_from_checkpoint( + async for event in wf_resume.run_stream( resume_checkpoint.checkpoint_id, ): if isinstance(event, RequestInfoEvent) and event.request_type is MagenticPlanReviewRequest: @@ -604,7 +609,7 @@ async def test_magentic_checkpoint_resume_inner_loop_superstep(): ) completed: WorkflowOutputEvent | None = None - async for event in resumed.run_stream_from_checkpoint(inner_loop_checkpoint.checkpoint_id): # type: ignore[reportUnknownMemberType] + async for event in resumed.run_stream(checkpoint_id=inner_loop_checkpoint.checkpoint_id): # type: ignore[reportUnknownMemberType] if isinstance(event, WorkflowOutputEvent): completed = event @@ -646,7 +651,7 @@ async def test_magentic_checkpoint_resume_after_reset(): ) completed: WorkflowOutputEvent | None = None - async for event in resumed_workflow.run_stream_from_checkpoint(resumed_state.checkpoint_id): + async for event in resumed_workflow.run_stream(checkpoint_id=resumed_state.checkpoint_id): if isinstance(event, WorkflowOutputEvent): completed = event @@ -687,8 +692,8 @@ async def test_magentic_checkpoint_resume_rejects_participant_renames(): ) with pytest.raises(ValueError, match="Workflow graph has changed"): - async for _ in renamed_workflow.run_stream_from_checkpoint( - target_checkpoint.checkpoint_id, # type: ignore[reportUnknownMemberType] + async for _ in renamed_workflow.run_stream( + checkpoint_id=target_checkpoint.checkpoint_id, # type: ignore[reportUnknownMemberType] ): pass @@ -735,3 +740,66 @@ async def test_magentic_stall_and_reset_successfully(): assert isinstance(output_event.data, ChatMessage) assert output_event.data.text is not None assert output_event.data.text == "re-ledger" + + +async def test_magentic_checkpoint_runtime_only() -> None: + """Test checkpointing configured ONLY at runtime, not at build time.""" + storage = InMemoryCheckpointStorage() + + manager = FakeManager(max_round_count=10) + manager.satisfied_after_signoff = True + wf = MagenticBuilder().participants(agentA=_DummyExec("agentA")).with_standard_manager(manager).build() + + baseline_output: ChatMessage | None = None + async for ev in wf.run_stream("runtime checkpoint test", checkpoint_storage=storage): + if isinstance(ev, WorkflowOutputEvent): + baseline_output = ev.data # type: ignore[assignment] + if isinstance(ev, WorkflowStatusEvent) and ev.state in ( + WorkflowRunState.IDLE, + WorkflowRunState.IDLE_WITH_PENDING_REQUESTS, + ): + break + + assert baseline_output is not None + + checkpoints = await storage.list_checkpoints() + assert len(checkpoints) > 0, "Runtime-only checkpointing should have created checkpoints" + + +async def test_magentic_checkpoint_runtime_overrides_buildtime() -> None: + """Test that runtime checkpoint storage overrides build-time configuration.""" + import tempfile + + with tempfile.TemporaryDirectory() as temp_dir1, tempfile.TemporaryDirectory() as temp_dir2: + from agent_framework._workflows._checkpoint import FileCheckpointStorage + + buildtime_storage = FileCheckpointStorage(temp_dir1) + runtime_storage = FileCheckpointStorage(temp_dir2) + + manager = FakeManager(max_round_count=10) + manager.satisfied_after_signoff = True + wf = ( + MagenticBuilder() + .participants(agentA=_DummyExec("agentA")) + .with_standard_manager(manager) + .with_checkpointing(buildtime_storage) + .build() + ) + + baseline_output: ChatMessage | None = None + async for ev in wf.run_stream("override test", checkpoint_storage=runtime_storage): + if isinstance(ev, WorkflowOutputEvent): + baseline_output = ev.data # type: ignore[assignment] + if isinstance(ev, WorkflowStatusEvent) and ev.state in ( + WorkflowRunState.IDLE, + WorkflowRunState.IDLE_WITH_PENDING_REQUESTS, + ): + break + + assert baseline_output is not None + + buildtime_checkpoints = await buildtime_storage.list_checkpoints() + runtime_checkpoints = await runtime_storage.list_checkpoints() + + assert len(runtime_checkpoints) > 0, "Runtime storage should have checkpoints" + assert len(buildtime_checkpoints) == 0, "Build-time storage should have no checkpoints when overridden" diff --git a/python/packages/core/tests/workflow/test_request_info_and_response.py b/python/packages/core/tests/workflow/test_request_info_and_response.py index b83028d98a..537d9b05c5 100644 --- a/python/packages/core/tests/workflow/test_request_info_and_response.py +++ b/python/packages/core/tests/workflow/test_request_info_and_response.py @@ -378,7 +378,7 @@ class TestRequestInfoAndResponse: # Step 5: Resume from checkpoint and verify the request can be continued completed = False restored_request_event: RequestInfoEvent | None = None - async for event in restored_workflow.run_stream_from_checkpoint(checkpoint_with_request.checkpoint_id): + async for event in restored_workflow.run_stream(checkpoint_id=checkpoint_with_request.checkpoint_id): # Should re-emit the pending request info event if isinstance(event, RequestInfoEvent) and event.request_id == request_info_event.request_id: restored_request_event = event diff --git a/python/packages/core/tests/workflow/test_sequential.py b/python/packages/core/tests/workflow/test_sequential.py index 54df5b1638..165d764725 100644 --- a/python/packages/core/tests/workflow/test_sequential.py +++ b/python/packages/core/tests/workflow/test_sequential.py @@ -145,7 +145,7 @@ async def test_sequential_checkpoint_resume_round_trip() -> None: wf_resume = SequentialBuilder().participants(list(resumed_agents)).with_checkpointing(storage).build() resumed_output: list[ChatMessage] | None = None - async for ev in wf_resume.run_stream_from_checkpoint(resume_checkpoint.checkpoint_id): + async for ev in wf_resume.run_stream(checkpoint_id=resume_checkpoint.checkpoint_id): if isinstance(ev, WorkflowOutputEvent): resumed_output = ev.data # type: ignore[assignment] if isinstance(ev, WorkflowStatusEvent) and ev.state in ( @@ -157,3 +157,75 @@ async def test_sequential_checkpoint_resume_round_trip() -> None: assert resumed_output is not None assert [m.role for m in resumed_output] == [m.role for m in baseline_output] assert [m.text for m in resumed_output] == [m.text for m in baseline_output] + + +async def test_sequential_checkpoint_runtime_only() -> None: + """Test checkpointing configured ONLY at runtime, not at build time.""" + storage = InMemoryCheckpointStorage() + + agents = (_EchoAgent(id="agent1", name="A1"), _EchoAgent(id="agent2", name="A2")) + wf = SequentialBuilder().participants(list(agents)).build() + + baseline_output: list[ChatMessage] | None = None + async for ev in wf.run_stream("runtime checkpoint test", checkpoint_storage=storage): + if isinstance(ev, WorkflowOutputEvent): + baseline_output = ev.data # type: ignore[assignment] + if isinstance(ev, WorkflowStatusEvent) and ev.state == WorkflowRunState.IDLE: + break + + assert baseline_output is not None + + checkpoints = await storage.list_checkpoints() + assert checkpoints + checkpoints.sort(key=lambda cp: cp.timestamp) + + resume_checkpoint = next( + (cp for cp in checkpoints if (cp.metadata or {}).get("checkpoint_type") == "superstep"), + checkpoints[-1], + ) + + resumed_agents = (_EchoAgent(id="agent1", name="A1"), _EchoAgent(id="agent2", name="A2")) + wf_resume = SequentialBuilder().participants(list(resumed_agents)).build() + + resumed_output: list[ChatMessage] | None = None + async for ev in wf_resume.run_stream(checkpoint_id=resume_checkpoint.checkpoint_id, checkpoint_storage=storage): + if isinstance(ev, WorkflowOutputEvent): + resumed_output = ev.data # type: ignore[assignment] + if isinstance(ev, WorkflowStatusEvent) and ev.state in ( + WorkflowRunState.IDLE, + WorkflowRunState.IDLE_WITH_PENDING_REQUESTS, + ): + break + + assert resumed_output is not None + assert [m.role for m in resumed_output] == [m.role for m in baseline_output] + assert [m.text for m in resumed_output] == [m.text for m in baseline_output] + + +async def test_sequential_checkpoint_runtime_overrides_buildtime() -> None: + """Test that runtime checkpoint storage overrides build-time configuration.""" + import tempfile + + with tempfile.TemporaryDirectory() as temp_dir1, tempfile.TemporaryDirectory() as temp_dir2: + from agent_framework._workflows._checkpoint import FileCheckpointStorage + + buildtime_storage = FileCheckpointStorage(temp_dir1) + runtime_storage = FileCheckpointStorage(temp_dir2) + + agents = (_EchoAgent(id="agent1", name="A1"), _EchoAgent(id="agent2", name="A2")) + wf = SequentialBuilder().participants(list(agents)).with_checkpointing(buildtime_storage).build() + + baseline_output: list[ChatMessage] | None = None + async for ev in wf.run_stream("override test", checkpoint_storage=runtime_storage): + if isinstance(ev, WorkflowOutputEvent): + baseline_output = ev.data # type: ignore[assignment] + if isinstance(ev, WorkflowStatusEvent) and ev.state == WorkflowRunState.IDLE: + break + + assert baseline_output is not None + + buildtime_checkpoints = await buildtime_storage.list_checkpoints() + runtime_checkpoints = await runtime_storage.list_checkpoints() + + assert len(runtime_checkpoints) > 0, "Runtime storage should have checkpoints" + assert len(buildtime_checkpoints) == 0, "Build-time storage should have no checkpoints when overridden" diff --git a/python/packages/core/tests/workflow/test_validation.py b/python/packages/core/tests/workflow/test_validation.py index d7fc11aa66..dee491a10b 100644 --- a/python/packages/core/tests/workflow/test_validation.py +++ b/python/packages/core/tests/workflow/test_validation.py @@ -385,28 +385,6 @@ def test_dead_end_detection(caplog: Any) -> None: assert "Verify these are intended as final nodes" in caplog.text -def test_cycle_detection_warning(caplog: Any) -> None: - caplog.set_level(logging.WARNING) - - executor1 = StringExecutor(id="executor1") - executor2 = StringExecutor(id="executor2") - executor3 = StringExecutor(id="executor3") - - # Create a cycle: executor1 -> executor2 -> executor3 -> executor1 - workflow = ( - WorkflowBuilder() - .add_edge(executor1, executor2) - .add_edge(executor2, executor3) - .add_edge(executor3, executor1) - .set_start_executor(executor1) - .build() - ) - - assert workflow is not None - assert "Cycle detected in the workflow graph" in caplog.text - assert "Ensure termination or iteration limits exist" in caplog.text - - def test_successful_type_compatibility_logging(caplog: Any) -> None: caplog.set_level(logging.DEBUG) @@ -420,51 +398,6 @@ def test_successful_type_compatibility_logging(caplog: Any) -> None: assert "Compatible type pairs" in caplog.text -def test_complex_cycle_detection(caplog: Any) -> None: - caplog.set_level(logging.WARNING) - - # Create a more complex graph with multiple cycles - executor1 = StringExecutor(id="executor1") - executor2 = StringExecutor(id="executor2") - executor3 = StringExecutor(id="executor3") - executor4 = StringExecutor(id="executor4") - - # Create multiple paths and cycles - workflow = ( - WorkflowBuilder() - .add_edge(executor1, executor2) - .add_edge(executor2, executor3) - .add_edge(executor3, executor4) - .add_edge(executor4, executor2) # Creates cycle: executor2 -> executor3 -> executor4 -> executor2 - .set_start_executor(executor1) - .build() - ) - - assert workflow is not None - assert "Cycle detected in the workflow graph" in caplog.text - - -def test_no_cycles_in_simple_chain(caplog: Any) -> None: - caplog.set_level(logging.WARNING) - - executor1 = StringExecutor(id="executor1") - executor2 = StringExecutor(id="executor2") - executor3 = StringExecutor(id="executor3") - - # Simple chain without cycles - workflow = ( - WorkflowBuilder() - .add_edge(executor1, executor2) - .add_edge(executor2, executor3) - .set_start_executor(executor1) - .build() - ) - - assert workflow is not None - # Should not log cycle detection - assert "Cycle detected" not in caplog.text - - def test_multiple_dead_ends_detection(caplog: Any) -> None: caplog.set_level(logging.INFO) diff --git a/python/packages/core/tests/workflow/test_workflow.py b/python/packages/core/tests/workflow/test_workflow.py index cbf75c5a65..7f1a7fdce6 100644 --- a/python/packages/core/tests/workflow/test_workflow.py +++ b/python/packages/core/tests/workflow/test_workflow.py @@ -185,65 +185,6 @@ async def test_workflow_run_not_completed(): await workflow.run(NumberMessage(data=0)) -async def test_workflow_send_responses_streaming(): - """Test the workflow run with approval.""" - executor_a = IncrementExecutor(id="executor_a") - executor_b = MockExecutorRequestApproval(id="executor_b") - - workflow = ( - WorkflowBuilder() - .set_start_executor(executor_a) - .add_edge(executor_a, executor_b) - .add_edge(executor_b, executor_a) - .build() - ) - - request_info_event: RequestInfoEvent | None = None - async for event in workflow.run_stream(NumberMessage(data=0)): - if isinstance(event, RequestInfoEvent): - request_info_event = event - - assert request_info_event is not None - result: int | None = None - completed = False - async for event in workflow.send_responses_streaming({ - request_info_event.request_id: ApprovalMessage(approved=True) - }): - if isinstance(event, WorkflowOutputEvent): - result = event.data - elif isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE: - completed = True - - assert ( - completed and result is not None and result == 1 - ) # The data should be incremented by 1 from the initial message - - -async def test_workflow_send_responses(): - """Test the workflow run with approval.""" - executor_a = IncrementExecutor(id="executor_a") - executor_b = MockExecutorRequestApproval(id="executor_b") - - workflow = ( - WorkflowBuilder() - .set_start_executor(executor_a) - .add_edge(executor_a, executor_b) - .add_edge(executor_b, executor_a) - .build() - ) - - events = await workflow.run(NumberMessage(data=0)) - request_info_events = events.get_request_info_events() - - assert len(request_info_events) == 1 - - result = await workflow.send_responses({request_info_events[0].request_id: ApprovalMessage(approved=True)}) - - assert result.get_final_state() == WorkflowRunState.IDLE - outputs = result.get_outputs() - assert outputs[0] == 1 # The data should be incremented by 1 from the initial message - - async def test_fan_out(): """Test a fan-out workflow.""" executor_a = IncrementExecutor(id="executor_a") @@ -354,7 +295,7 @@ async def test_workflow_checkpointing_not_enabled_for_external_restore(simple_ex # Attempt to restore from checkpoint without providing external storage should fail try: - [event async for event in workflow.run_stream_from_checkpoint("fake-checkpoint-id")] + [event async for event in workflow.run_stream(checkpoint_id="fake-checkpoint-id")] raise AssertionError("Expected ValueError to be raised") except ValueError as e: assert "Cannot restore from checkpoint" in str(e) @@ -372,7 +313,7 @@ async def test_workflow_run_stream_from_checkpoint_no_checkpointing_enabled(simp # Attempt to run from checkpoint should fail try: - async for _ in workflow.run_stream_from_checkpoint("fake_checkpoint_id"): + async for _ in workflow.run_stream(checkpoint_id="fake_checkpoint_id"): pass raise AssertionError("Expected ValueError to be raised") except ValueError as e: @@ -396,7 +337,7 @@ async def test_workflow_run_stream_from_checkpoint_invalid_checkpoint(simple_exe # Attempt to run from non-existent checkpoint should fail try: - async for _ in workflow.run_stream_from_checkpoint("nonexistent_checkpoint_id"): + async for _ in workflow.run_stream(checkpoint_id="nonexistent_checkpoint_id"): pass raise AssertionError("Expected RuntimeError to be raised") except RuntimeError as e: @@ -427,8 +368,8 @@ async def test_workflow_run_stream_from_checkpoint_with_external_storage(simple_ # Resume from checkpoint using external storage parameter try: events: list[WorkflowEvent] = [] - async for event in workflow_without_checkpointing.run_stream_from_checkpoint( - checkpoint_id, checkpoint_storage=storage + async for event in workflow_without_checkpointing.run_stream( + checkpoint_id=checkpoint_id, checkpoint_storage=storage ): events.append(event) if len(events) >= 2: # Limit to avoid infinite loops @@ -463,14 +404,14 @@ async def test_workflow_run_from_checkpoint_non_streaming(simple_executor: Execu .build() ) - # Test non-streaming run_from_checkpoint method - result = await workflow.run_from_checkpoint(checkpoint_id) + # Test non-streaming run method with checkpoint_id + result = await workflow.run(checkpoint_id=checkpoint_id) assert isinstance(result, list) # Should return WorkflowRunResult which extends list assert hasattr(result, "get_outputs") # Should have WorkflowRunResult methods async def test_workflow_run_stream_from_checkpoint_with_responses(simple_executor: Executor): - """Test that run_stream_from_checkpoint accepts responses parameter.""" + """Test that workflow can be resumed from checkpoint with pending RequestInfoEvents.""" with tempfile.TemporaryDirectory() as temp_dir: storage = FileCheckpointStorage(temp_dir) @@ -502,20 +443,16 @@ async def test_workflow_run_stream_from_checkpoint_with_responses(simple_executo .build() ) - # Test that run_stream_from_checkpoint accepts responses parameter - responses = {"request_123": "test_response"} - + # Resume from checkpoint - pending request events should be emitted events: list[WorkflowEvent] = [] - async for event in workflow.run_stream_from_checkpoint(checkpoint_id): + async for event in workflow.run_stream(checkpoint_id=checkpoint_id): events.append(event) + # Verify that the pending request event was emitted assert next( event for event in events if isinstance(event, RequestInfoEvent) and event.request_id == "request_123" ) - async for event in workflow.send_responses_streaming(responses): - events.append(event) - assert len(events) > 0 # Just ensure we processed some events @@ -594,6 +531,74 @@ async def test_workflow_multiple_runs_no_state_collision(): assert outputs1[0] != outputs3[0] +async def test_workflow_checkpoint_runtime_only_configuration(simple_executor: Executor): + """Test that checkpointing can be configured ONLY at runtime, not at build time.""" + with tempfile.TemporaryDirectory() as temp_dir: + storage = FileCheckpointStorage(temp_dir) + + # Build workflow WITHOUT checkpointing at build time + workflow = ( + WorkflowBuilder().add_edge(simple_executor, simple_executor).set_start_executor(simple_executor).build() + ) + + # Run with runtime checkpoint storage - should create checkpoints + test_message = Message(data="runtime checkpoint test", source_id="test", target_id=None) + result = await workflow.run(test_message, checkpoint_storage=storage) + assert result is not None + assert result.get_final_state() == WorkflowRunState.IDLE + + # Verify checkpoints were created + checkpoints = await storage.list_checkpoints() + assert len(checkpoints) > 0 + + # Find a superstep checkpoint to resume from + checkpoints.sort(key=lambda cp: cp.timestamp) + resume_checkpoint = next( + (cp for cp in checkpoints if (cp.metadata or {}).get("checkpoint_type") == "superstep"), + checkpoints[-1], + ) + + # Create new workflow instance (still without build-time checkpointing) + workflow_resume = ( + WorkflowBuilder().add_edge(simple_executor, simple_executor).set_start_executor(simple_executor).build() + ) + + # Resume from checkpoint using runtime checkpoint storage + result_resumed = await workflow_resume.run( + checkpoint_id=resume_checkpoint.checkpoint_id, checkpoint_storage=storage + ) + assert result_resumed is not None + assert result_resumed.get_final_state() in (WorkflowRunState.IDLE, WorkflowRunState.IDLE_WITH_PENDING_REQUESTS) + + +async def test_workflow_checkpoint_runtime_overrides_buildtime(simple_executor: Executor): + """Test that runtime checkpoint storage overrides build-time configuration.""" + with tempfile.TemporaryDirectory() as temp_dir1, tempfile.TemporaryDirectory() as temp_dir2: + buildtime_storage = FileCheckpointStorage(temp_dir1) + runtime_storage = FileCheckpointStorage(temp_dir2) + + # Build workflow with build-time checkpointing + workflow = ( + WorkflowBuilder() + .add_edge(simple_executor, simple_executor) + .set_start_executor(simple_executor) + .with_checkpointing(buildtime_storage) + .build() + ) + + # Run with runtime checkpoint storage override + test_message = Message(data="override test", source_id="test", target_id=None) + result = await workflow.run(test_message, checkpoint_storage=runtime_storage) + assert result is not None + + # Verify checkpoints were created in runtime storage, not build-time storage + buildtime_checkpoints = await buildtime_storage.list_checkpoints() + runtime_checkpoints = await runtime_storage.list_checkpoints() + + assert len(runtime_checkpoints) > 0, "Runtime storage should have checkpoints" + assert len(buildtime_checkpoints) == 0, "Build-time storage should have no checkpoints when overridden" + + async def test_comprehensive_edge_groups_workflow(): """Test a workflow that uses SwitchCaseEdgeGroup, FanOutEdgeGroup, and FanInEdgeGroup.""" from agent_framework import Case, Default @@ -799,9 +804,6 @@ async def test_workflow_concurrent_execution_prevention_mixed_methods(): async for _ in workflow.run_stream(NumberMessage(data=0)): break - with pytest.raises(RuntimeError, match="Workflow is already running. Concurrent executions are not allowed."): - await workflow.send_responses({"test": "data"}) - # Wait for the original task to complete await task1 @@ -884,3 +886,48 @@ async def test_agent_streaming_vs_non_streaming() -> None: if e.data and e.data.contents and e.data.contents[0].text ) assert accumulated_text == "Hello World", f"Expected 'Hello World', got '{accumulated_text}'" + + +async def test_workflow_run_parameter_validation(simple_executor: Executor) -> None: + """Test that run() and run_stream() properly validate parameter combinations.""" + workflow = WorkflowBuilder().add_edge(simple_executor, simple_executor).set_start_executor(simple_executor).build() + + test_message = Message(data="test", source_id="test", target_id=None) + + # Valid: message only (new run) + result = await workflow.run(test_message) + assert result.get_final_state() == WorkflowRunState.IDLE + + # Invalid: both message and checkpoint_id + with pytest.raises(ValueError, match="Cannot provide both 'message' and 'checkpoint_id'"): + await workflow.run(test_message, checkpoint_id="fake_id") + + # Invalid: both message and checkpoint_id (streaming) + with pytest.raises(ValueError, match="Cannot provide both 'message' and 'checkpoint_id'"): + async for _ in workflow.run_stream(test_message, checkpoint_id="fake_id"): + pass + + # Invalid: none of message or checkpoint_id + with pytest.raises(ValueError, match="Must provide either"): + await workflow.run() + + # Invalid: none of message or checkpoint_id (streaming) + with pytest.raises(ValueError, match="Must provide either"): + async for _ in workflow.run_stream(): + pass + + +async def test_workflow_run_stream_parameter_validation(simple_executor: Executor) -> None: + """Test run_stream() specific parameter validation scenarios.""" + workflow = WorkflowBuilder().add_edge(simple_executor, simple_executor).set_start_executor(simple_executor).build() + + test_message = Message(data="test", source_id="test", target_id=None) + + # Valid: message only (new run) + events: list[WorkflowEvent] = [] + async for event in workflow.run_stream(test_message): + events.append(event) + assert any(isinstance(e, WorkflowStatusEvent) and e.state == WorkflowRunState.IDLE for e in events) + + # Invalid combinations already tested in test_workflow_run_parameter_validation + # This test ensures streaming works correctly for valid parameters diff --git a/python/samples/getting_started/threads/custom_chat_message_store_thread.py b/python/samples/getting_started/threads/custom_chat_message_store_thread.py index 19223ecd7a..f4a9d79112 100644 --- a/python/samples/getting_started/threads/custom_chat_message_store_thread.py +++ b/python/samples/getting_started/threads/custom_chat_message_store_thread.py @@ -5,14 +5,8 @@ from collections.abc import Collection from typing import Any from agent_framework import ChatMessage, ChatMessageStoreProtocol +from agent_framework._threads import ChatMessageStoreState from agent_framework.openai import OpenAIChatClient -from pydantic import BaseModel - - -class CustomStoreState(BaseModel): - """Implementation of custom chat message store state.""" - - messages: list[ChatMessage] class CustomChatMessageStore(ChatMessageStoreProtocol): @@ -32,13 +26,13 @@ class CustomChatMessageStore(ChatMessageStoreProtocol): async def deserialize_state(self, serialized_store_state: Any, **kwargs: Any) -> None: if serialized_store_state: - state = CustomStoreState.model_validate(serialized_store_state, **kwargs) + state = ChatMessageStoreState.from_dict(serialized_store_state, **kwargs) if state.messages: self._messages.extend(state.messages) async def serialize_state(self, **kwargs: Any) -> Any: - state = CustomStoreState(messages=self._messages) - return state.model_dump(**kwargs) + state = ChatMessageStoreState(messages=self._messages) + return state.to_dict(**kwargs) async def main() -> None: diff --git a/python/samples/getting_started/workflows/_start-here/step1_executors_and_edges.py b/python/samples/getting_started/workflows/_start-here/step1_executors_and_edges.py index 9f67f6f7d8..b5c80062dd 100644 --- a/python/samples/getting_started/workflows/_start-here/step1_executors_and_edges.py +++ b/python/samples/getting_started/workflows/_start-here/step1_executors_and_edges.py @@ -117,14 +117,14 @@ async def main(): # retrieves the outputs yielded by any terminal nodes. events = await workflow.run("hello world") print(events.get_outputs()) - # Summarize the final run state (e.g., COMPLETED) + # Summarize the final run state (e.g., IDLE) print("Final state:", events.get_final_state()) """ Sample Output: ['DLROW OLLEH'] - Final state: WorkflowRunState.COMPLETED + Final state: WorkflowRunState.IDLE """ diff --git a/python/samples/getting_started/workflows/agents/azure_chat_agents_tool_calls_with_feedback.py b/python/samples/getting_started/workflows/agents/azure_chat_agents_tool_calls_with_feedback.py index 81ee4f8c4d..f5cb8e99e8 100644 --- a/python/samples/getting_started/workflows/agents/azure_chat_agents_tool_calls_with_feedback.py +++ b/python/samples/getting_started/workflows/agents/azure_chat_agents_tool_calls_with_feedback.py @@ -261,17 +261,21 @@ async def main() -> None: pending_responses: dict[str, str] | None = None completed = False + initial_run = True while not completed: last_executor: str | None = None - stream = ( - workflow.send_responses_streaming(pending_responses) - if pending_responses is not None - else workflow.run_stream( + if initial_run: + stream = workflow.run_stream( "Create a short launch blurb for the LumenX desk lamp. Emphasize adjustability and warm lighting." ) - ) - pending_responses = None + initial_run = False + elif pending_responses is not None: + stream = workflow.send_responses_streaming(pending_responses) + pending_responses = None + else: + break + requests: list[tuple[str, DraftFeedbackRequest]] = [] async for event in stream: diff --git a/python/samples/getting_started/workflows/checkpoint/checkpoint_with_human_in_the_loop.py b/python/samples/getting_started/workflows/checkpoint/checkpoint_with_human_in_the_loop.py index 2a24327952..9fb870bf01 100644 --- a/python/samples/getting_started/workflows/checkpoint/checkpoint_with_human_in_the_loop.py +++ b/python/samples/getting_started/workflows/checkpoint/checkpoint_with_human_in_the_loop.py @@ -250,7 +250,7 @@ async def run_interactive_session( event_stream = workflow.run_stream(initial_message) elif checkpoint_id: print("\nStarting workflow from checkpoint...\n") - event_stream = workflow.run_stream_from_checkpoint(checkpoint_id) + event_stream = workflow.run_stream(checkpoint_id) else: raise ValueError("Either initial_message or checkpoint_id must be provided") diff --git a/python/samples/getting_started/workflows/checkpoint/checkpoint_with_resume.py b/python/samples/getting_started/workflows/checkpoint/checkpoint_with_resume.py index 17fb44c87b..cb0c7705c5 100644 --- a/python/samples/getting_started/workflows/checkpoint/checkpoint_with_resume.py +++ b/python/samples/getting_started/workflows/checkpoint/checkpoint_with_resume.py @@ -47,7 +47,7 @@ What you learn: - How to configure FileCheckpointStorage and call with_checkpointing on WorkflowBuilder. - How to list and inspect checkpoints programmatically. - How to interactively choose a checkpoint to resume from (instead of always resuming - from the most recent or a hard-coded one) using run_stream_from_checkpoint. + from the most recent or a hard-coded one) using run_stream. - How workflows complete by yielding outputs when idle, not via explicit completion events. Prerequisites: @@ -281,7 +281,7 @@ async def main(): new_workflow = create_workflow(checkpoint_storage=checkpoint_storage) print(f"\nResuming from checkpoint: {chosen_cp_id}") - async for event in new_workflow.run_stream_from_checkpoint(chosen_cp_id, checkpoint_storage=checkpoint_storage): + async for event in new_workflow.run_stream(checkpoint_id=chosen_cp_id, checkpoint_storage=checkpoint_storage): print(f"Resumed Event: {event}") """ diff --git a/python/samples/getting_started/workflows/checkpoint/sub_workflow_checkpoint.py b/python/samples/getting_started/workflows/checkpoint/sub_workflow_checkpoint.py index 3311d18b36..62c88bf49f 100644 --- a/python/samples/getting_started/workflows/checkpoint/sub_workflow_checkpoint.py +++ b/python/samples/getting_started/workflows/checkpoint/sub_workflow_checkpoint.py @@ -356,7 +356,7 @@ async def main() -> None: workflow2 = build_parent_workflow(storage) request_info_event: RequestInfoEvent | None = None - async for event in workflow2.run_stream_from_checkpoint( + async for event in workflow2.run_stream( resume_checkpoint.checkpoint_id, ): if isinstance(event, RequestInfoEvent): diff --git a/python/samples/getting_started/workflows/human-in-the-loop/guessing_game_with_human_input.py b/python/samples/getting_started/workflows/human-in-the-loop/guessing_game_with_human_input.py index a922baf16c..f985893cf2 100644 --- a/python/samples/getting_started/workflows/human-in-the-loop/guessing_game_with_human_input.py +++ b/python/samples/getting_started/workflows/human-in-the-loop/guessing_game_with_human_input.py @@ -37,7 +37,7 @@ Show how to integrate a human step in the middle of an LLM workflow by using Demonstrate: - Alternating turns between an AgentExecutor and a human, driven by events. - Using Pydantic response_format to enforce structured JSON output from the agent instead of regex parsing. -- Driving the loop in application code with run_stream and send_responses_streaming. +- Driving the loop in application code with run_stream and responses parameter. Prerequisites: - Azure OpenAI configured for AzureOpenAIChatClient with required environment variables. diff --git a/python/samples/getting_started/workflows/orchestration/magentic_checkpoint.py b/python/samples/getting_started/workflows/orchestration/magentic_checkpoint.py index 6ae2dd18b5..de7d794b19 100644 --- a/python/samples/getting_started/workflows/orchestration/magentic_checkpoint.py +++ b/python/samples/getting_started/workflows/orchestration/magentic_checkpoint.py @@ -32,7 +32,7 @@ Concepts highlighted here: must keep stable IDs so the checkpoint state aligns when we rebuild the graph. 2. **Executor snapshotting** - checkpoints capture the pending plan-review request map, at superstep boundaries. -3. **Resume with responses** - `Workflow.run_stream_from_checkpoint` accepts a +3. **Resume with responses** - `Workflow.send_responses_streaming` accepts a `responses` mapping so we can inject the stored human reply during restoration. Prerequisites: @@ -141,7 +141,7 @@ async def main() -> None: # Resume execution and capture the re-emitted plan review request. request_info_event: RequestInfoEvent | None = None - async for event in resumed_workflow.run_stream_from_checkpoint(resume_checkpoint.checkpoint_id): + async for event in resumed_workflow.run_stream(checkpoint_id=resume_checkpoint.checkpoint_id): if isinstance(event, RequestInfoEvent) and isinstance(event.data, MagenticPlanReviewRequest): request_info_event = event @@ -212,7 +212,7 @@ async def main() -> None: final_event_post: WorkflowOutputEvent | None = None post_emitted_events = False post_plan_workflow = build_workflow(checkpoint_storage) - async for event in post_plan_workflow.run_stream_from_checkpoint(post_plan_checkpoint.checkpoint_id): + async for event in post_plan_workflow.run_stream(checkpoint_id=post_plan_checkpoint.checkpoint_id): post_emitted_events = True if isinstance(event, WorkflowOutputEvent): final_event_post = event From 64fc3f381f20183d28d137d98c5160ed8bc12a99 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Tue, 4 Nov 2025 15:54:20 +0000 Subject: [PATCH 10/15] .NET: Response & foundry agent hosted MCP sample (#1568) * Adding sample demonstrating hosted MCP with Responses * Add mcp readme.md to slnx * Update FoundryAgent sample to use MCP types from abstraction and to show how to do approval * Fix param name after package update. * Fix environment variable name for consistency * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Chris <66376200+crickman@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- dotnet/agent-framework-dotnet.slnx | 2 + .../FoundryAgent_Hosted_MCP/Program.cs | 116 +++++++++++++----- .../ModelContextProtocol/README.md | 1 + .../ResponseAgent_Hosted_MCP/Program.cs | 95 ++++++++++++++ .../ResponseAgent_Hosted_MCP/README.md | 17 +++ .../ResponseAgent_Hosted_MCP.csproj | 20 +++ 6 files changed, 220 insertions(+), 31 deletions(-) create mode 100644 dotnet/samples/GettingStarted/ModelContextProtocol/ResponseAgent_Hosted_MCP/Program.cs create mode 100644 dotnet/samples/GettingStarted/ModelContextProtocol/ResponseAgent_Hosted_MCP/README.md create mode 100644 dotnet/samples/GettingStarted/ModelContextProtocol/ResponseAgent_Hosted_MCP/ResponseAgent_Hosted_MCP.csproj diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 896a31c9fc..1bfb5814f9 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -68,9 +68,11 @@ + + diff --git a/dotnet/samples/GettingStarted/ModelContextProtocol/FoundryAgent_Hosted_MCP/Program.cs b/dotnet/samples/GettingStarted/ModelContextProtocol/FoundryAgent_Hosted_MCP/Program.cs index 32017af194..f824f09991 100644 --- a/dotnet/samples/GettingStarted/ModelContextProtocol/FoundryAgent_Hosted_MCP/Program.cs +++ b/dotnet/samples/GettingStarted/ModelContextProtocol/FoundryAgent_Hosted_MCP/Program.cs @@ -1,52 +1,106 @@ // Copyright (c) Microsoft. All rights reserved. -// This sample shows how to create and use a simple AI agent with Azure Foundry Agents as the backend. +// This sample shows how to create and use a simple AI agent with Azure Foundry Agents as the backend, that uses a Hosted MCP Tool. +// In this case the Azure Foundry Agents service will invoke any MCP tools as required. MCP tools are not invoked by the Agent Framework. +// The sample first shows how to use MCP tools with auto approval, and then how to set up a tool that requires approval before it can be invoked and how to approve such a tool. using Azure.AI.Agents.Persistent; using Azure.Identity; using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; var endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_PROJECT_ENDPOINT is not set."); -var model = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_MODEL_ID") ?? "gpt-4.1-mini"; +var model = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME") ?? "gpt-4.1-mini"; // Get a client to create/retrieve server side agents with. var persistentAgentsClient = new PersistentAgentsClient(endpoint, new AzureCliCredential()); +// **** MCP Tool with Auto Approval **** +// ************************************* + // Create an MCP tool definition that the agent can use. -var mcpTool = new MCPToolDefinition( - serverLabel: "microsoft_learn", - serverUrl: "https://learn.microsoft.com/api/mcp"); -mcpTool.AllowedTools.Add("microsoft_docs_search"); - -// Create a server side persistent agent with the Azure.AI.Agents.Persistent SDK. -var agentMetadata = await persistentAgentsClient.Administration.CreateAgentAsync( - model: model, - name: "MicrosoftLearnAgent", - instructions: "You answer questions by searching the Microsoft Learn content only.", - tools: [mcpTool]); - -// Retrieve an already created server side persistent agent as an AIAgent. -AIAgent agent = await persistentAgentsClient.GetAIAgentAsync(agentMetadata.Value.Id); - -// Create run options to configure the agent invocation. -var runOptions = new ChatClientAgentRunOptions() +// In this case we allow the tool to always be called without approval. +var mcpTool = new HostedMcpServerTool( + serverName: "microsoft_learn", + serverAddress: "https://learn.microsoft.com/api/mcp") { - ChatOptions = new() - { - RawRepresentationFactory = (_) => new ThreadAndRunOptions() - { - ToolResources = new MCPToolResource(serverLabel: "microsoft_learn") - { - RequireApproval = new MCPApproval("never"), - }.ToToolResources() - } - } + AllowedTools = ["microsoft_docs_search"], + ApprovalMode = HostedMcpServerToolApprovalMode.NeverRequire }; +// Create a server side persistent agent with the mcp tool, and expose it as an AIAgent. +AIAgent agent = await persistentAgentsClient.CreateAIAgentAsync( + model: model, + options: new() + { + Name = "MicrosoftLearnAgent", + Instructions = "You answer questions by searching the Microsoft Learn content only.", + ChatOptions = new() + { + Tools = [mcpTool] + }, + }); + // You can then invoke the agent like any other AIAgent. AgentThread thread = agent.GetNewThread(); -var response = await agent.RunAsync("Please summarize the Azure AI Agent documentation related to MCP Tool calling?", thread, runOptions); -Console.WriteLine(response); +Console.WriteLine(await agent.RunAsync("Please summarize the Azure AI Agent documentation related to MCP Tool calling?", thread)); // Cleanup for sample purposes. await persistentAgentsClient.Administration.DeleteAgentAsync(agent.Id); + +// **** MCP Tool with Approval Required **** +// ***************************************** + +// Create an MCP tool definition that the agent can use. +// In this case we require approval before the tool can be called. +var mcpToolWithApproval = new HostedMcpServerTool( + serverName: "microsoft_learn", + serverAddress: "https://learn.microsoft.com/api/mcp") +{ + AllowedTools = ["microsoft_docs_search"], + ApprovalMode = HostedMcpServerToolApprovalMode.AlwaysRequire +}; + +// Create an agent based on Azure OpenAI Responses as the backend. +AIAgent agentWithRequiredApproval = await persistentAgentsClient.CreateAIAgentAsync( + model: model, + options: new() + { + Name = "MicrosoftLearnAgentWithApproval", + Instructions = "You answer questions by searching the Microsoft Learn content only.", + ChatOptions = new() + { + Tools = [mcpToolWithApproval] + }, + }); + +// You can then invoke the agent like any other AIAgent. +var threadWithRequiredApproval = agentWithRequiredApproval.GetNewThread(); +var response = await agentWithRequiredApproval.RunAsync("Please summarize the Azure AI Agent documentation related to MCP Tool calling?", threadWithRequiredApproval); +var userInputRequests = response.UserInputRequests.ToList(); + +while (userInputRequests.Count > 0) +{ + // Ask the user to approve each MCP call request. + // For simplicity, we are assuming here that only MCP approval requests are being made. + var userInputResponses = userInputRequests + .OfType() + .Select(approvalRequest => + { + Console.WriteLine($""" + The agent would like to invoke the following MCP Tool, please reply Y to approve. + ServerName: {approvalRequest.ToolCall.ServerName} + Name: {approvalRequest.ToolCall.ToolName} + Arguments: {string.Join(", ", approvalRequest.ToolCall.Arguments?.Select(x => $"{x.Key}: {x.Value}") ?? [])} + """); + return new ChatMessage(ChatRole.User, [approvalRequest.CreateResponse(Console.ReadLine()?.Equals("Y", StringComparison.OrdinalIgnoreCase) ?? false)]); + }) + .ToList(); + + // Pass the user input responses back to the agent for further processing. + response = await agentWithRequiredApproval.RunAsync(userInputResponses, threadWithRequiredApproval); + + userInputRequests = response.UserInputRequests.ToList(); +} + +Console.WriteLine($"\nAgent: {response}"); diff --git a/dotnet/samples/GettingStarted/ModelContextProtocol/README.md b/dotnet/samples/GettingStarted/ModelContextProtocol/README.md index be84bff51f..874afa28b8 100644 --- a/dotnet/samples/GettingStarted/ModelContextProtocol/README.md +++ b/dotnet/samples/GettingStarted/ModelContextProtocol/README.md @@ -21,6 +21,7 @@ Before you begin, ensure you have the following prerequisites: |---|---| |[Agent with MCP server tools](./Agent_MCP_Server/)|This sample demonstrates how to use MCP server tools with a simple agent| |[Agent with MCP server tools and authorization](./Agent_MCP_Server_Auth/)|This sample demonstrates how to use MCP Server tools from a protected MCP server with a simple agent| +|[Responses Agent with Hosted MCP tool](./ResponseAgent_Hosted_MCP/)|This sample demonstrates how to use the Hosted MCP tool with the Responses Service, where the service invokes any MCP tools directly| ## Running the samples from the console diff --git a/dotnet/samples/GettingStarted/ModelContextProtocol/ResponseAgent_Hosted_MCP/Program.cs b/dotnet/samples/GettingStarted/ModelContextProtocol/ResponseAgent_Hosted_MCP/Program.cs new file mode 100644 index 0000000000..19793e64df --- /dev/null +++ b/dotnet/samples/GettingStarted/ModelContextProtocol/ResponseAgent_Hosted_MCP/Program.cs @@ -0,0 +1,95 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample shows how to create and use a simple AI agent with OpenAI Responses as the backend, that uses a Hosted MCP Tool. +// In this case the OpenAI responses service will invoke any MCP tools as required. MCP tools are not invoked by the Agent Framework. +// The sample first shows how to use MCP tools with auto approval, and then how to set up a tool that requires approval before it can be invoked and how to approve such a tool. + +using Azure.AI.OpenAI; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using OpenAI; + +var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); +var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; + +// **** MCP Tool with Auto Approval **** +// ************************************* + +// Create an MCP tool definition that the agent can use. +// In this case we allow the tool to always be called without approval. +var mcpTool = new HostedMcpServerTool( + serverName: "microsoft_learn", + serverAddress: "https://learn.microsoft.com/api/mcp") +{ + AllowedTools = ["microsoft_docs_search"], + ApprovalMode = HostedMcpServerToolApprovalMode.NeverRequire +}; + +// Create an agent based on Azure OpenAI Responses as the backend. +AIAgent agent = new AzureOpenAIClient( + new Uri(endpoint), + new AzureCliCredential()) + .GetOpenAIResponseClient(deploymentName) + .CreateAIAgent( + instructions: "You answer questions by searching the Microsoft Learn content only.", + name: "MicrosoftLearnAgent", + tools: [mcpTool]); + +// You can then invoke the agent like any other AIAgent. +AgentThread thread = agent.GetNewThread(); +Console.WriteLine(await agent.RunAsync("Please summarize the Azure AI Agent documentation related to MCP Tool calling?", thread)); + +// **** MCP Tool with Approval Required **** +// ***************************************** + +// Create an MCP tool definition that the agent can use. +// In this case we require approval before the tool can be called. +var mcpToolWithApproval = new HostedMcpServerTool( + serverName: "microsoft_learn", + serverAddress: "https://learn.microsoft.com/api/mcp") +{ + AllowedTools = ["microsoft_docs_search"], + ApprovalMode = HostedMcpServerToolApprovalMode.AlwaysRequire +}; + +// Create an agent based on Azure OpenAI Responses as the backend. +AIAgent agentWithRequiredApproval = new AzureOpenAIClient( + new Uri(endpoint), + new AzureCliCredential()) + .GetOpenAIResponseClient(deploymentName) + .CreateAIAgent( + instructions: "You answer questions by searching the Microsoft Learn content only.", + name: "MicrosoftLearnAgentWithApproval", + tools: [mcpToolWithApproval]); + +// You can then invoke the agent like any other AIAgent. +var threadWithRequiredApproval = agentWithRequiredApproval.GetNewThread(); +var response = await agentWithRequiredApproval.RunAsync("Please summarize the Azure AI Agent documentation related to MCP Tool calling?", threadWithRequiredApproval); +var userInputRequests = response.UserInputRequests.ToList(); + +while (userInputRequests.Count > 0) +{ + // Ask the user to approve each MCP call request. + // For simplicity, we are assuming here that only MCP approval requests are being made. + var userInputResponses = userInputRequests + .OfType() + .Select(approvalRequest => + { + Console.WriteLine($""" + The agent would like to invoke the following MCP Tool, please reply Y to approve. + ServerName: {approvalRequest.ToolCall.ServerName} + Name: {approvalRequest.ToolCall.ToolName} + Arguments: {string.Join(", ", approvalRequest.ToolCall.Arguments?.Select(x => $"{x.Key}: {x.Value}") ?? [])} + """); + return new ChatMessage(ChatRole.User, [approvalRequest.CreateResponse(Console.ReadLine()?.Equals("Y", StringComparison.OrdinalIgnoreCase) ?? false)]); + }) + .ToList(); + + // Pass the user input responses back to the agent for further processing. + response = await agentWithRequiredApproval.RunAsync(userInputResponses, threadWithRequiredApproval); + + userInputRequests = response.UserInputRequests.ToList(); +} + +Console.WriteLine($"\nAgent: {response}"); diff --git a/dotnet/samples/GettingStarted/ModelContextProtocol/ResponseAgent_Hosted_MCP/README.md b/dotnet/samples/GettingStarted/ModelContextProtocol/ResponseAgent_Hosted_MCP/README.md new file mode 100644 index 0000000000..f84bd8f1b4 --- /dev/null +++ b/dotnet/samples/GettingStarted/ModelContextProtocol/ResponseAgent_Hosted_MCP/README.md @@ -0,0 +1,17 @@ +# Prerequisites + +Before you begin, ensure you have the following prerequisites: + +- .NET 8.0 SDK or later +- Azure OpenAI service endpoint and deployment configured +- Azure CLI installed and authenticated (for Azure credential authentication) +- User has the `Cognitive Services OpenAI Contributor` role for the Azure OpenAI resource. + +**Note**: This demo uses Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Azure OpenAI resource. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively). + +Set the following environment variables: + +```powershell +$env:AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/" # Replace with your Azure OpenAI resource endpoint +$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4.1-mini" # Optional, defaults to gpt-4.1-mini +``` diff --git a/dotnet/samples/GettingStarted/ModelContextProtocol/ResponseAgent_Hosted_MCP/ResponseAgent_Hosted_MCP.csproj b/dotnet/samples/GettingStarted/ModelContextProtocol/ResponseAgent_Hosted_MCP/ResponseAgent_Hosted_MCP.csproj new file mode 100644 index 0000000000..0eacdab258 --- /dev/null +++ b/dotnet/samples/GettingStarted/ModelContextProtocol/ResponseAgent_Hosted_MCP/ResponseAgent_Hosted_MCP.csproj @@ -0,0 +1,20 @@ + + + + Exe + net9.0 + + enable + enable + + + + + + + + + + + + From ada5b83c8087ad0d11d5d4bdd7b444c9f39c4ff3 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Tue, 4 Nov 2025 16:24:26 +0000 Subject: [PATCH 11/15] .NET: Add rag samples with sample TextSearchStore (#1664) * Port store for adding text to a vector store to AF * Fix typo. * Change TextSearchStore to sample, and add sample to use it and do rag with a custom schema * Add more tests and fix broken ones * Fix merge issue * Fix sample after merge. * Convert TextSearchStore to use Dynamic mode to be AOT compatible. * Add some more clarification on when to use assistant messages in rag searches. --- dotnet/Directory.Packages.props | 2 + dotnet/agent-framework-dotnet.slnx | 5 + .../Catalog/AgentWithTextSearchRag/Program.cs | 18 +- .../AgentWithRAG_Step01_BasicTextRAG.csproj | 22 + .../Program.cs | 107 +++++ .../TextSearchStore/TextSearchDocument.cs | 51 +++ .../TextSearchStore/TextSearchStore.cs | 392 ++++++++++++++++++ .../TextSearchStore/TextSearchStoreOptions.cs | 140 +++++++ .../TextSearchStoreUpsertOptions.cs | 17 + ...ithRAG_Step02_ExternalDataSourceRAG.csproj | 22 + .../Program.cs | 134 ++++++ .../README.md | 60 +++ .../GettingStarted/AgentWithRAG/README.md | 8 + .../Agent_Step18_TextSearchRag/Program.cs | 22 +- .../Data/TextSearchProvider.cs | 28 +- .../Data/TextSearchProviderOptions.cs | 20 + .../Microsoft.Agents.AI.csproj | 2 + .../Data/TextSearchProviderTests.cs | 71 +++- 18 files changed, 1079 insertions(+), 42 deletions(-) create mode 100644 dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step01_BasicTextRAG/AgentWithRAG_Step01_BasicTextRAG.csproj create mode 100644 dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step01_BasicTextRAG/Program.cs create mode 100644 dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step01_BasicTextRAG/TextSearchStore/TextSearchDocument.cs create mode 100644 dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step01_BasicTextRAG/TextSearchStore/TextSearchStore.cs create mode 100644 dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step01_BasicTextRAG/TextSearchStore/TextSearchStoreOptions.cs create mode 100644 dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step01_BasicTextRAG/TextSearchStore/TextSearchStoreUpsertOptions.cs create mode 100644 dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step02_ExternalDataSourceRAG/AgentWithRAG_Step02_ExternalDataSourceRAG.csproj create mode 100644 dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step02_ExternalDataSourceRAG/Program.cs create mode 100644 dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step02_ExternalDataSourceRAG/README.md create mode 100644 dotnet/samples/GettingStarted/AgentWithRAG/README.md diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index 08532904c4..69d3e03d31 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -65,9 +65,11 @@ + + diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 1bfb5814f9..de8aef42fc 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -67,6 +67,11 @@ + + + + + diff --git a/dotnet/samples/Catalog/AgentWithTextSearchRag/Program.cs b/dotnet/samples/Catalog/AgentWithTextSearchRag/Program.cs index 931ce50014..3e86534edd 100644 --- a/dotnet/samples/Catalog/AgentWithTextSearchRag/Program.cs +++ b/dotnet/samples/Catalog/AgentWithTextSearchRag/Program.cs @@ -52,9 +52,9 @@ static Task> MockSearchAsync(st { results.Add(new() { - Name = "Contoso Outdoors Return Policy", - Link = "https://contoso.com/policies/returns", - Value = "Customers may return any item within 30 days of delivery. Items should be unused and include original packaging. Refunds are issued to the original payment method within 5 business days of inspection." + SourceName = "Contoso Outdoors Return Policy", + SourceLink = "https://contoso.com/policies/returns", + Text = "Customers may return any item within 30 days of delivery. Items should be unused and include original packaging. Refunds are issued to the original payment method within 5 business days of inspection." }); } @@ -62,9 +62,9 @@ static Task> MockSearchAsync(st { results.Add(new() { - Name = "Contoso Outdoors Shipping Guide", - Link = "https://contoso.com/help/shipping", - Value = "Standard shipping is free on orders over $50 and typically arrives in 3-5 business days within the continental United States. Expedited options are available at checkout." + SourceName = "Contoso Outdoors Shipping Guide", + SourceLink = "https://contoso.com/help/shipping", + Text = "Standard shipping is free on orders over $50 and typically arrives in 3-5 business days within the continental United States. Expedited options are available at checkout." }); } @@ -72,9 +72,9 @@ static Task> MockSearchAsync(st { results.Add(new() { - Name = "TrailRunner Tent Care Instructions", - Link = "https://contoso.com/manuals/trailrunner-tent", - Value = "Clean the tent fabric with lukewarm water and a non-detergent soap. Allow it to air dry completely before storage and avoid prolonged UV exposure to extend the lifespan of the waterproof coating." + SourceName = "TrailRunner Tent Care Instructions", + SourceLink = "https://contoso.com/manuals/trailrunner-tent", + Text = "Clean the tent fabric with lukewarm water and a non-detergent soap. Allow it to air dry completely before storage and avoid prolonged UV exposure to extend the lifespan of the waterproof coating." }); } diff --git a/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step01_BasicTextRAG/AgentWithRAG_Step01_BasicTextRAG.csproj b/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step01_BasicTextRAG/AgentWithRAG_Step01_BasicTextRAG.csproj new file mode 100644 index 0000000000..0c8a9f2dfc --- /dev/null +++ b/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step01_BasicTextRAG/AgentWithRAG_Step01_BasicTextRAG.csproj @@ -0,0 +1,22 @@ + + + + Exe + net9.0 + + enable + enable + + + + + + + + + + + + + + diff --git a/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step01_BasicTextRAG/Program.cs b/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step01_BasicTextRAG/Program.cs new file mode 100644 index 0000000000..611e11c22c --- /dev/null +++ b/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step01_BasicTextRAG/Program.cs @@ -0,0 +1,107 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample shows how to use TextSearchProvider to add retrieval augmented generation (RAG) capabilities to an AI agent. +// The sample uses an In-Memory vector store, which can easily be replaced with any other vector store that implements the Microsoft.Extensions.VectorData abstractions. +// The TextSearchProvider runs a search against the vector store via the TextSearchStore before each model invocation and injects the results into the model context. +// The TextSearchStore is a sample store implementation that hardcodes a storage schema and uses the vector store to store and retrieve documents. + +using Azure.AI.OpenAI; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Data; +using Microsoft.Agents.AI.Samples; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.VectorData; +using Microsoft.SemanticKernel.Connectors.InMemory; +using OpenAI; + +var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); +var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +var embeddingDeploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME") ?? "text-embedding-3-large"; + +AzureOpenAIClient azureOpenAIClient = new( + new Uri(endpoint), + new AzureCliCredential()); + +// Create an In-Memory vector store that uses the Azure OpenAI embedding model to generate embeddings. +VectorStore vectorStore = new InMemoryVectorStore(new() +{ + EmbeddingGenerator = azureOpenAIClient.GetEmbeddingClient(embeddingDeploymentName).AsIEmbeddingGenerator() +}); + +// Create a store that defines a storage schema, and uses the vector store to store and retrieve documents. +TextSearchStore textSearchStore = new(vectorStore, "product-and-policy-info", 3072); + +// Upload sample documents into the store. +await textSearchStore.UpsertDocumentsAsync(GetSampleDocuments()); + +// Create an adapter function that the TextSearchProvider can use to run searches against the TextSearchStore. +Func>> SearchAdapter = async (text, ct) => +{ + // Here we are limiting the search results to the single top result to demonstrate that we are accurately matching + // specific search results for each question, but in a real world case, more results should be used. + var searchResults = await textSearchStore.SearchAsync(text, 1, ct); + return searchResults.Select(r => new TextSearchProvider.TextSearchResult + { + SourceName = r.SourceName, + SourceLink = r.SourceLink, + Text = r.Text ?? string.Empty, + RawRepresentation = r + }); +}; + +// Configure the options for the TextSearchProvider. +TextSearchProviderOptions textSearchOptions = new() +{ + // Run the search prior to every model invocation. + SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke, +}; + +// Create the AI agent with the TextSearchProvider as the AI context provider. +AIAgent agent = azureOpenAIClient + .GetChatClient(deploymentName) + .CreateAIAgent(new ChatClientAgentOptions + { + 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 => ctx.SerializedState.ValueKind is not System.Text.Json.JsonValueKind.Null and not System.Text.Json.JsonValueKind.Undefined + ? new TextSearchProvider(SearchAdapter, ctx.SerializedState, ctx.JsonSerializerOptions, textSearchOptions) + : new TextSearchProvider(SearchAdapter, textSearchOptions) + }); + +AgentThread thread = agent.GetNewThread(); + +Console.WriteLine(">> Asking about returns\n"); +Console.WriteLine(await agent.RunAsync("Hi! I need help understanding the return policy.", thread)); + +Console.WriteLine("\n>> Asking about shipping\n"); +Console.WriteLine(await agent.RunAsync("How long does standard shipping usually take?", thread)); + +Console.WriteLine("\n>> Asking about product care\n"); +Console.WriteLine(await agent.RunAsync("What is the best way to maintain the TrailRunner tent fabric?", thread)); + +// Produces some sample search documents. +// Each one contains a source name and link, which the agent can use to cite sources in its responses. +static IEnumerable GetSampleDocuments() +{ + yield return new TextSearchDocument + { + SourceId = "return-policy-001", + SourceName = "Contoso Outdoors Return Policy", + SourceLink = "https://contoso.com/policies/returns", + Text = "Customers may return any item within 30 days of delivery. Items should be unused and include original packaging. Refunds are issued to the original payment method within 5 business days of inspection." + }; + yield return new TextSearchDocument + { + SourceId = "shipping-guide-001", + SourceName = "Contoso Outdoors Shipping Guide", + SourceLink = "https://contoso.com/help/shipping", + Text = "Standard shipping is free on orders over $50 and typically arrives in 3-5 business days within the continental United States. Expedited options are available at checkout." + }; + yield return new TextSearchDocument + { + SourceId = "tent-care-001", + SourceName = "TrailRunner Tent Care Instructions", + SourceLink = "https://contoso.com/manuals/trailrunner-tent", + Text = "Clean the tent fabric with lukewarm water and a non-detergent soap. Allow it to air dry completely before storage and avoid prolonged UV exposure to extend the lifespan of the waterproof coating." + }; +} diff --git a/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step01_BasicTextRAG/TextSearchStore/TextSearchDocument.cs b/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step01_BasicTextRAG/TextSearchStore/TextSearchDocument.cs new file mode 100644 index 0000000000..773d3ff6f3 --- /dev/null +++ b/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step01_BasicTextRAG/TextSearchStore/TextSearchDocument.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Samples; + +/// +/// Represents a document that can be used for Retrieval Augmented Generation (RAG) that stores textual data. +/// +public sealed class TextSearchDocument +{ + /// + /// Gets or sets an optional list of namespaces that the document should belong to. + /// + /// + /// A namespace is a logical grouping of documents, e.g. may include a group id to scope the document to a specific group of users. + /// + public IList Namespaces { get; set; } = []; + + /// + /// Gets or sets the content as text. + /// + public string? Text { get; set; } + + /// + /// Gets or sets an optional source ID for the document. + /// + /// + /// This ID should be unique within the collection that the document is stored in, and can + /// be used to map back to the source artifact for this document. + /// If updates need to be made later or the source document was deleted and this document + /// also needs to be deleted, this id can be used to find the document again. + /// + public string? SourceId { get; set; } + + /// + /// Gets or sets an optional name for the source document. + /// + /// + /// This can be used to provide display names for citation links when the document is referenced as + /// part of a response to a query. + /// + public string? SourceName { get; set; } + + /// + /// Gets or sets an optional link back to the source of the document. + /// + /// + /// This can be used to provide citation links when the document is referenced as + /// part of a response to a query. + /// + public string? SourceLink { get; set; } +} diff --git a/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step01_BasicTextRAG/TextSearchStore/TextSearchStore.cs b/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step01_BasicTextRAG/TextSearchStore/TextSearchStore.cs new file mode 100644 index 0000000000..502c17dba1 --- /dev/null +++ b/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step01_BasicTextRAG/TextSearchStore/TextSearchStore.cs @@ -0,0 +1,392 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Linq.Expressions; +using System.Text.RegularExpressions; +using Microsoft.Extensions.VectorData; + +namespace Microsoft.Agents.AI.Samples; + +/// +/// A class that allows for easy storage and retrieval of documents in a Vector Store for Retrieval Augmented Generation (RAG). +/// +/// +/// +/// This class provides an opinionated schema for storing documents in a vector store. It is valuable for simple scenarios +/// where you want to store text + embedding, or a reference to an external document + embedding without needing to customize the schema. +/// If you want to control the schema yourself, use an implementation of directly instead. +/// +/// +/// This class and its related types are currently provided as a sample implementation, but may be promoted to a first-class supported API in future releases. +/// +/// +public sealed partial class TextSearchStore : IDisposable +{ +#if NET + [GeneratedRegex(@"\p{L}+", RegexOptions.IgnoreCase, "en-US")] + private static partial Regex AnyLanguageWordRegex(); + + private static readonly Func> s_defaultWordSegmenter = text => AnyLanguageWordRegex().Matches(text).Select(x => x.Value).ToList(); +#else + private static readonly Regex s_anyLanguageWordRegex = new(@"\p{L}+", RegexOptions.Compiled); + private static Regex AnyLanguageWordRegex() => s_anyLanguageWordRegex; + + private static readonly Func> s_defaultWordSegmenter = text => + { + List words = new(); + foreach (Match word in AnyLanguageWordRegex().Matches(text)) + { + words.Add(word.Value); + } + return words; + }; +#endif + + private readonly VectorStore _vectorStore; + private readonly TextSearchStoreOptions _options; + private readonly Func> _wordSegmenter; + + private readonly VectorStoreCollection> _vectorStoreRecordCollection; + private readonly SemaphoreSlim _collectionInitializationLock = new(1, 1); + private bool _collectionInitialized; + private bool _disposedValue; + + /// + /// Initializes a new instance of the class. + /// + /// The vector store to store and read the memories from. + /// The name of the collection in the vector store to store and read the memories from. + /// The number of dimensions to use for the memory embeddings. + /// Options to configure the behavior of this class. + /// Thrown if the key type provided is not supported. + public TextSearchStore( + VectorStore vectorStore, + string collectionName, + int vectorDimensions, + TextSearchStoreOptions? options = default) + { + // Verify + if (vectorStore is null) + { + throw new ArgumentNullException(nameof(vectorStore)); + } + + if (string.IsNullOrWhiteSpace(collectionName)) + { + throw new ArgumentException("Collection name cannot be null or whitespace.", nameof(collectionName)); + } + + if (vectorDimensions < 1) + { + throw new ArgumentOutOfRangeException(nameof(vectorDimensions), "Vector dimensions must be greater than zero."); + } + + if (options?.KeyType is not null && options.KeyType != typeof(string) && options.KeyType != typeof(Guid)) + { + throw new NotSupportedException($"Unsupported key of type '{options.KeyType.Name}'"); + } + + if (options?.KeyType is not null && options.KeyType != typeof(string) && options?.UseSourceIdAsPrimaryKey is true) + { + throw new NotSupportedException($"The {nameof(TextSearchStoreOptions.UseSourceIdAsPrimaryKey)} option can only be used when the key type is 'string'."); + } + + // Assign + this._vectorStore = vectorStore; + this._options = options ?? new TextSearchStoreOptions(); + this._wordSegmenter = this._options.WordSegmenter ?? s_defaultWordSegmenter; + + // Create a definition so that we can use the dimensions provided at runtime. + VectorStoreCollectionDefinition ragDocumentDefinition = new() + { + Properties = new List() + { + new VectorStoreKeyProperty("Key", this._options.KeyType ?? typeof(string)), + new VectorStoreDataProperty("Namespaces", typeof(List)) { IsIndexed = true }, + new VectorStoreDataProperty("SourceId", typeof(string)) { IsIndexed = true }, + new VectorStoreDataProperty("Text", typeof(string)) { IsFullTextIndexed = true }, + new VectorStoreDataProperty("SourceName", typeof(string)), + new VectorStoreDataProperty("SourceLink", typeof(string)), + new VectorStoreVectorProperty("TextEmbedding", typeof(string), vectorDimensions), + } + }; + + this._vectorStoreRecordCollection = this._vectorStore.GetDynamicCollection(collectionName, ragDocumentDefinition); + } + + /// + /// Upserts a batch of text chunks into the vector store. + /// + /// The text chunks to upload. + /// The to monitor for cancellation requests. The default is . + /// A task that completes when the documents have been upserted. + public async Task UpsertTextAsync(IEnumerable textChunks, CancellationToken cancellationToken = default) + { + if (textChunks == null) + { + throw new ArgumentNullException(nameof(textChunks)); + } + + var vectorStoreRecordCollection = await this.EnsureCollectionExistsAsync(cancellationToken).ConfigureAwait(false); + + var storageDocuments = textChunks.Select(textChunk => + { + // Without text we cannot generate a vector. + if (string.IsNullOrWhiteSpace(textChunk)) + { + throw new ArgumentException("One of the provided text chunks is null.", nameof(textChunks)); + } + + return new Dictionary + { + { "Key", this.GenerateUniqueKey(null) }, + { "Namespaces", new List() }, + { "Text", textChunk }, + { "TextEmbedding", textChunk }, + }; + }); + + await vectorStoreRecordCollection.UpsertAsync(storageDocuments, cancellationToken).ConfigureAwait(false); + } + + /// + /// Upserts a batch of documents into the vector store. + /// + /// The documents to upload. + /// Optional options to control the upsert behavior. + /// The to monitor for cancellation requests. The default is . + /// A task that completes when the documents have been upserted. + public async Task UpsertDocumentsAsync(IEnumerable documents, TextSearchStoreUpsertOptions? options = null, CancellationToken cancellationToken = default) + { + if (documents is null) + { + throw new ArgumentNullException(nameof(documents)); + } + + var vectorStoreRecordCollection = await this.EnsureCollectionExistsAsync(cancellationToken).ConfigureAwait(false); + + var storageDocuments = documents.Select(document => + { + if (document is null) + { + throw new ArgumentNullException(nameof(documents), "One of the provided documents is null."); + } + + // Without text we cannot generate a vector. + if (string.IsNullOrWhiteSpace(document.Text)) + { + throw new ArgumentException($"The {nameof(TextSearchDocument.Text)} property must be set.", nameof(document)); + } + + // If we aren't persisting the text, we need a source id or link to refer back to the original document. + if (options?.DoNotPersistSourceText is true && string.IsNullOrWhiteSpace(document.SourceId) && string.IsNullOrWhiteSpace(document.SourceLink)) + { + throw new ArgumentException($"Either the {nameof(TextSearchDocument.SourceId)} or {nameof(TextSearchDocument.SourceLink)} properties must be set when the {nameof(TextSearchStoreUpsertOptions.DoNotPersistSourceText)} setting is true.", nameof(document)); + } + + var key = this.GenerateUniqueKey(this._options.UseSourceIdAsPrimaryKey ?? false ? document.SourceId : null); + + return new Dictionary() + { + { "Key", key }, + { "Namespaces", document.Namespaces.ToList() }, + { "SourceId", document.SourceId }, + { "Text", options?.DoNotPersistSourceText is true ? null : document.Text }, + { "SourceName", document.SourceName }, + { "SourceLink", document.SourceLink }, + { "TextEmbedding", document.Text }, + }; + }); + + await vectorStoreRecordCollection.UpsertAsync(storageDocuments, cancellationToken).ConfigureAwait(false); + } + + /// + /// Search the database for documents similar to the provided query. + /// + /// The text query to find similar documents to. + /// The maximum number of results to return. + /// The to monitor for cancellation requests. The default is . + /// The search results. + public async Task> SearchAsync(string query, int top, CancellationToken cancellationToken = default) + { + var searchResult = await this.SearchCoreAsync(query, top, cancellationToken).ConfigureAwait(false); + + return searchResult.Select(x => new TextSearchDocument() + { + Namespaces = (List)x["Namespaces"]!, + Text = (string?)x["Text"], + SourceId = (string?)x["SourceId"], + SourceName = (string?)x["SourceName"], + SourceLink = (string?)x["SourceLink"], + }); + } + + /// + /// Internal search implementation with hydration of id / link only storage. + /// + /// The text query to find similar documents to. + /// The maximum number of results to return. + /// The to monitor for cancellation requests. The default is . + /// The search results. + private async Task>> SearchCoreAsync(string query, int top, CancellationToken cancellationToken = default) + { + // Short circuit if the query is empty. + if (string.IsNullOrWhiteSpace(query)) + { + return []; + } + + var vectorStoreRecordCollection = await this.EnsureCollectionExistsAsync(cancellationToken).ConfigureAwait(false); + + // If the user has not opted out of hybrid search, check if the vector store supports it. + var hybridSearchCollection = this._options.UseHybridSearch ?? true ? + vectorStoreRecordCollection.GetService(typeof(IKeywordHybridSearchable>)) as IKeywordHybridSearchable> : + null; + + // Optional filter to limit the search to a specific namespace. + Expression, bool>>? filter = string.IsNullOrWhiteSpace(this._options.SearchNamespace) ? null : x => ((List)x["Namespaces"]!).Contains(this._options.SearchNamespace); + + // Execute a hybrid search if possible, otherwise perform a regular vector search. + var searchResult = hybridSearchCollection is null + ? vectorStoreRecordCollection.SearchAsync( + query, + top, + options: new() + { + Filter = filter, + }, + cancellationToken: cancellationToken) + : hybridSearchCollection.HybridSearchAsync( + query, + this._wordSegmenter(query), + top, + options: new() + { + Filter = filter, + }, + cancellationToken: cancellationToken); + + // Retrieve the documents from the search results. + List> searchResponseDocs = new(); + await foreach (var searchResponseDoc in searchResult.WithCancellation(cancellationToken).ConfigureAwait(false)) + { + searchResponseDocs.Add(searchResponseDoc.Record); + } + + // Find any source ids and links for which the text needs to be retrieved. + var sourceIdsToRetrieve = searchResponseDocs + .Where(x => string.IsNullOrWhiteSpace((string?)x["Text"])) + .Select(x => new TextSearchStoreOptions.SourceRetrievalRequest((string?)x["SourceId"], (string?)x["SourceLink"])) + .ToList(); + + // If we have none, we can return early. + if (sourceIdsToRetrieve.Count == 0) + { + return searchResponseDocs; + } + + if (this._options.SourceRetrievalCallback is null) + { + throw new InvalidOperationException($"The {nameof(TextSearchStoreOptions.SourceRetrievalCallback)} option must be set if retrieving documents without stored text."); + } + + // Retrieve the source text for the documents that need it. + var retrievalResponses = await this._options.SourceRetrievalCallback(sourceIdsToRetrieve).ConfigureAwait(false); + + if (retrievalResponses is null) + { + throw new InvalidOperationException($"The {nameof(TextSearchStoreOptions.SourceRetrievalCallback)} must return a non-null value."); + } + + // Update the retrieved documents with the retrieved text. + return searchResponseDocs.GroupJoin( + retrievalResponses, + searchResponseDoc => (searchResponseDoc["SourceId"], searchResponseDoc["SourceLink"]), + retrievalResponse => (retrievalResponse.SourceId, retrievalResponse.SourceLink), + (searchResponseDoc, textRetrievalResponse) => (searchResponseDoc, textRetrievalResponse)) + .SelectMany( + joinedSet => joinedSet.textRetrievalResponse.DefaultIfEmpty(), + (combined, textRetrievalResponse) => + { + combined.searchResponseDoc["Text"] = textRetrievalResponse?.Text ?? combined.searchResponseDoc["Text"]; + return combined.searchResponseDoc; + }); + } + + /// + /// Thread safe method to get the collection and ensure that it is created at least once. + /// + /// The to monitor for cancellation requests. The default is . + /// The created collection. + private async Task>> EnsureCollectionExistsAsync(CancellationToken cancellationToken) + { + // Return immediately if the collection is already created, no need to do any locking in this case. + if (this._collectionInitialized) + { + return this._vectorStoreRecordCollection; + } + + // Wait on a lock to ensure that only one thread can create the collection. + await this._collectionInitializationLock.WaitAsync(cancellationToken).ConfigureAwait(false); + + // If multiple threads waited on the lock, and the first already created the collection, + // we can return immediately without doing any work in subsequent threads. + if (this._collectionInitialized) + { + this._collectionInitializationLock.Release(); + return this._vectorStoreRecordCollection; + } + + // Only the winning thread should reach this point and create the collection. + try + { + await this._vectorStoreRecordCollection.EnsureCollectionExistsAsync(cancellationToken).ConfigureAwait(false); + this._collectionInitialized = true; + } + finally + { + this._collectionInitializationLock.Release(); + } + + return this._vectorStoreRecordCollection; + } + + /// + /// Generates a unique key for the RAG document. + /// + /// Source id of the source document for this RAG document. + /// A new unique key. + /// Thrown if the requested key type is not supported. + private object GenerateUniqueKey(string? sourceId) + => this._options.KeyType switch + { + _ when (this._options.KeyType == null || this._options.KeyType == typeof(string)) && !string.IsNullOrWhiteSpace(sourceId) => sourceId!, + _ when this._options.KeyType == null || this._options.KeyType == typeof(string) => Guid.NewGuid().ToString(), + _ when this._options.KeyType == typeof(Guid) => Guid.NewGuid(), + + _ => throw new NotSupportedException($"Unsupported key of type '{this._options.KeyType.Name}'") + }; + + /// + private void Dispose(bool disposing) + { + if (!this._disposedValue) + { + if (disposing) + { + this._vectorStoreRecordCollection.Dispose(); + this._collectionInitializationLock.Dispose(); + } + + this._disposedValue = true; + } + } + + /// + public void Dispose() + { + // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method + this.Dispose(disposing: true); + GC.SuppressFinalize(this); + } +} diff --git a/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step01_BasicTextRAG/TextSearchStore/TextSearchStoreOptions.cs b/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step01_BasicTextRAG/TextSearchStore/TextSearchStoreOptions.cs new file mode 100644 index 0000000000..53da092c82 --- /dev/null +++ b/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step01_BasicTextRAG/TextSearchStore/TextSearchStoreOptions.cs @@ -0,0 +1,140 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Samples; + +/// +/// Contains options for the . +/// +public sealed class TextSearchStoreOptions +{ + /// + /// Gets or sets an optional namespace to pre-filter the possible + /// records with when doing a vector search. + /// + public string? SearchNamespace { get; init; } + + /// + /// Gets or sets a value indicating whether to use the source ID as the primary key for records. + /// + /// + /// + /// Using the source ID as the primary key allows for easy updates from the source for any changed + /// records, since those records can just be upserted again, and will overwrite the previous version + /// of the same record. + /// + /// + /// This setting can only be used when the chosen key type is a string. + /// + /// + /// + /// Defaults to false if not set. + /// + public bool? UseSourceIdAsPrimaryKey { get; init; } + + /// + /// Gets or sets a value indicating whether to use hybrid search if it is available for the provided vector store. + /// + /// + /// Defaults to true if not set. + /// + public bool? UseHybridSearch { get; init; } + + /// + /// Gets or sets a word segmenter function to split search text into separate words for the purposes of hybrid search. + /// This will not be used if is set to false. + /// + /// + /// Defaults to a simple text-character-based segmenter that splits the text by any character that is not a text character. + /// + public Func>? WordSegmenter { get; init; } + + /// + /// Gets or sets the type of key to use for records in the text search store. + /// + /// + /// Make sure to pick a key type that is supported by the underlying vector store. + /// Note that you have to choose when using . + /// + /// Defaults to if not set. Only and is currently supported. + public Type? KeyType { get; init; } + + /// + /// Gets or sets an optional callback to load the source text using the source id or source link + /// if the source text is not persisted in the database. + /// + /// + /// The response should include the source id or source link, as provided in the request, + /// plus the source text loaded from the source. + /// + public Func, Task>>? SourceRetrievalCallback { get; init; } + + /// + /// Represents a request to the . + /// + public sealed class SourceRetrievalRequest + { + /// + /// Initializes a new instance of the class. + /// + /// The source ID of the document to retrieve. + /// The source link of the document to retrieve. + public SourceRetrievalRequest(string? sourceId, string? sourceLink) + { + this.SourceId = sourceId; + this.SourceLink = sourceLink; + } + + /// + /// Gets or sets the source ID of the document to retrieve. + /// + public string? SourceId { get; set; } + + /// + /// Gets or sets the source link of the document to retrieve. + /// + public string? SourceLink { get; set; } + } + + /// + /// Represents a response from the . + /// + public sealed class SourceRetrievalResponse + { + /// + /// Initializes a new instance of the class. + /// + /// The request matching this response. + /// The source text that was retrieved. + public SourceRetrievalResponse(SourceRetrievalRequest request, string text) + { + if (request == null) + { + throw new ArgumentNullException(nameof(request)); + } + + if (text == null) + { + throw new ArgumentNullException(nameof(text)); + } + + this.SourceId = request.SourceId; + this.SourceLink = request.SourceLink; + this.Text = text; + } + + /// + /// Gets or sets the source ID of the document that was retrieved. + /// + public string? SourceId { get; set; } + + /// + /// Gets or sets the source link of the document that was retrieved. + /// + public string? SourceLink { get; set; } + + /// + /// Gets or sets the source text of the document that was retrieved. + /// + public string Text { get; set; } + } +} diff --git a/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step01_BasicTextRAG/TextSearchStore/TextSearchStoreUpsertOptions.cs b/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step01_BasicTextRAG/TextSearchStore/TextSearchStoreUpsertOptions.cs new file mode 100644 index 0000000000..127d7de01e --- /dev/null +++ b/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step01_BasicTextRAG/TextSearchStore/TextSearchStoreUpsertOptions.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Samples; + +/// +/// Contains options for . +/// +public sealed class TextSearchStoreUpsertOptions +{ + /// + /// Gets or sets a value indicating whether the source text should be persisted in the database. + /// + /// + /// Defaults to if not set. + /// + public bool DoNotPersistSourceText { get; init; } +} diff --git a/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step02_ExternalDataSourceRAG/AgentWithRAG_Step02_ExternalDataSourceRAG.csproj b/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step02_ExternalDataSourceRAG/AgentWithRAG_Step02_ExternalDataSourceRAG.csproj new file mode 100644 index 0000000000..56e2ad232b --- /dev/null +++ b/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step02_ExternalDataSourceRAG/AgentWithRAG_Step02_ExternalDataSourceRAG.csproj @@ -0,0 +1,22 @@ + + + + Exe + net9.0 + + enable + enable + + + + + + + + + + + + + + diff --git a/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step02_ExternalDataSourceRAG/Program.cs b/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step02_ExternalDataSourceRAG/Program.cs new file mode 100644 index 0000000000..e29bb58d04 --- /dev/null +++ b/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step02_ExternalDataSourceRAG/Program.cs @@ -0,0 +1,134 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample shows how to use Qdrant to add retrieval augmented generation (RAG) capabilities to an AI agent. +// While the sample is using Qdrant, it can easily be replaced with any other vector store that implements the Microsoft.Extensions.VectorData abstractions. +// The TextSearchProvider runs a search against the vector store before each model invocation and injects the results into the model context. + +using Azure.AI.OpenAI; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Data; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.VectorData; +using Microsoft.SemanticKernel.Connectors.Qdrant; +using OpenAI; +using Qdrant.Client; + +var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); +var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +var embeddingDeploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME") ?? "text-embedding-3-large"; +var afOverviewUrl = "https://github.com/MicrosoftDocs/semantic-kernel-docs/blob/main/agent-framework/overview/agent-framework-overview.md"; +var afMigrationUrl = "https://raw.githubusercontent.com/MicrosoftDocs/semantic-kernel-docs/refs/heads/main/agent-framework/migration-guide/from-semantic-kernel/index.md"; + +AzureOpenAIClient azureOpenAIClient = new( + new Uri(endpoint), + new AzureCliCredential()); + +// Create a Qdrant vector store that uses the Azure OpenAI embedding model to generate embeddings. +QdrantClient client = new("localhost"); +VectorStore vectorStore = new QdrantVectorStore(client, ownsClient: true, new() +{ + EmbeddingGenerator = azureOpenAIClient.GetEmbeddingClient(embeddingDeploymentName).AsIEmbeddingGenerator() +}); + +// Create a collection and upsert some text into it. +var documentationCollection = vectorStore.GetCollection("documentation"); +await documentationCollection.EnsureCollectionDeletedAsync(); // Clear out any data from previous runs. +await documentationCollection.EnsureCollectionExistsAsync(); +await UploadDataFromMarkdown(afOverviewUrl, "Microsoft Agent Framework Overview", documentationCollection, 2000, 200); +await UploadDataFromMarkdown(afMigrationUrl, "Semantic Kernel to Microsoft Agent Framework Migration Guide", documentationCollection, 2000, 200); + +// Create an adapter function that the TextSearchProvider can use to run searches against the collection. +Func>> SearchAdapter = async (text, ct) => +{ + List results = []; + await foreach (var result in documentationCollection.SearchAsync(text, 5, cancellationToken: ct)) + { + results.Add(new TextSearchProvider.TextSearchResult + { + SourceName = result.Record.SourceName, + SourceLink = result.Record.SourceLink, + Text = result.Record.Text ?? string.Empty, + RawRepresentation = result + }); + } + return results; +}; + +// Configure the options for the TextSearchProvider. +TextSearchProviderOptions textSearchOptions = new() +{ + // Run the search prior to every model invocation. + SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke, + // Use up to 4 recent messages when searching so that searches + // still produce valuable results even when the user is referring + // back to previous messages in their request. + RecentMessageMemoryLimit = 5 +}; + +// Create the AI agent with the TextSearchProvider as the AI context provider. +AIAgent agent = azureOpenAIClient + .GetChatClient(deploymentName) + .CreateAIAgent(new ChatClientAgentOptions + { + Instructions = "You are a helpful support specialist for the Microsoft Agent Framework. Answer questions using the provided context and cite the source document when available. Keep responses brief.", + AIContextProviderFactory = ctx => ctx.SerializedState.ValueKind is not System.Text.Json.JsonValueKind.Null and not System.Text.Json.JsonValueKind.Undefined + ? new TextSearchProvider(SearchAdapter, ctx.SerializedState, ctx.JsonSerializerOptions, textSearchOptions) + : new TextSearchProvider(SearchAdapter, textSearchOptions) + }); + +AgentThread thread = agent.GetNewThread(); + +Console.WriteLine(">> Asking about SK threads\n"); +Console.WriteLine(await agent.RunAsync("Hi! How do I create a thread in Semantic Kernel?", thread)); + +// Here we are asking a very vague question when taken out of context, +// but since we are including previous messages in our search using RecentMessageMemoryLimit +// the RAG search should still produce useful results. +Console.WriteLine("\n>> Asking about AF threads\n"); +Console.WriteLine(await agent.RunAsync("and in Agent Framework?", thread)); + +Console.WriteLine("\n>> Contrasting Approaches\n"); +Console.WriteLine(await agent.RunAsync("Please contrast the two approaches", thread)); + +Console.WriteLine("\n>> Asking about ancestry\n"); +Console.WriteLine(await agent.RunAsync("What are the predecessors to the Agent Framework?", thread)); + +static async Task UploadDataFromMarkdown(string markdownUrl, string sourceName, VectorStoreCollection vectorStoreCollection, int chunkSize, int overlap) +{ + // Download the markdown from the given url. + using HttpClient client = new(); + var markdown = await client.GetStringAsync(new Uri(markdownUrl)); + + // Chunk it into separate parts with some overlap between chunks + var chunks = new List(); + for (int i = 0; i < markdown.Length; i += chunkSize) + { + var chunk = new DocumentationChunk + { + Key = Guid.NewGuid(), + SourceLink = markdownUrl, + SourceName = sourceName, + Text = markdown.Substring(i, Math.Min(chunkSize + overlap, markdown.Length - i)) + }; + chunks.Add(chunk); + } + + // Upsert each chunk into the provided vector store. + await vectorStoreCollection.UpsertAsync(chunks); +} + +// Data model that defines the database schema we want to use. +internal sealed class DocumentationChunk +{ + [VectorStoreKey] + public Guid Key { get; set; } + [VectorStoreData] + public string SourceLink { get; set; } = string.Empty; + [VectorStoreData] + public string SourceName { get; set; } = string.Empty; + [VectorStoreData] + public string Text { get; set; } = string.Empty; + [VectorStoreVector(Dimensions: 3072)] + public string Embedding => this.Text; +} diff --git a/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step02_ExternalDataSourceRAG/README.md b/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step02_ExternalDataSourceRAG/README.md new file mode 100644 index 0000000000..1817f0d8ca --- /dev/null +++ b/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step02_ExternalDataSourceRAG/README.md @@ -0,0 +1,60 @@ +# Agent Framework Retrieval Augmented Generation (RAG) with an external Vector Store with a custom schema + +This sample demonstrates how to create and run an agent that uses Retrieval Augmented Generation (RAG) with an external vector store. +It also uses a custom schema for the documents stored in the vector store. +This sample uses Qdrant for the vector store, but this can easily be swapped out for any vector store that has a Microsoft.Extensions.VectorStore implementation. + +## Prerequisites + +- .NET 8.0 SDK or later +- Azure OpenAI service endpoint +- Both a chat completion and embedding deployment configured in the Azure OpenAI resource +- Azure CLI installed and authenticated (for Azure credential authentication) +- User has the `Cognitive Services OpenAI Contributor` role for the Azure OpenAI resource. +- An existing Qdrant instance. You can use a managed service or run a local instance using Docker, but the sample assumes the instance is running locally. + +**Note**: These samples use Azure OpenAI models. For more information, see [how to deploy Azure OpenAI models with Azure AI Foundry](https://learn.microsoft.com/en-us/azure/ai-foundry/how-to/deploy-models-openai). + +**Note**: These samples use Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Azure OpenAI resource and have the `Cognitive Services OpenAI Contributor` role. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively). + +## Running the sample from the console + +Set the following environment variables: + +```powershell +$env:AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/" # Replace with your Azure OpenAI resource endpoint +$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini +$env:AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME="text-embedding-3-large" # Optional, defaults to text-embedding-3-large +``` + +If the variables are not set, you will be prompted for the values when running the samples. + +To use Qdrant in docker locally, start your Qdrant instance using the default port mappings. + +```powershell +docker run -d --name qdrant -p 6333:6333 -p 6334:6334 qdrant/qdrant:latest +``` + +Execute the following command to build the sample: + +```powershell +dotnet build +``` + +Execute the following command to run the sample: + +```powershell +dotnet run --no-build +``` + +Or just build and run in one step: + +```powershell +dotnet run +``` + +## Running the sample from Visual Studio + +Open the solution in Visual Studio and set the sample project as the startup project. Then, run the project using the built-in debugger or by pressing `F5`. + +You will be prompted for any required environment variables if they are not already set. diff --git a/dotnet/samples/GettingStarted/AgentWithRAG/README.md b/dotnet/samples/GettingStarted/AgentWithRAG/README.md new file mode 100644 index 0000000000..f45c2c2540 --- /dev/null +++ b/dotnet/samples/GettingStarted/AgentWithRAG/README.md @@ -0,0 +1,8 @@ +# Agent Framework Retrieval Augmented Generation (RAG) + +These samples show how to create an agent with the Agent Framework that uses Retrieval Augmented Generation (RAG) to enhance its responses with information from a knowledge base. + +|Sample|Description| +|---|---| +|[Basic Text RAG](./AgentWithRAG_Step01_BasicTextRAG/)|This sample demonstrates how to create and run a basic agent with simple text Retrieval Augmented Generation (RAG).| +|[RAG with external Vector Store and custom schema](./AgentWithRAG_Step02_ExternalDataSourceRAG/)|This sample demonstrates how to create and run an agent that uses Retrieval Augmented Generation (RAG) with an external vector store. It also uses a custom schema for the documents stored in the vector store.| diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step18_TextSearchRag/Program.cs b/dotnet/samples/GettingStarted/Agents/Agent_Step18_TextSearchRag/Program.cs index 931ce50014..81a6b29152 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step18_TextSearchRag/Program.cs +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step18_TextSearchRag/Program.cs @@ -28,7 +28,9 @@ AIAgent agent = new AzureOpenAIClient( .CreateAIAgent(new ChatClientAgentOptions { Instructions = "You are a helpful support specialist for Contoso Outdoors. Answer questions using the provided context and cite the source document when available.", - AIContextProviderFactory = _ => new TextSearchProvider(MockSearchAsync, textSearchOptions) + AIContextProviderFactory = ctx => ctx.SerializedState.ValueKind is not System.Text.Json.JsonValueKind.Null and not System.Text.Json.JsonValueKind.Undefined + ? new TextSearchProvider(MockSearchAsync, ctx.SerializedState, ctx.JsonSerializerOptions, textSearchOptions) + : new TextSearchProvider(MockSearchAsync, textSearchOptions) }); AgentThread thread = agent.GetNewThread(); @@ -52,9 +54,9 @@ static Task> MockSearchAsync(st { results.Add(new() { - Name = "Contoso Outdoors Return Policy", - Link = "https://contoso.com/policies/returns", - Value = "Customers may return any item within 30 days of delivery. Items should be unused and include original packaging. Refunds are issued to the original payment method within 5 business days of inspection." + SourceName = "Contoso Outdoors Return Policy", + SourceLink = "https://contoso.com/policies/returns", + Text = "Customers may return any item within 30 days of delivery. Items should be unused and include original packaging. Refunds are issued to the original payment method within 5 business days of inspection." }); } @@ -62,9 +64,9 @@ static Task> MockSearchAsync(st { results.Add(new() { - Name = "Contoso Outdoors Shipping Guide", - Link = "https://contoso.com/help/shipping", - Value = "Standard shipping is free on orders over $50 and typically arrives in 3-5 business days within the continental United States. Expedited options are available at checkout." + SourceName = "Contoso Outdoors Shipping Guide", + SourceLink = "https://contoso.com/help/shipping", + Text = "Standard shipping is free on orders over $50 and typically arrives in 3-5 business days within the continental United States. Expedited options are available at checkout." }); } @@ -72,9 +74,9 @@ static Task> MockSearchAsync(st { results.Add(new() { - Name = "TrailRunner Tent Care Instructions", - Link = "https://contoso.com/manuals/trailrunner-tent", - Value = "Clean the tent fabric with lukewarm water and a non-detergent soap. Allow it to air dry completely before storage and avoid prolonged UV exposure to extend the lifespan of the waterproof coating." + SourceName = "TrailRunner Tent Care Instructions", + SourceLink = "https://contoso.com/manuals/trailrunner-tent", + Text = "Clean the tent fabric with lukewarm water and a non-detergent soap. Allow it to air dry completely before storage and avoid prolonged UV exposure to extend the lifespan of the waterproof coating." }); } diff --git a/dotnet/src/Microsoft.Agents.AI/Data/TextSearchProvider.cs b/dotnet/src/Microsoft.Agents.AI/Data/TextSearchProvider.cs index c7cffd7c04..6858510312 100644 --- a/dotnet/src/Microsoft.Agents.AI/Data/TextSearchProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Data/TextSearchProvider.cs @@ -45,6 +45,7 @@ public sealed class TextSearchProvider : AIContextProvider private readonly AITool[] _tools; private readonly Queue _recentMessagesText; private readonly TextSearchProviderOptions _options; + private readonly List _recentMessageRolesIncluded; /// /// Initializes a new instance of the class. @@ -60,6 +61,7 @@ public sealed class TextSearchProvider : AIContextProvider Throw.IfLessThan(this._options.RecentMessageMemoryLimit, 0); this._logger = loggerFactory?.CreateLogger(); this._recentMessagesText = new(); + this._recentMessageRolesIncluded = this._options.RecentMessageRolesIncluded ?? [ChatRole.User]; // Create the on-demand search tool (only used if behavior is OnDemandFunctionCalling) this._tools = @@ -91,6 +93,7 @@ public sealed class TextSearchProvider : AIContextProvider this._options = options ?? new(); Throw.IfLessThan(this._options.RecentMessageMemoryLimit, 0); this._logger = loggerFactory?.CreateLogger(); + this._recentMessageRolesIncluded = this._options.RecentMessageRolesIncluded ?? [ChatRole.User]; List? restoredMessages = null; @@ -163,7 +166,7 @@ public sealed class TextSearchProvider : AIContextProvider return new AIContext { - Messages = [new ChatMessage(ChatRole.User, formatted)] + Messages = [new ChatMessage(ChatRole.User, formatted) { AdditionalProperties = new AdditionalPropertiesDictionary() { ["IsTextSearchProviderOutput"] = true } }] }; } @@ -183,7 +186,12 @@ public sealed class TextSearchProvider : AIContextProvider var messagesText = context.RequestMessages .Concat(context.ResponseMessages ?? []) - .Where(m => (m.Role == ChatRole.User || m.Role == ChatRole.Assistant) && !string.IsNullOrWhiteSpace(m.Text)) + .Where(m => + this._recentMessageRolesIncluded.Contains(m.Role) && + !string.IsNullOrWhiteSpace(m.Text) && + // Filter out any messages that were added by this class in InvokingAsync, since we don't want + // a feedback loop where previous search results are used to find new search results. + (m.AdditionalProperties == null || m.AdditionalProperties.TryGetValue("IsTextSearchProviderOutput", out bool isTextSearchProviderOutput) == false || !isTextSearchProviderOutput)) .Select(m => m.Text) .ToList(); if (messagesText.Count > limit) @@ -262,15 +270,15 @@ public sealed class TextSearchProvider : AIContextProvider for (int i = 0; i < results.Count; i++) { var result = results[i]; - if (!string.IsNullOrWhiteSpace(result.Name)) + if (!string.IsNullOrWhiteSpace(result.SourceName)) { - sb.AppendLine($"SourceDocName: {result.Name}"); + sb.AppendLine($"SourceDocName: {result.SourceName}"); } - if (!string.IsNullOrWhiteSpace(result.Link)) + if (!string.IsNullOrWhiteSpace(result.SourceLink)) { - sb.AppendLine($"SourceDocLink: {result.Link}"); + sb.AppendLine($"SourceDocLink: {result.SourceLink}"); } - sb.AppendLine($"Contents: {result.Value}"); + sb.AppendLine($"Contents: {result.Text}"); sb.AppendLine("----"); } sb.AppendLine(this._options.CitationsPrompt ?? DefaultCitationsPrompt); @@ -286,17 +294,17 @@ public sealed class TextSearchProvider : AIContextProvider /// /// Gets or sets the display name of the source document (optional). /// - public string? Name { get; set; } + public string? SourceName { get; set; } /// /// Gets or sets a link/URL to the source document (optional). /// - public string? Link { get; set; } + public string? SourceLink { get; set; } /// /// Gets or sets the textual content of the retrieved chunk. /// - public string Value { get; set; } = string.Empty; + public string Text { get; set; } = string.Empty; /// /// Gets or sets the raw representation of the search result from the data source. diff --git a/dotnet/src/Microsoft.Agents.AI/Data/TextSearchProviderOptions.cs b/dotnet/src/Microsoft.Agents.AI/Data/TextSearchProviderOptions.cs index e6b20ed6b2..7949d7918a 100644 --- a/dotnet/src/Microsoft.Agents.AI/Data/TextSearchProviderOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI/Data/TextSearchProviderOptions.cs @@ -59,6 +59,26 @@ public sealed class TextSearchProviderOptions /// public int RecentMessageMemoryLimit { get; set; } + /// + /// Gets or sets the list of types to filter recent messages to + /// when deciding which recent messages to include when constructing the search input. + /// + /// + /// + /// Depending on your scenario, you may want to use only user messages, only assistant messages, + /// or both. For example, if the assistant may often provide clarifying questions or if the conversation + /// is expected to be particularly chatty, you may want to include assistant messages in the search context as well. + /// + /// + /// Be careful when including assistant messages though, as they may skew the search results towards + /// information that has already been provided by the assistant, rather than focusing on the user's current needs. + /// + /// + /// + /// When not specified, defaults to only . + /// + public List? RecentMessageRolesIncluded { get; set; } + /// /// Behavior choices for the provider. /// diff --git a/dotnet/src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj b/dotnet/src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj index d95b8ea52f..a560dece67 100644 --- a/dotnet/src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj +++ b/dotnet/src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj @@ -19,6 +19,7 @@ + @@ -31,6 +32,7 @@ + diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Data/TextSearchProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Data/TextSearchProviderTests.cs index 44423695eb..66abba6c8b 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Data/TextSearchProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Data/TextSearchProviderTests.cs @@ -41,8 +41,8 @@ public sealed class TextSearchProviderTests // Arrange List results = [ - new() { Name = "Doc1", Link = "http://example.com/doc1", Value = "Content of Doc1" }, - new() { Name = "Doc2", Link = "http://example.com/doc2", Value = "Content of Doc2" } + new() { SourceName = "Doc1", SourceLink = "http://example.com/doc1", Text = "Content of Doc1" }, + new() { SourceName = "Doc2", SourceLink = "http://example.com/doc2", Text = "Content of Doc2" } ]; string? capturedInput = null; @@ -158,8 +158,8 @@ public sealed class TextSearchProviderTests // Arrange List results = [ - new() { Name = "Doc1", Link = "http://example.com/doc1", Value = "Content of Doc1" }, - new() { Name = "Doc2", Link = "http://example.com/doc2", Value = "Content of Doc2" } + new() { SourceName = "Doc1", SourceLink = "http://example.com/doc1", Text = "Content of Doc1" }, + new() { SourceName = "Doc2", SourceLink = "http://example.com/doc2", Text = "Content of Doc2" } ]; Task> SearchDelegateAsync(string input, CancellationToken ct) @@ -210,8 +210,8 @@ public sealed class TextSearchProviderTests // Arrange List results = [ - new() { Name = "Doc1", Link = "http://example.com/doc1", Value = "Content of Doc1" }, - new() { Name = "Doc2", Link = "http://example.com/doc2", Value = "Content of Doc2" } + new() { SourceName = "Doc1", SourceLink = "http://example.com/doc1", Text = "Content of Doc1" }, + new() { SourceName = "Doc2", SourceLink = "http://example.com/doc2", Text = "Content of Doc2" } ]; Task> SearchDelegateAsync(string input, CancellationToken ct) @@ -244,8 +244,8 @@ public sealed class TextSearchProviderTests var payload2 = new RawPayload { Id = "R2" }; List results = [ - new() { Name = "Doc1", Value = "Content 1", RawRepresentation = payload1 }, - new() { Name = "Doc2", Value = "Content 2", RawRepresentation = payload2 } + new() { SourceName = "Doc1", Text = "Content 1", RawRepresentation = payload1 }, + new() { SourceName = "Doc2", Text = "Content 2", RawRepresentation = payload2 } ]; Task> SearchDelegateAsync(string input, CancellationToken ct) @@ -335,7 +335,8 @@ public sealed class TextSearchProviderTests var options = new TextSearchProviderOptions { SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke, - RecentMessageMemoryLimit = 3 + RecentMessageMemoryLimit = 3, + RecentMessageRolesIncluded = [ChatRole.User, ChatRole.Assistant] }; string? capturedInput = null; Task> SearchDelegateAsync(string input, CancellationToken ct) @@ -374,7 +375,8 @@ public sealed class TextSearchProviderTests var options = new TextSearchProviderOptions { SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke, - RecentMessageMemoryLimit = 5 + RecentMessageMemoryLimit = 5, + RecentMessageRolesIncluded = [ChatRole.User, ChatRole.Assistant] }; string? capturedInput = null; Task> SearchDelegateAsync(string input, CancellationToken ct) @@ -408,6 +410,46 @@ public sealed class TextSearchProviderTests Assert.Equal("A\nB\nC\nD\nE\nF", capturedInput); // All retained (limit 5) + current request message. } + [Fact] + public async Task InvokingAsync_WithRecentMessageRolesIncluded_ShouldFilterRolesAsync() + { + // Arrange + var options = new TextSearchProviderOptions + { + SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke, + RecentMessageMemoryLimit = 4, + RecentMessageRolesIncluded = new List { ChatRole.Assistant } // Only retain assistant messages. + }; + string? capturedInput = null; + Task> SearchDelegateAsync(string input, CancellationToken ct) + { + capturedInput = input; + return Task.FromResult>([]); // No results needed for this test. + } + var provider = new TextSearchProvider(SearchDelegateAsync, options); + + // Populate memory with mixed roles; only Assistant messages (A1,A2) should be retained. + var initialMessages = new[] + { + new ChatMessage(ChatRole.User, "U1"), + new ChatMessage(ChatRole.Assistant, "A1"), + new ChatMessage(ChatRole.User, "U2"), + new ChatMessage(ChatRole.Assistant, "A2"), + }; + await provider.InvokedAsync(new(initialMessages, null)); + + var invokingContext = new AIContextProvider.InvokingContext(new[] + { + new ChatMessage(ChatRole.User, "Question?") // Current request message always appended. + }); + + // Act + await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert + Assert.Equal("A1\nA2\nQuestion?", capturedInput); // Only assistant messages from memory + current request. + } + #endregion #region Serialization Tests @@ -438,7 +480,8 @@ public sealed class TextSearchProviderTests var options = new TextSearchProviderOptions { SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke, - RecentMessageMemoryLimit = 3 + RecentMessageMemoryLimit = 3, + RecentMessageRolesIncluded = [ChatRole.User, ChatRole.Assistant] }; var provider = new TextSearchProvider(this.NoResultSearchAsync, options); var messages = new[] @@ -467,7 +510,8 @@ public sealed class TextSearchProviderTests var options = new TextSearchProviderOptions { SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke, - RecentMessageMemoryLimit = 4 + RecentMessageMemoryLimit = 4, + RecentMessageRolesIncluded = [ChatRole.User, ChatRole.Assistant] }; var provider = new TextSearchProvider(this.NoResultSearchAsync, options); var messages = new[] @@ -507,7 +551,8 @@ public sealed class TextSearchProviderTests var initialProvider = new TextSearchProvider(this.NoResultSearchAsync, new TextSearchProviderOptions { SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke, - RecentMessageMemoryLimit = 5 + RecentMessageMemoryLimit = 5, + RecentMessageRolesIncluded = [ChatRole.User, ChatRole.Assistant] }); var messages = new[] { From 8b4aa1ebb533ba941d26fc75fa737f7b5f1f4dec Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Tue, 4 Nov 2025 17:09:45 +0000 Subject: [PATCH 12/15] .NET: Add additional error handling to existing providers. (#1837) * Add additional error handling to existing providers. * Add additional information when logging mem0 messages. * Fix formatting. --- .../Microsoft.Agents.AI.Mem0/Mem0Provider.cs | 89 ++++++++++----- .../Data/TextSearchProvider.cs | 66 ++++++----- .../Data/TextSearchProviderOptions.cs | 6 +- .../Mem0ProviderTests.cs | 104 +++++++++++++++--- .../Data/TextSearchProviderTests.cs | 30 ++++- 5 files changed, 217 insertions(+), 78 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0Provider.cs b/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0Provider.cs index c68b6313d5..d0f6f78b3f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0Provider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0Provider.cs @@ -131,31 +131,62 @@ public sealed class Mem0Provider : AIContextProvider Environment.NewLine, context.RequestMessages.Where(m => !string.IsNullOrWhiteSpace(m.Text)).Select(m => m.Text)); - var memories = (await this._client.SearchAsync( - this.ApplicationId, - this.AgentId, - this.ThreadId, - this.UserId, - queryText, - cancellationToken).ConfigureAwait(false)).ToList(); - - var contextInstructions = memories.Count == 0 - ? null - : $"{this._contextPrompt}\n{string.Join(Environment.NewLine, memories)}"; - - if (this._logger is not null) + try { - this._logger.LogInformation("Mem0AIContextProvider retrieved {Count} memories.", memories.Count); - if (contextInstructions is not null) + var memories = (await this._client.SearchAsync( + this.ApplicationId, + this.AgentId, + this.ThreadId, + this.UserId, + queryText, + cancellationToken).ConfigureAwait(false)).ToList(); + + var outputMessageText = memories.Count == 0 + ? null + : $"{this._contextPrompt}\n{string.Join(Environment.NewLine, memories)}"; + + if (this._logger is not null) { - this._logger.LogTrace("Mem0AIContextProvider instructions: {Instructions}", contextInstructions); + this._logger.LogInformation( + "Mem0AIContextProvider: Retrieved {Count} memories. ApplicationId: '{ApplicationId}', AgentId: '{AgentId}', ThreadId: '{ThreadId}', UserId: '{UserId}'", + memories.Count, + this.ApplicationId, + this.AgentId, + this.ThreadId, + this.UserId); + if (outputMessageText is not null) + { + this._logger.LogTrace( + "Mem0AIContextProvider: Search Results\nInput:{Input}\nOutput:{MessageText}\nApplicationId: '{ApplicationId}', AgentId: '{AgentId}', ThreadId: '{ThreadId}', UserId: '{UserId}'", + queryText, + outputMessageText, + this.ApplicationId, + this.AgentId, + this.ThreadId, + this.UserId); + } } - } - return new AIContext + return new AIContext + { + Messages = [new ChatMessage(ChatRole.User, outputMessageText)] + }; + } + catch (ArgumentException) { - Messages = [new ChatMessage(ChatRole.User, contextInstructions)] - }; + throw; + } + catch (Exception ex) + { + this._logger?.LogError( + ex, + "Mem0AIContextProvider: Failed to search Mem0 for memories due to error. ApplicationId: '{ApplicationId}', AgentId: '{AgentId}', ThreadId: '{ThreadId}', UserId: '{UserId}'", + this.ApplicationId, + this.AgentId, + this.ThreadId, + this.UserId); + return new AIContext(); + } } /// @@ -166,12 +197,20 @@ public sealed class Mem0Provider : AIContextProvider return; // Do not update memory on failed invocations. } - // Persist request and response messages after invocation. - await this.PersistMessagesAsync(context.RequestMessages, cancellationToken).ConfigureAwait(false); - - if (context.ResponseMessages is not null) + try { - await this.PersistMessagesAsync(context.ResponseMessages, cancellationToken).ConfigureAwait(false); + // Persist request and response messages after invocation. + await this.PersistMessagesAsync(context.RequestMessages.Concat(context.ResponseMessages ?? []), cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + this._logger?.LogError( + ex, + "Mem0AIContextProvider: Failed to send messages to Mem0 due to error. ApplicationId: '{ApplicationId}', AgentId: '{AgentId}', ThreadId: '{ThreadId}', UserId: '{UserId}'", + this.ApplicationId, + this.AgentId, + this.ThreadId, + this.UserId); } } diff --git a/dotnet/src/Microsoft.Agents.AI/Data/TextSearchProvider.cs b/dotnet/src/Microsoft.Agents.AI/Data/TextSearchProvider.cs index 6858510312..8cb4582e37 100644 --- a/dotnet/src/Microsoft.Agents.AI/Data/TextSearchProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Data/TextSearchProvider.cs @@ -22,7 +22,7 @@ namespace Microsoft.Agents.AI.Data; /// /// The provider supports two behaviors controlled via : /// -/// – Automatically performs a search prior to every AI invocation and injects results as additional instructions. +/// – Automatically performs a search prior to every AI invocation and injects results as additional messages. /// – Exposes a function tool that the model may invoke to retrieve contextual information when needed. /// /// @@ -122,12 +122,13 @@ public sealed class TextSearchProvider : AIContextProvider if (this._options.SearchTime != TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke) { // Expose the search tool for on-demand invocation. - return new AIContext { Tools = this._tools }; // No automatic instructions injection. + return new AIContext { Tools = this._tools }; // No automatic message injection. } // Aggregate text from memory + current request messages. var sbInput = new StringBuilder(); - foreach (var messageText in this._recentMessagesText) + var requestMessagesText = context.RequestMessages.Where(x => !string.IsNullOrWhiteSpace(x?.Text)).Select(x => x.Text); + foreach (var messageText in this._recentMessagesText.Concat(requestMessagesText)) { if (sbInput.Length > 0) { @@ -136,38 +137,35 @@ public sealed class TextSearchProvider : AIContextProvider sbInput.Append(messageText); } - foreach (var message in context.RequestMessages) - { - if (!string.IsNullOrWhiteSpace(message?.Text)) - { - if (sbInput.Length > 0) - { - sbInput.Append('\n'); - } - sbInput.Append(message.Text); - } - } string input = sbInput.ToString(); - // Search - var results = await this._searchAsync(input, cancellationToken).ConfigureAwait(false); - IList materialized = results as IList ?? results.ToList(); - if (materialized.Count == 0) + try { - this._logger?.LogWarning("TextSearchProvider: No search results found."); + // Search + var results = await this._searchAsync(input, cancellationToken).ConfigureAwait(false); + IList materialized = results as IList ?? results.ToList(); + this._logger?.LogInformation("TextSearchProvider: Retrieved {Count} search results.", materialized.Count); + + if (materialized.Count == 0) + { + return new AIContext(); + } + + // Format search results + string formatted = this.FormatResults(materialized); + + this._logger?.LogTrace("TextSearchProvider: Search Results\nInput:{Input}\nOutput:{MessageText}", input, formatted); + + return new AIContext + { + Messages = [new ChatMessage(ChatRole.User, formatted) { AdditionalProperties = new AdditionalPropertiesDictionary() { ["IsTextSearchProviderOutput"] = true } }] + }; + } + catch (Exception ex) + { + this._logger?.LogError(ex, "TextSearchProvider: Failed to search for data due to error"); return new AIContext(); } - - // Format search results - string formatted = this.FormatResults(materialized); - - this._logger?.LogInformation("TextSearchProvider: Retrieved {Count} search results.", materialized.Count); - this._logger?.LogTrace("TextSearchProvider Input:{Input}\nContext Instructions:{Instructions}", input, formatted); - - return new AIContext - { - Messages = [new ChatMessage(ChatRole.User, formatted) { AdditionalProperties = new AdditionalPropertiesDictionary() { ["IsTextSearchProviderOutput"] = true } }] - }; } /// @@ -240,16 +238,16 @@ public sealed class TextSearchProvider : AIContextProvider { var results = await this._searchAsync(userQuestion, cancellationToken).ConfigureAwait(false); IList materialized = results as IList ?? results.ToList(); - string formatted = this.FormatResults(materialized); + string outputText = this.FormatResults(materialized); this._logger?.LogInformation("TextSearchProvider: Retrieved {Count} search results.", materialized.Count); - this._logger?.LogTrace("TextSearchProvider Input:{UserQuestion}\nContext Instructions:{Instructions}", userQuestion, formatted); + this._logger?.LogTrace("TextSearchProvider Input:{UserQuestion}\nOutput:{MessageText}", userQuestion, outputText); - return formatted; + return outputText; } /// - /// Formats search results into an instructions string for model consumption. + /// Formats search results into an output string for model consumption. /// /// The results. /// Formatted string (may be empty). diff --git a/dotnet/src/Microsoft.Agents.AI/Data/TextSearchProviderOptions.cs b/dotnet/src/Microsoft.Agents.AI/Data/TextSearchProviderOptions.cs index 7949d7918a..6700634bcd 100644 --- a/dotnet/src/Microsoft.Agents.AI/Data/TextSearchProviderOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI/Data/TextSearchProviderOptions.cs @@ -30,12 +30,12 @@ public sealed class TextSearchProviderOptions public string? FunctionToolDescription { get; set; } /// - /// Gets or sets the context prompt prefixed to automatically injected results. + /// Gets or sets the context prompt prefixed to results. /// public string? ContextPrompt { get; set; } /// - /// Gets or sets the instruction appended after automatically injected results to request citations. + /// Gets or sets the instruction appended after results to request citations. /// public string? CitationsPrompt { get; set; } @@ -85,7 +85,7 @@ public sealed class TextSearchProviderOptions public enum TextSearchBehavior { /// - /// Execute search prior to each invocation and inject results as instructions. + /// Execute search prior to each invocation and inject results as a message. /// BeforeAIInvoke, diff --git a/dotnet/tests/Microsoft.Agents.AI.Mem0.UnitTests/Mem0ProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.Mem0.UnitTests/Mem0ProviderTests.cs index 985150671d..ae97aed78c 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Mem0.UnitTests/Mem0ProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Mem0.UnitTests/Mem0ProviderTests.cs @@ -9,6 +9,7 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; +using Moq; namespace Microsoft.Agents.AI.Mem0.UnitTests; @@ -17,13 +18,23 @@ namespace Microsoft.Agents.AI.Mem0.UnitTests; /// public sealed class Mem0ProviderTests : IDisposable { + private readonly Mock> _loggerMock; + private readonly Mock _loggerFactoryMock; private readonly RecordingHandler _handler = new(); private readonly HttpClient _httpClient; - private readonly ILoggerFactory _loggerFactory = LoggerFactory.Create(b => b.AddProvider(new NullLoggerProvider())); private bool _disposed; public Mem0ProviderTests() { + this._loggerMock = new(); + this._loggerFactoryMock = new(); + this._loggerFactoryMock + .Setup(f => f.CreateLogger(It.IsAny())) + .Returns(this._loggerMock.Object); + this._loggerFactoryMock + .Setup(f => f.CreateLogger(typeof(Mem0Provider).FullName!)) + .Returns(this._loggerMock.Object); + this._httpClient = new HttpClient(this._handler) { BaseAddress = new Uri("https://localhost/") @@ -80,7 +91,7 @@ public sealed class Mem0ProviderTests : IDisposable ThreadId = "thread", UserId = "user" }; - var sut = new Mem0Provider(this._httpClient, options, this._loggerFactory); + var sut = new Mem0Provider(this._httpClient, options, this._loggerFactoryMock.Object); var invokingContext = new AIContextProvider.InvokingContext(new[] { new ChatMessage(ChatRole.User, "What is my name?") }); // Act @@ -99,6 +110,24 @@ public sealed class Mem0ProviderTests : IDisposable var contextMessage = Assert.Single(aiContext.Messages); Assert.Equal(ChatRole.User, contextMessage.Role); Assert.Contains("Name is Caoimhe", contextMessage.Text); + + this._loggerMock.Verify( + l => l.Log( + LogLevel.Information, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains("Mem0AIContextProvider: Retrieved 1 memories.")), + It.IsAny(), + It.IsAny>()), + Times.Once); + + this._loggerMock.Verify( + l => l.Log( + LogLevel.Trace, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains("Mem0AIContextProvider: Search Results\nInput:What is my name?\nOutput")), + It.IsAny(), + It.IsAny>()), + Times.Once); } [Fact] @@ -156,6 +185,39 @@ public sealed class Mem0ProviderTests : IDisposable Assert.Empty(this._handler.Requests); } + [Fact] + public async Task InvokedAsync_ShouldNotThrow_WhenStorageFailsAsync() + { + // Arrange + var options = new Mem0ProviderOptions { ApplicationId = "a", AgentId = "b", ThreadId = "c", UserId = "d" }; + var sut = new Mem0Provider(this._httpClient, options, this._loggerFactoryMock.Object); + this._handler.EnqueueEmptyInternalServerError(); + + var requestMessages = new List + { + new(ChatRole.User, "User text"), + new(ChatRole.System, "System text"), + new(ChatRole.Tool, "Tool text should be ignored") + }; + var responseMessages = new List + { + new(ChatRole.Assistant, "Assistant text") + }; + + // Act + await sut.InvokedAsync(new AIContextProvider.InvokedContext(requestMessages, aiContextProviderMessages: null) { ResponseMessages = responseMessages }); + + // Assert + this._loggerMock.Verify( + l => l.Log( + LogLevel.Error, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains("Mem0AIContextProvider: Failed to send messages to Mem0 due to error")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + [Fact] public async Task ClearStoredMemoriesAsync_SendsDeleteWithQueryAsync() { @@ -267,6 +329,30 @@ public sealed class Mem0ProviderTests : IDisposable await Assert.ThrowsAsync(() => sut.InvokingAsync(ctx).AsTask()); } + [Fact] + public async Task InvokingAsync_ShouldNotThrow_WhenSearchFailsAsync() + { + // Arrange + var options = new Mem0ProviderOptions { ApplicationId = "app" }; + var provider = new Mem0Provider(this._httpClient, options, loggerFactory: this._loggerFactoryMock.Object); + var invokingContext = new AIContextProvider.InvokingContext(new[] { new ChatMessage(ChatRole.User, "Q?") }); + + // Act + var aiContext = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert + Assert.Null(aiContext.Messages); + Assert.Null(aiContext.Tools); + this._loggerMock.Verify( + l => l.Log( + LogLevel.Error, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains("Mem0AIContextProvider: Failed to search Mem0 for memories due to error")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + private static bool ContainsOrdinal(string source, string value) => source.IndexOf(value, StringComparison.Ordinal) >= 0; public void Dispose() @@ -275,7 +361,6 @@ public sealed class Mem0ProviderTests : IDisposable { this._httpClient.Dispose(); this._handler.Dispose(); - this._loggerFactory.Dispose(); this._disposed = true; } } @@ -309,18 +394,7 @@ public sealed class Mem0ProviderTests : IDisposable } public void EnqueueEmptyOk() => this._responses.Enqueue(new HttpResponseMessage(System.Net.HttpStatusCode.OK)); - } - private sealed class NullLoggerProvider : ILoggerProvider - { - public ILogger CreateLogger(string categoryName) => new NullLogger(); - public void Dispose() { } - - private sealed class NullLogger : ILogger - { - public IDisposable? BeginScope(TState state) where TState : notnull => null; - public bool IsEnabled(LogLevel logLevel) => false; - public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) { } - } + public void EnqueueEmptyInternalServerError() => this._responses.Enqueue(new HttpResponseMessage(System.Net.HttpStatusCode.InternalServerError)); } } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Data/TextSearchProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Data/TextSearchProviderTests.cs index 66abba6c8b..831c843337 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Data/TextSearchProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Data/TextSearchProviderTests.cs @@ -116,7 +116,7 @@ public sealed class TextSearchProviderTests l => l.Log( LogLevel.Trace, It.IsAny(), - It.Is((v, t) => v.ToString()!.Contains("TextSearchProvider Input:Sample user question?\nAdditional part\nContext Instructions")), + It.Is((v, t) => v.ToString()!.Contains("TextSearchProvider: Search Results\nInput:Sample user question?\nAdditional part\nOutput")), It.IsAny(), It.IsAny>()), Times.AtLeastOnce); @@ -150,6 +150,29 @@ public sealed class TextSearchProviderTests Assert.Equal(expectedDescription, tool.Description); } + [Fact] + public async Task InvokingAsync_ShouldNotThrow_WhenSearchFailsAsync() + { + // Arrange + var provider = new TextSearchProvider(this.FailingSearchAsync, loggerFactory: this._loggerFactoryMock.Object); + var invokingContext = new AIContextProvider.InvokingContext(new[] { new ChatMessage(ChatRole.User, "Q?") }); + + // Act + var aiContext = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert + Assert.Null(aiContext.Messages); + Assert.Null(aiContext.Tools); + this._loggerMock.Verify( + l => l.Log( + LogLevel.Error, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains("TextSearchProvider: Failed to search for data due to error")), + It.IsAny(), + It.IsAny>()), + Times.AtLeastOnce); + } + [Theory] [InlineData(null, null)] [InlineData("Custom context prompt", "Custom citations prompt")] @@ -619,6 +642,11 @@ public sealed class TextSearchProviderTests return Task.FromResult>([]); } + private Task> FailingSearchAsync(string input, CancellationToken ct) + { + throw new InvalidOperationException("Search Failed"); + } + private sealed class RawPayload { public string Id { get; set; } = string.Empty; From 9e1b3c9b8507f4371672e4be7c4ec1378660aa8b Mon Sep 17 00:00:00 2001 From: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com> Date: Tue, 4 Nov 2025 14:42:07 -0800 Subject: [PATCH 13/15] Python: .NET: Updated package version and small fix (#1911) * Removed public key * Updated package version * Updated Python package versions --- dotnet/nuget/nuget-package.props | 6 ++-- .../Microsoft.Agents.AI.csproj | 2 +- python/CHANGELOG.md | 25 ++++++++++++++- python/packages/a2a/pyproject.toml | 2 +- python/packages/anthropic/pyproject.toml | 2 +- python/packages/azure-ai/pyproject.toml | 2 +- python/packages/copilotstudio/pyproject.toml | 2 +- python/packages/core/pyproject.toml | 2 +- python/packages/devui/pyproject.toml | 2 +- python/packages/lab/pyproject.toml | 2 +- python/packages/mem0/pyproject.toml | 2 +- python/packages/purview/pyproject.toml | 2 +- python/packages/redis/pyproject.toml | 2 +- python/pyproject.toml | 2 +- python/uv.lock | 32 ++++++++++++------- 15 files changed, 60 insertions(+), 27 deletions(-) diff --git a/dotnet/nuget/nuget-package.props b/dotnet/nuget/nuget-package.props index c761802029..cc1c3b84ac 100644 --- a/dotnet/nuget/nuget-package.props +++ b/dotnet/nuget/nuget-package.props @@ -2,9 +2,9 @@ 1.0.0 - $(VersionPrefix)-$(VersionSuffix).251028.1 - $(VersionPrefix)-preview.251028.1 - 1.0.0-preview.251028.1 + $(VersionPrefix)-$(VersionSuffix).251104.1 + $(VersionPrefix)-preview.251104.1 + 1.0.0-preview.251104.1 Debug;Release;Publish true diff --git a/dotnet/src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj b/dotnet/src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj index a560dece67..59345d21ae 100644 --- a/dotnet/src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj +++ b/dotnet/src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj @@ -32,7 +32,7 @@ - + diff --git a/python/CHANGELOG.md b/python/CHANGELOG.md index 29561bf511..c86947b527 100644 --- a/python/CHANGELOG.md +++ b/python/CHANGELOG.md @@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.0.0b251104] - 2025-11-04 + +### Added + +- Introducing the Anthropic Client ([#1819](https://github.com/microsoft/agent-framework/pull/1819)) + +### Changed + +- [BREAKING] Consolidate workflow run APIs ([#1723](https://github.com/microsoft/agent-framework/pull/1723)) +- [BREAKING] Remove request_type param from ctx.request_info() ([#1824](https://github.com/microsoft/agent-framework/pull/1824)) +- [BREAKING] Cleanup of dependencies ([#1803](https://github.com/microsoft/agent-framework/pull/1803)) +- [BREAKING] Replace `RequestInfoExecutor` with `request_info` API and `@response_handler` ([#1466](https://github.com/microsoft/agent-framework/pull/1466)) +- Azure AI Search Support Update + Refactored Samples & Unit Tests ([#1683](https://github.com/microsoft/agent-framework/pull/1683)) +- Lab: Updates to GAIA module ([#1763](https://github.com/microsoft/agent-framework/pull/1763)) + +### Fixed + +- Azure AI `top_p` and `temperature` parameters fix ([#1839](https://github.com/microsoft/agent-framework/pull/1839)) +- Ensure agent thread is part of checkpoint ([#1756](https://github.com/microsoft/agent-framework/pull/1756)) +- Fix middleware and cleanup confusing function ([#1865](https://github.com/microsoft/agent-framework/pull/1865)) +- Fix type compatibility check ([#1753](https://github.com/microsoft/agent-framework/pull/1753)) + ## [1.0.0b251028] - 2025-10-28 ### Added @@ -124,7 +146,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 For more information, see the [announcement blog post](https://devblogs.microsoft.com/foundry/introducing-microsoft-agent-framework-the-open-source-engine-for-agentic-ai-apps/). -[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251028...HEAD +[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251104...HEAD +[1.0.0b251104]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251028...python-1.0.0b251104 [1.0.0b251028]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251016...python-1.0.0b251028 [1.0.0b251016]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251007...python-1.0.0b251016 [1.0.0b251007]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251001...python-1.0.0b251007 diff --git a/python/packages/a2a/pyproject.toml b/python/packages/a2a/pyproject.toml index c31e219926..058a843523 100644 --- a/python/packages/a2a/pyproject.toml +++ b/python/packages/a2a/pyproject.toml @@ -4,7 +4,7 @@ description = "A2A integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b251028" +version = "1.0.0b251104" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" diff --git a/python/packages/anthropic/pyproject.toml b/python/packages/anthropic/pyproject.toml index 4e620c7595..65f3419276 100644 --- a/python/packages/anthropic/pyproject.toml +++ b/python/packages/anthropic/pyproject.toml @@ -4,7 +4,7 @@ description = "Anthropic integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b251028" +version = "1.0.0b251104" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" diff --git a/python/packages/azure-ai/pyproject.toml b/python/packages/azure-ai/pyproject.toml index 2cd455c934..e68fdb0b66 100644 --- a/python/packages/azure-ai/pyproject.toml +++ b/python/packages/azure-ai/pyproject.toml @@ -4,7 +4,7 @@ description = "Azure AI Foundry integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b251028" +version = "1.0.0b251104" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" diff --git a/python/packages/copilotstudio/pyproject.toml b/python/packages/copilotstudio/pyproject.toml index b3325c76b5..7d2b927201 100644 --- a/python/packages/copilotstudio/pyproject.toml +++ b/python/packages/copilotstudio/pyproject.toml @@ -4,7 +4,7 @@ description = "Copilot Studio integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b251028" +version = "1.0.0b251104" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" diff --git a/python/packages/core/pyproject.toml b/python/packages/core/pyproject.toml index 71855049a4..c4cef634a3 100644 --- a/python/packages/core/pyproject.toml +++ b/python/packages/core/pyproject.toml @@ -4,7 +4,7 @@ description = "Microsoft Agent Framework for building AI Agents with Python. Thi authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b251028" +version = "1.0.0b251104" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" diff --git a/python/packages/devui/pyproject.toml b/python/packages/devui/pyproject.toml index 1b22574ed1..02d1e7de0a 100644 --- a/python/packages/devui/pyproject.toml +++ b/python/packages/devui/pyproject.toml @@ -4,7 +4,7 @@ description = "Debug UI for Microsoft Agent Framework with OpenAI-compatible API authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b251028" +version = "1.0.0b251104" license-files = ["LICENSE"] urls.homepage = "https://github.com/microsoft/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" diff --git a/python/packages/lab/pyproject.toml b/python/packages/lab/pyproject.toml index 8b7f8f3c96..d0a91e4147 100644 --- a/python/packages/lab/pyproject.toml +++ b/python/packages/lab/pyproject.toml @@ -4,7 +4,7 @@ description = "Experimental modules for Microsoft Agent Framework" authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b251028" +version = "1.0.0b251104" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" diff --git a/python/packages/mem0/pyproject.toml b/python/packages/mem0/pyproject.toml index 93df61461e..a59acf5e86 100644 --- a/python/packages/mem0/pyproject.toml +++ b/python/packages/mem0/pyproject.toml @@ -4,7 +4,7 @@ description = "Mem0 integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b251028" +version = "1.0.0b251104" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" diff --git a/python/packages/purview/pyproject.toml b/python/packages/purview/pyproject.toml index 4a498ac957..e7f120a23a 100644 --- a/python/packages/purview/pyproject.toml +++ b/python/packages/purview/pyproject.toml @@ -4,7 +4,7 @@ description = "Microsoft Purview (Graph dataSecurityAndGovernance) integration f authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b251028" +version = "1.0.0b251104" license-files = ["LICENSE"] urls.homepage = "https://github.com/microsoft/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" diff --git a/python/packages/redis/pyproject.toml b/python/packages/redis/pyproject.toml index 877b45bbc9..3e359b522e 100644 --- a/python/packages/redis/pyproject.toml +++ b/python/packages/redis/pyproject.toml @@ -4,7 +4,7 @@ description = "Redis integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b251028" +version = "1.0.0b251104" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" diff --git a/python/pyproject.toml b/python/pyproject.toml index 6280b3d37b..5b7d8fee8d 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -4,7 +4,7 @@ description = "Microsoft Agent Framework for building AI Agents with Python. Thi authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b251028" +version = "1.0.0b251104" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" diff --git a/python/uv.lock b/python/uv.lock index c19ba2cc89..fdcc316836 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -73,7 +73,7 @@ wheels = [ [[package]] name = "agent-framework" -version = "1.0.0b251028" +version = "1.0.0b251104" source = { virtual = "." } dependencies = [ { name = "agent-framework-a2a", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -160,7 +160,7 @@ docs = [ [[package]] name = "agent-framework-a2a" -version = "1.0.0b251028" +version = "1.0.0b251104" source = { editable = "packages/a2a" } dependencies = [ { name = "a2a-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -175,7 +175,7 @@ requires-dist = [ [[package]] name = "agent-framework-anthropic" -version = "1.0.0b251028" +version = "1.0.0b251104" source = { editable = "packages/anthropic" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -190,7 +190,7 @@ requires-dist = [ [[package]] name = "agent-framework-azure-ai" -version = "1.0.0b251028" +version = "1.0.0b251104" source = { editable = "packages/azure-ai" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -207,7 +207,7 @@ requires-dist = [ [[package]] name = "agent-framework-copilotstudio" -version = "1.0.0b251028" +version = "1.0.0b251104" source = { editable = "packages/copilotstudio" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -222,7 +222,7 @@ requires-dist = [ [[package]] name = "agent-framework-core" -version = "1.0.0b251028" +version = "1.0.0b251104" source = { editable = "packages/core" } dependencies = [ { name = "azure-identity", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -274,7 +274,7 @@ provides-extras = ["all"] [[package]] name = "agent-framework-devui" -version = "1.0.0b251028" +version = "1.0.0b251104" source = { editable = "packages/devui" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -308,7 +308,7 @@ provides-extras = ["dev", "all"] [[package]] name = "agent-framework-lab" -version = "1.0.0b251028" +version = "1.0.0b251104" source = { editable = "packages/lab" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -399,7 +399,7 @@ dev = [ [[package]] name = "agent-framework-mem0" -version = "1.0.0b251028" +version = "1.0.0b251104" source = { editable = "packages/mem0" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -414,7 +414,7 @@ requires-dist = [ [[package]] name = "agent-framework-purview" -version = "1.0.0b251028" +version = "1.0.0b251104" source = { editable = "packages/purview" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -431,7 +431,7 @@ requires-dist = [ [[package]] name = "agent-framework-redis" -version = "1.0.0b251028" +version = "1.0.0b251104" source = { editable = "packages/redis" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -2039,6 +2039,8 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7f/91/ae2eb6b7979e2f9b035a9f612cf70f1bf54aad4e1d125129bef1eae96f19/greenlet-3.2.4-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c2ca18a03a8cfb5b25bc1cbe20f3d9a4c80d8c3b13ba3df49ac3961af0b1018d", size = 584358, upload-time = "2025-08-07T13:18:23.708Z" }, { url = "https://files.pythonhosted.org/packages/f7/85/433de0c9c0252b22b16d413c9407e6cb3b41df7389afc366ca204dbc1393/greenlet-3.2.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9fe0a28a7b952a21e2c062cd5756d34354117796c6d9215a87f55e38d15402c5", size = 1113550, upload-time = "2025-08-07T13:42:37.467Z" }, { url = "https://files.pythonhosted.org/packages/a1/8d/88f3ebd2bc96bf7747093696f4335a0a8a4c5acfcf1b757717c0d2474ba3/greenlet-3.2.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8854167e06950ca75b898b104b63cc646573aa5fef1353d4508ecdd1ee76254f", size = 1137126, upload-time = "2025-08-07T13:18:20.239Z" }, + { url = "https://files.pythonhosted.org/packages/f1/29/74242b7d72385e29bcc5563fba67dad94943d7cd03552bac320d597f29b2/greenlet-3.2.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f47617f698838ba98f4ff4189aef02e7343952df3a615f847bb575c3feb177a7", size = 1544904, upload-time = "2025-11-04T12:42:04.763Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e2/1572b8eeab0f77df5f6729d6ab6b141e4a84ee8eb9bc8c1e7918f94eda6d/greenlet-3.2.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:af41be48a4f60429d5cad9d22175217805098a9ef7c40bfef44f7669fb9d74d8", size = 1611228, upload-time = "2025-11-04T12:42:08.423Z" }, { url = "https://files.pythonhosted.org/packages/d6/6f/b60b0291d9623c496638c582297ead61f43c4b72eef5e9c926ef4565ec13/greenlet-3.2.4-cp310-cp310-win_amd64.whl", hash = "sha256:73f49b5368b5359d04e18d15828eecc1806033db5233397748f4ca813ff1056c", size = 298654, upload-time = "2025-08-07T13:50:00.469Z" }, { url = "https://files.pythonhosted.org/packages/a4/de/f28ced0a67749cac23fecb02b694f6473f47686dff6afaa211d186e2ef9c/greenlet-3.2.4-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:96378df1de302bc38e99c3a9aa311967b7dc80ced1dcc6f171e99842987882a2", size = 272305, upload-time = "2025-08-07T13:15:41.288Z" }, { url = "https://files.pythonhosted.org/packages/09/16/2c3792cba130000bf2a31c5272999113f4764fd9d874fb257ff588ac779a/greenlet-3.2.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1ee8fae0519a337f2329cb78bd7a8e128ec0f881073d43f023c7b8d4831d5246", size = 632472, upload-time = "2025-08-07T13:42:55.044Z" }, @@ -2048,6 +2050,8 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1f/8e/abdd3f14d735b2929290a018ecf133c901be4874b858dd1c604b9319f064/greenlet-3.2.4-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2523e5246274f54fdadbce8494458a2ebdcdbc7b802318466ac5606d3cded1f8", size = 587684, upload-time = "2025-08-07T13:18:25.164Z" }, { url = "https://files.pythonhosted.org/packages/5d/65/deb2a69c3e5996439b0176f6651e0052542bb6c8f8ec2e3fba97c9768805/greenlet-3.2.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1987de92fec508535687fb807a5cea1560f6196285a4cde35c100b8cd632cc52", size = 1116647, upload-time = "2025-08-07T13:42:38.655Z" }, { url = "https://files.pythonhosted.org/packages/3f/cc/b07000438a29ac5cfb2194bfc128151d52f333cee74dd7dfe3fb733fc16c/greenlet-3.2.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:55e9c5affaa6775e2c6b67659f3a71684de4c549b3dd9afca3bc773533d284fa", size = 1142073, upload-time = "2025-08-07T13:18:21.737Z" }, + { url = "https://files.pythonhosted.org/packages/67/24/28a5b2fa42d12b3d7e5614145f0bd89714c34c08be6aabe39c14dd52db34/greenlet-3.2.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c9c6de1940a7d828635fbd254d69db79e54619f165ee7ce32fda763a9cb6a58c", size = 1548385, upload-time = "2025-11-04T12:42:11.067Z" }, + { url = "https://files.pythonhosted.org/packages/6a/05/03f2f0bdd0b0ff9a4f7b99333d57b53a7709c27723ec8123056b084e69cd/greenlet-3.2.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:03c5136e7be905045160b1b9fdca93dd6727b180feeafda6818e6496434ed8c5", size = 1613329, upload-time = "2025-11-04T12:42:12.928Z" }, { url = "https://files.pythonhosted.org/packages/d8/0f/30aef242fcab550b0b3520b8e3561156857c94288f0332a79928c31a52cf/greenlet-3.2.4-cp311-cp311-win_amd64.whl", hash = "sha256:9c40adce87eaa9ddb593ccb0fa6a07caf34015a29bf8d344811665b573138db9", size = 299100, upload-time = "2025-08-07T13:44:12.287Z" }, { url = "https://files.pythonhosted.org/packages/44/69/9b804adb5fd0671f367781560eb5eb586c4d495277c93bde4307b9e28068/greenlet-3.2.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3b67ca49f54cede0186854a008109d6ee71f66bd57bb36abd6d0a0267b540cdd", size = 274079, upload-time = "2025-08-07T13:15:45.033Z" }, { url = "https://files.pythonhosted.org/packages/46/e9/d2a80c99f19a153eff70bc451ab78615583b8dac0754cfb942223d2c1a0d/greenlet-3.2.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddf9164e7a5b08e9d22511526865780a576f19ddd00d62f8a665949327fde8bb", size = 640997, upload-time = "2025-08-07T13:42:56.234Z" }, @@ -2057,6 +2061,8 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/19/0d/6660d55f7373b2ff8152401a83e02084956da23ae58cddbfb0b330978fe9/greenlet-3.2.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0", size = 607586, upload-time = "2025-08-07T13:18:28.544Z" }, { url = "https://files.pythonhosted.org/packages/8e/1a/c953fdedd22d81ee4629afbb38d2f9d71e37d23caace44775a3a969147d4/greenlet-3.2.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0", size = 1123281, upload-time = "2025-08-07T13:42:39.858Z" }, { url = "https://files.pythonhosted.org/packages/3f/c7/12381b18e21aef2c6bd3a636da1088b888b97b7a0362fac2e4de92405f97/greenlet-3.2.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:20fb936b4652b6e307b8f347665e2c615540d4b42b3b4c8a321d8286da7e520f", size = 1151142, upload-time = "2025-08-07T13:18:22.981Z" }, + { url = "https://files.pythonhosted.org/packages/27/45/80935968b53cfd3f33cf99ea5f08227f2646e044568c9b1555b58ffd61c2/greenlet-3.2.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee7a6ec486883397d70eec05059353b8e83eca9168b9f3f9a361971e77e0bcd0", size = 1564846, upload-time = "2025-11-04T12:42:15.191Z" }, + { url = "https://files.pythonhosted.org/packages/69/02/b7c30e5e04752cb4db6202a3858b149c0710e5453b71a3b2aec5d78a1aab/greenlet-3.2.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:326d234cbf337c9c3def0676412eb7040a35a768efc92504b947b3e9cfc7543d", size = 1633814, upload-time = "2025-11-04T12:42:17.175Z" }, { url = "https://files.pythonhosted.org/packages/e9/08/b0814846b79399e585f974bbeebf5580fbe59e258ea7be64d9dfb253c84f/greenlet-3.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7d4e128405eea3814a12cc2605e0e6aedb4035bf32697f72deca74de4105e02", size = 299899, upload-time = "2025-08-07T13:38:53.448Z" }, { url = "https://files.pythonhosted.org/packages/49/e8/58c7f85958bda41dafea50497cbd59738c5c43dbbea5ee83d651234398f4/greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31", size = 272814, upload-time = "2025-08-07T13:15:50.011Z" }, { url = "https://files.pythonhosted.org/packages/62/dd/b9f59862e9e257a16e4e610480cfffd29e3fae018a68c2332090b53aac3d/greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945", size = 641073, upload-time = "2025-08-07T13:42:57.23Z" }, @@ -2066,6 +2072,8 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ee/43/3cecdc0349359e1a527cbf2e3e28e5f8f06d3343aaf82ca13437a9aa290f/greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671", size = 610497, upload-time = "2025-08-07T13:18:31.636Z" }, { url = "https://files.pythonhosted.org/packages/b8/19/06b6cf5d604e2c382a6f31cafafd6f33d5dea706f4db7bdab184bad2b21d/greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b", size = 1121662, upload-time = "2025-08-07T13:42:41.117Z" }, { url = "https://files.pythonhosted.org/packages/a2/15/0d5e4e1a66fab130d98168fe984c509249c833c1a3c16806b90f253ce7b9/greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae", size = 1149210, upload-time = "2025-08-07T13:18:24.072Z" }, + { url = "https://files.pythonhosted.org/packages/1c/53/f9c440463b3057485b8594d7a638bed53ba531165ef0ca0e6c364b5cc807/greenlet-3.2.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e343822feb58ac4d0a1211bd9399de2b3a04963ddeec21530fc426cc121f19b", size = 1564759, upload-time = "2025-11-04T12:42:19.395Z" }, + { url = "https://files.pythonhosted.org/packages/47/e4/3bb4240abdd0a8d23f4f88adec746a3099f0d86bfedb623f063b2e3b4df0/greenlet-3.2.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca7f6f1f2649b89ce02f6f229d7c19f680a6238af656f61e0115b24857917929", size = 1634288, upload-time = "2025-11-04T12:42:21.174Z" }, { url = "https://files.pythonhosted.org/packages/0b/55/2321e43595e6801e105fcfdee02b34c0f996eb71e6ddffca6b10b7e1d771/greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b", size = 299685, upload-time = "2025-08-07T13:24:38.824Z" }, { url = "https://files.pythonhosted.org/packages/22/5c/85273fd7cc388285632b0498dbbab97596e04b154933dfe0f3e68156c68c/greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0", size = 273586, upload-time = "2025-08-07T13:16:08.004Z" }, { url = "https://files.pythonhosted.org/packages/d1/75/10aeeaa3da9332c2e761e4c50d4c3556c21113ee3f0afa2cf5769946f7a3/greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f", size = 686346, upload-time = "2025-08-07T13:42:59.944Z" }, @@ -2073,6 +2081,8 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/8b/29aae55436521f1d6f8ff4e12fb676f3400de7fcf27fccd1d4d17fd8fecd/greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1", size = 694659, upload-time = "2025-08-07T13:53:17.759Z" }, { url = "https://files.pythonhosted.org/packages/92/2e/ea25914b1ebfde93b6fc4ff46d6864564fba59024e928bdc7de475affc25/greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735", size = 695355, upload-time = "2025-08-07T13:18:34.517Z" }, { url = "https://files.pythonhosted.org/packages/72/60/fc56c62046ec17f6b0d3060564562c64c862948c9d4bc8aa807cf5bd74f4/greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337", size = 657512, upload-time = "2025-08-07T13:18:33.969Z" }, + { url = "https://files.pythonhosted.org/packages/23/6e/74407aed965a4ab6ddd93a7ded3180b730d281c77b765788419484cdfeef/greenlet-3.2.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2917bdf657f5859fbf3386b12d68ede4cf1f04c90c3a6bc1f013dd68a22e2269", size = 1612508, upload-time = "2025-11-04T12:42:23.427Z" }, + { url = "https://files.pythonhosted.org/packages/0d/da/343cd760ab2f92bac1845ca07ee3faea9fe52bee65f7bcb19f16ad7de08b/greenlet-3.2.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:015d48959d4add5d6c9f6c5210ee3803a830dce46356e3bc326d6776bde54681", size = 1680760, upload-time = "2025-11-04T12:42:25.341Z" }, { url = "https://files.pythonhosted.org/packages/e3/a5/6ddab2b4c112be95601c13428db1d8b6608a8b6039816f2ba09c346c08fc/greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01", size = 303425, upload-time = "2025-08-07T13:32:27.59Z" }, ] From d5040236c9f6654bdf3cd3d061adb5a59a85c3f2 Mon Sep 17 00:00:00 2001 From: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Date: Wed, 5 Nov 2025 07:49:34 +0900 Subject: [PATCH 14/15] Fix mcp tool cloning for handoff pattern (#1883) --- .../agent_framework/_workflows/_handoff.py | 10 +++++- .../core/tests/workflow/test_handoff.py | 33 +++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/python/packages/core/agent_framework/_workflows/_handoff.py b/python/packages/core/agent_framework/_workflows/_handoff.py index c98d9e752c..3c5995aeaf 100644 --- a/python/packages/core/agent_framework/_workflows/_handoff.py +++ b/python/packages/core/agent_framework/_workflows/_handoff.py @@ -80,6 +80,14 @@ def _clone_chat_agent(agent: ChatAgent) -> ChatAgent: options = agent.chat_options middleware = list(agent.middleware or []) + # Reconstruct the original tools list by combining regular tools with MCP tools. + # ChatAgent.__init__ separates MCP tools into _local_mcp_tools during initialization, + # so we need to recombine them here to pass the complete tools list to the constructor. + # This makes sure MCP tools are preserved when cloning agents for handoff workflows. + all_tools = list(options.tools) if options.tools else [] + if agent._local_mcp_tools: + all_tools.extend(agent._local_mcp_tools) + return ChatAgent( chat_client=agent.chat_client, instructions=options.instructions, @@ -101,7 +109,7 @@ def _clone_chat_agent(agent: ChatAgent) -> ChatAgent: store=options.store, temperature=options.temperature, tool_choice=options.tool_choice, # type: ignore[arg-type] - tools=list(options.tools) if options.tools else None, + tools=all_tools if all_tools else None, top_p=options.top_p, user=options.user, additional_chat_options=dict(options.additional_properties), diff --git a/python/packages/core/tests/workflow/test_handoff.py b/python/packages/core/tests/workflow/test_handoff.py index 8042c68e08..44a6403c6f 100644 --- a/python/packages/core/tests/workflow/test_handoff.py +++ b/python/packages/core/tests/workflow/test_handoff.py @@ -3,6 +3,7 @@ from collections.abc import AsyncIterable, AsyncIterator from dataclasses import dataclass from typing import Any, cast +from unittest.mock import MagicMock import pytest @@ -10,6 +11,7 @@ from agent_framework import ( AgentRunResponse, AgentRunResponseUpdate, BaseAgent, + ChatAgent, ChatMessage, FunctionCallContent, HandoffBuilder, @@ -20,6 +22,8 @@ from agent_framework import ( WorkflowEvent, WorkflowOutputEvent, ) +from agent_framework._mcp import MCPTool +from agent_framework._workflows._handoff import _clone_chat_agent @dataclass @@ -368,3 +372,32 @@ async def test_handoff_async_termination_condition() -> None: user_messages = [msg for msg in final_conv_list if msg.role == Role.USER] assert len(user_messages) == 2 assert termination_call_count > 0 + + +async def test_clone_chat_agent_preserves_mcp_tools() -> None: + """Test that _clone_chat_agent preserves MCP tools when cloning an agent.""" + mock_chat_client = MagicMock() + + mock_mcp_tool = MagicMock(spec=MCPTool) + mock_mcp_tool.name = "test_mcp_tool" + + def sample_function() -> str: + return "test" + + original_agent = ChatAgent( + chat_client=mock_chat_client, + name="TestAgent", + instructions="Test instructions", + tools=[mock_mcp_tool, sample_function], + ) + + assert hasattr(original_agent, "_local_mcp_tools") + assert len(original_agent._local_mcp_tools) == 1 + assert original_agent._local_mcp_tools[0] == mock_mcp_tool + + cloned_agent = _clone_chat_agent(original_agent) + + assert hasattr(cloned_agent, "_local_mcp_tools") + assert len(cloned_agent._local_mcp_tools) == 1 + assert cloned_agent._local_mcp_tools[0] == mock_mcp_tool + assert len(cloned_agent.chat_options.tools) == 1 From 7b3e2a7e824753d907b555078e48932d9f3a61fc Mon Sep 17 00:00:00 2001 From: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Date: Wed, 5 Nov 2025 08:20:13 +0900 Subject: [PATCH 15/15] Update changelog with a new PR that went in (#1912) --- python/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/python/CHANGELOG.md b/python/CHANGELOG.md index c86947b527..3ccd587c09 100644 --- a/python/CHANGELOG.md +++ b/python/CHANGELOG.md @@ -28,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Ensure agent thread is part of checkpoint ([#1756](https://github.com/microsoft/agent-framework/pull/1756)) - Fix middleware and cleanup confusing function ([#1865](https://github.com/microsoft/agent-framework/pull/1865)) - Fix type compatibility check ([#1753](https://github.com/microsoft/agent-framework/pull/1753)) +- Fix mcp tool cloning for handoff pattern ([#1883](https://github.com/microsoft/agent-framework/pull/1883)) ## [1.0.0b251028] - 2025-10-28