diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 6ea60a0d59..90b127a829 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -11,9 +11,6 @@ updates: schedule: interval: "cron" cronjob: "0 8 * * 4,0" # Every Thursday(4) and Sunday(0) at 8:00 UTC - experimental: - nuget-native-updater: false - enable-cooldown-metrics-collection: false ignore: # For all System.* and Microsoft.Extensions/Bcl.* packages, ignore all major version updates - dependency-name: "System.*" @@ -28,6 +25,14 @@ updates: - "dependencies" # Maintain dependencies for python + - package-ecosystem: "pip" + directory: "python/" + schedule: + interval: "weekly" + day: "monday" + labels: + - "python" + - "dependencies" - package-ecosystem: "uv" directory: "python/" schedule: diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index fd7e69fd52..4215400e63 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -17,6 +17,8 @@ + + @@ -93,10 +95,10 @@ - - - - + + + + @@ -118,8 +120,6 @@ - - @@ -165,4 +165,4 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - \ No newline at end of file + diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 588dff618a..908884476d 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -44,7 +44,8 @@ - + + @@ -91,6 +92,7 @@ + @@ -100,6 +102,25 @@ + + + + + + + + + + + + + + + + + + + @@ -124,13 +145,22 @@ + + + + + + + + + + - @@ -175,8 +205,8 @@ - + @@ -302,6 +332,7 @@ + @@ -314,6 +345,7 @@ + @@ -321,6 +353,7 @@ + @@ -337,6 +370,7 @@ + diff --git a/dotnet/eng/MSBuild/Shared.props b/dotnet/eng/MSBuild/Shared.props index 54f93699ad..da8806a1f3 100644 --- a/dotnet/eng/MSBuild/Shared.props +++ b/dotnet/eng/MSBuild/Shared.props @@ -11,4 +11,13 @@ + + + + + + + + + diff --git a/dotnet/nuget/nuget-package.props b/dotnet/nuget/nuget-package.props index df707d9031..2282c9ce13 100644 --- a/dotnet/nuget/nuget-package.props +++ b/dotnet/nuget/nuget-package.props @@ -2,9 +2,9 @@ 1.0.0 - $(VersionPrefix)-$(VersionSuffix).251113.1 - $(VersionPrefix)-preview.251113.1 - 1.0.0-preview.251113.1 + $(VersionPrefix)-$(VersionSuffix).251114.1 + $(VersionPrefix)-preview.251114.1 + 1.0.0-preview.251114.1 Debug;Release;Publish true diff --git a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureFoundryAgent/Agent_With_AzureFoundryAgent.csproj b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureAIAgentsPersistent/Agent_With_AzureAIAgentsPersistent.csproj similarity index 100% rename from dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureFoundryAgent/Agent_With_AzureFoundryAgent.csproj rename to dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureAIAgentsPersistent/Agent_With_AzureAIAgentsPersistent.csproj diff --git a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureFoundryAgent/Program.cs b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureAIAgentsPersistent/Program.cs similarity index 100% rename from dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureFoundryAgent/Program.cs rename to dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureAIAgentsPersistent/Program.cs diff --git a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureFoundryAgent/README.md b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureAIAgentsPersistent/README.md similarity index 100% rename from dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureFoundryAgent/README.md rename to dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureAIAgentsPersistent/README.md diff --git a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureAIProject/Agent_With_AzureAIProject.csproj b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureAIProject/Agent_With_AzureAIProject.csproj new file mode 100644 index 0000000000..057a0fc507 --- /dev/null +++ b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureAIProject/Agent_With_AzureAIProject.csproj @@ -0,0 +1,21 @@ + + + + Exe + net9.0 + + enable + enable + $(NoWarn);IDE0059 + + + + + + + + + + + + diff --git a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureAIProject/Program.cs b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureAIProject/Program.cs new file mode 100644 index 0000000000..dd4a011e4d --- /dev/null +++ b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureAIProject/Program.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample shows how to create and use a AI agents with Azure Foundry Agents as the backend. + +using Azure.AI.Projects; +using Azure.AI.Projects.OpenAI; +using Azure.Identity; +using Microsoft.Agents.AI; + +var endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_PROJECT_ENDPOINT is not set."); +var deploymentName = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; + +const string JokerInstructions = "You are good at telling jokes."; +const string JokerName = "JokerAgent"; + +// Get a client to create/retrieve/delete server side agents with Azure Foundry Agents. +var aiProjectClient = new AIProjectClient(new Uri(endpoint), new AzureCliCredential()); + +// Define the agent you want to create. (Prompt Agent in this case) +var agentVersionCreationOptions = new AgentVersionCreationOptions(new PromptAgentDefinition(model: deploymentName) { Instructions = JokerInstructions }); +// Azure.AI.Agents SDK creates and manages agent by name and versions. +// You can create a server side agent version with the Azure.AI.Agents SDK client below. +var agentVersion = aiProjectClient.Agents.CreateAgentVersion(agentName: JokerName, options: agentVersionCreationOptions); + +// Note: +// agentVersion.Id = ":", +// agentVersion.Version = , +// agentVersion.Name = + +// You can retrieve an AIAgent for a already created server side agent version. +AIAgent jokerAgentV1 = aiProjectClient.GetAIAgent(agentVersion); + +// You can also create another AIAgent version (V2) by providing the same name with a different definition. +AIAgent jokerAgentV2 = aiProjectClient.CreateAIAgent(name: JokerName, model: deploymentName, instructions: JokerInstructions + "V2"); + +// You can also get the AIAgent latest version just providing its name. +AIAgent jokerAgentLatest = aiProjectClient.GetAIAgent(name: JokerName); +var latestVersion = jokerAgentLatest.GetService()!; + +// The AIAgent version can be accessed via the GetService method. +Console.WriteLine($"Latest agent version id: {latestVersion.Id}"); + +// Once you have the AIAgent, you can invoke it like any other AIAgent. +AgentThread thread = jokerAgentLatest.GetNewThread(); +Console.WriteLine(await jokerAgentLatest.RunAsync("Tell me a joke about a pirate.", thread)); + +// This will use the same thread to continue the conversation. +Console.WriteLine(await jokerAgentLatest.RunAsync("Now tell me a joke about a cat and a dog using last joke as the anchor.", thread)); + +// Cleanup by agent name removes both agent versions created (jokerAgentV1 + jokerAgentV2). +aiProjectClient.Agents.DeleteAgent(jokerAgentV1.Name); diff --git a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureAIProject/README.md b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureAIProject/README.md new file mode 100644 index 0000000000..df0854ba2f --- /dev/null +++ b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureAIProject/README.md @@ -0,0 +1,16 @@ +# Prerequisites + +Before you begin, ensure you have the following prerequisites: + +- .NET 8.0 SDK or later +- Azure Foundry 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 Foundry 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_FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" # Replace with your Azure Foundry resource endpoint +$env:AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini +``` diff --git a/dotnet/samples/GettingStarted/AgentProviders/README.md b/dotnet/samples/GettingStarted/AgentProviders/README.md index 4e84cd4f08..5d32f2542b 100644 --- a/dotnet/samples/GettingStarted/AgentProviders/README.md +++ b/dotnet/samples/GettingStarted/AgentProviders/README.md @@ -15,7 +15,8 @@ See the README.md for each sample for the prerequisites for that sample. |Sample|Description| |---|---| |[Creating an AIAgent with A2A](./Agent_With_A2A/)|This sample demonstrates how to create AIAgent for an existing A2A agent.| -|[Creating an AIAgent with AzureFoundry Agent](./Agent_With_AzureFoundryAgent/)|This sample demonstrates how to create an Azure Foundry agent and expose it as an AIAgent| +|[Creating an AIAgent with Foundry Agents using Azure.AI.Agents.Persistent](./Agent_With_AzureAIAgentsPersistent/)|This sample demonstrates how to create a Foundry Persistent agent and expose it as an AIAgent using the Azure.AI.Agents.Persistent SDK| +|[Creating an AIAgent with Foundry Agents using Azure.AI.Project](./Agent_With_AzureAIProject/)|This sample demonstrates how to create an Foundry Project agent and expose it as an AIAgent using the Azure.AI.Project SDK| |[Creating an AIAgent with AzureFoundry Model](./Agent_With_AzureFoundryModel/)|This sample demonstrates how to use any model deployed to Azure Foundry to create an AIAgent| |[Creating an AIAgent with Azure OpenAI ChatCompletion](./Agent_With_AzureOpenAIChatCompletion/)|This sample demonstrates how to create an AIAgent using Azure OpenAI ChatCompletion as the underlying inference service| |[Creating an AIAgent with Azure OpenAI Responses](./Agent_With_AzureOpenAIResponses/)|This sample demonstrates how to create an AIAgent using Azure OpenAI Responses as the underlying inference service| diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step01.1_Basics/FoundryAgents_Step01.1_Basics.csproj b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step01.1_Basics/FoundryAgents_Step01.1_Basics.csproj new file mode 100644 index 0000000000..a2ccc2a339 --- /dev/null +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step01.1_Basics/FoundryAgents_Step01.1_Basics.csproj @@ -0,0 +1,21 @@ + + + + Exe + net9.0 + + enable + enable + $(NoWarn);IDE0059 + + + + + + + + + + + + diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step01.1_Basics/Program.cs b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step01.1_Basics/Program.cs new file mode 100644 index 0000000000..c51d345c11 --- /dev/null +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step01.1_Basics/Program.cs @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample shows how to create and use AI agents with Azure Foundry Agents as the backend. + +using Azure.AI.Projects; +using Azure.AI.Projects.OpenAI; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +string endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_PROJECT_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; + +const string JokerInstructions = "You are good at telling jokes."; +const string JokerName = "JokerAgent"; + +// Get a client to create/retrieve/delete server side agents with Azure Foundry Agents. +AIProjectClient aiProjectClient = new(new Uri(endpoint), new AzureCliCredential()); + +// Define the agent you want to create. (Prompt Agent in this case) +AgentVersionCreationOptions options = new(new PromptAgentDefinition(model: deploymentName) { Instructions = JokerInstructions }); + +// Azure.AI.Agents SDK creates and manages agent by name and versions. +// You can create a server side agent version with the Azure.AI.Agents SDK client below. +AgentVersion agentVersion = aiProjectClient.Agents.CreateAgentVersion(agentName: JokerName, options); + +// Note: +// agentVersion.Id = ":", +// agentVersion.Version = , +// agentVersion.Name = + +// You can retrieve an AIAgent for an already created server side agent version. +AIAgent jokerAgentV1 = aiProjectClient.GetAIAgent(agentVersion); + +// You can also create another AIAgent version (V2) by providing the same name with a different definition. +AIAgent jokerAgentV2 = aiProjectClient.CreateAIAgent(name: JokerName, model: deploymentName, instructions: JokerInstructions + "V2"); + +// You can also get the AIAgent latest version by just providing its name. +AIAgent jokerAgentLatest = aiProjectClient.GetAIAgent(name: JokerName); +AgentVersion latestVersion = jokerAgentLatest.GetService()!; + +// The AIAgent version can be accessed via the GetService method. +Console.WriteLine($"Latest agent version id: {latestVersion.Id}"); + +// Once you have the AIAgent, you can invoke it like any other AIAgent. +AgentThread thread = jokerAgentLatest.GetNewThread(); +Console.WriteLine(await jokerAgentLatest.RunAsync("Tell me a joke about a pirate.", thread)); + +// This will use the same thread to continue the conversation. +Console.WriteLine(await jokerAgentLatest.RunAsync("Now tell me a joke about a cat and a dog using last joke as the anchor.", thread)); + +// Cleanup by agent name removes both agent versions created (jokerAgentV1 + jokerAgentV2). +await aiProjectClient.Agents.DeleteAgentAsync(jokerAgentV1.Name); diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step01.1_Basics/README.md b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step01.1_Basics/README.md new file mode 100644 index 0000000000..6a22b1df81 --- /dev/null +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step01.1_Basics/README.md @@ -0,0 +1,40 @@ +# Creating and Managing AI Agents with Versioning + +This sample demonstrates how to create and manage AI agents with Azure Foundry Agents, including: +- Creating agents with different versions +- Retrieving agents by version or latest version +- Running multi-turn conversations with agents +- Managing agent lifecycle (creation and deletion) + +## Prerequisites + +Before you begin, ensure you have the following prerequisites: + +- .NET 8.0 SDK or later +- Azure Foundry 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 Foundry 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_FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" # Replace with your Azure Foundry resource endpoint +$env:AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini +``` + +## Run the sample + +Navigate to the FoundryAgents sample directory and run: + +```powershell +cd dotnet/samples/GettingStarted/FoundryAgents +dotnet run --project .\FoundryAgents_Step01.1_Basics +``` + +## What this sample demonstrates + +1. **Creating agents with versions**: Shows how to create multiple versions of the same agent with different instructions +2. **Retrieving agents**: Demonstrates retrieving agents by specific version or getting the latest version +3. **Multi-turn conversations**: Shows how to use threads to maintain conversation context across multiple agent runs +4. **Agent cleanup**: Demonstrates proper resource cleanup by deleting agents diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step01.2_Running/FoundryAgents_Step01.2_Running.csproj b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step01.2_Running/FoundryAgents_Step01.2_Running.csproj new file mode 100644 index 0000000000..3ed207aadf --- /dev/null +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step01.2_Running/FoundryAgents_Step01.2_Running.csproj @@ -0,0 +1,20 @@ + + + + Exe + net9.0 + + enable + enable + + + + + + + + + + + + diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step01.2_Running/Program.cs b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step01.2_Running/Program.cs new file mode 100644 index 0000000000..bd5620ff1f --- /dev/null +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step01.2_Running/Program.cs @@ -0,0 +1,41 @@ +// 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. + +using Azure.AI.Projects; +using Azure.AI.Projects.OpenAI; +using Azure.Identity; +using Microsoft.Agents.AI; + +string endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_PROJECT_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; + +const string JokerInstructions = "You are good at telling jokes."; +const string JokerName = "JokerAgent"; + +// Get a client to create/retrieve/delete server side agents with Azure Foundry Agents. +AIProjectClient aiProjectClient = new(new Uri(endpoint), new AzureCliCredential()); + +// Define the agent you want to create. (Prompt Agent in this case) +AgentVersionCreationOptions options = new(new PromptAgentDefinition(model: deploymentName) { Instructions = JokerInstructions }); + +// Azure.AI.Agents SDK creates and manages agent by name and versions. +// You can create a server side agent version with the Azure.AI.Agents SDK client below. +AgentVersion agentVersion = aiProjectClient.Agents.CreateAgentVersion(agentName: JokerName, options); + +// You can retrieve an AIAgent for a already created server side agent version. +AIAgent jokerAgent = aiProjectClient.GetAIAgent(agentVersion); + +// Invoke the agent and output the text result. +AgentThread thread = jokerAgent.GetNewThread(); +Console.WriteLine(await jokerAgent.RunAsync("Tell me a joke about a pirate.", thread)); + +// Invoke the agent with streaming support. +thread = jokerAgent.GetNewThread(); +await foreach (AgentRunResponseUpdate update in jokerAgent.RunStreamingAsync("Tell me a joke about a pirate.", thread)) +{ + Console.WriteLine(update); +} + +// Cleanup by agent name removes the agent version created. +await aiProjectClient.Agents.DeleteAgentAsync(jokerAgent.Name); diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step01.2_Running/README.md b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step01.2_Running/README.md new file mode 100644 index 0000000000..26725e016e --- /dev/null +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step01.2_Running/README.md @@ -0,0 +1,46 @@ +# Running a Simple AI Agent with Streaming + +This sample demonstrates how to create and run a simple AI agent with Azure Foundry Agents, including both text and streaming responses. + +## What this sample demonstrates + +- Creating a simple AI agent with instructions +- Running an agent with text output +- Running an agent with streaming output +- Managing agent lifecycle (creation and deletion) + +## Prerequisites + +Before you begin, ensure you have the following prerequisites: + +- .NET 8.0 SDK or later +- Azure Foundry 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 Foundry 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_FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" # Replace with your Azure Foundry resource endpoint +$env:AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini +``` + +## Run the sample + +Navigate to the FoundryAgents sample directory and run: + +```powershell +cd dotnet/samples/GettingStarted/FoundryAgents +dotnet run --project .\FoundryAgents_Step01.2_Running +``` + +## Expected behavior + +The sample will: + +1. Create an agent named "JokerAgent" with instructions to tell jokes +2. Run the agent with a text prompt and display the response +3. Run the agent again with streaming to display the response as it's generated +4. Clean up resources by deleting the agent + diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step02_MultiturnConversation/FoundryAgents_Step02_MultiturnConversation.csproj b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step02_MultiturnConversation/FoundryAgents_Step02_MultiturnConversation.csproj new file mode 100644 index 0000000000..3ed207aadf --- /dev/null +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step02_MultiturnConversation/FoundryAgents_Step02_MultiturnConversation.csproj @@ -0,0 +1,20 @@ + + + + Exe + net9.0 + + enable + enable + + + + + + + + + + + + diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step02_MultiturnConversation/Program.cs b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step02_MultiturnConversation/Program.cs new file mode 100644 index 0000000000..3cbb0099ea --- /dev/null +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step02_MultiturnConversation/Program.cs @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample shows how to create and use a simple AI agent with a multi-turn conversation. + +using Azure.AI.Projects; +using Azure.AI.Projects.OpenAI; +using Azure.Identity; +using Microsoft.Agents.AI; + +string endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_PROJECT_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; + +const string JokerInstructions = "You are good at telling jokes."; +const string JokerName = "JokerAgent"; + +// Get a client to create/retrieve/delete server side agents with Azure Foundry Agents. +AIProjectClient aiProjectClient = new(new Uri(endpoint), new AzureCliCredential()); + +// Define the agent you want to create. (Prompt Agent in this case) +AgentVersionCreationOptions options = new(new PromptAgentDefinition(model: deploymentName) { Instructions = JokerInstructions }); + +// Create a server side agent version with the Azure.AI.Agents SDK client. +AgentVersion agentVersion = aiProjectClient.Agents.CreateAgentVersion(agentName: JokerName, options); + +// Retrieve an AIAgent for the created server side agent version. +AIAgent jokerAgent = aiProjectClient.GetAIAgent(agentVersion); + +// Invoke the agent with a multi-turn conversation, where the context is preserved in the thread object. +AgentThread thread = jokerAgent.GetNewThread(); +Console.WriteLine(await jokerAgent.RunAsync("Tell me a joke about a pirate.", thread)); +Console.WriteLine(await jokerAgent.RunAsync("Now add some emojis to the joke and tell it in the voice of a pirate's parrot.", thread)); + +// Invoke the agent with a multi-turn conversation and streaming, where the context is preserved in the thread object. +thread = jokerAgent.GetNewThread(); +await foreach (AgentRunResponseUpdate update in jokerAgent.RunStreamingAsync("Tell me a joke about a pirate.", thread)) +{ + Console.WriteLine(update); +} +await foreach (AgentRunResponseUpdate update in jokerAgent.RunStreamingAsync("Now add some emojis to the joke and tell it in the voice of a pirate's parrot.", thread)) +{ + Console.WriteLine(update); +} + +// Cleanup by agent name removes the agent version created. +await aiProjectClient.Agents.DeleteAgentAsync(jokerAgent.Name); diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step02_MultiturnConversation/README.md b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step02_MultiturnConversation/README.md new file mode 100644 index 0000000000..2c38002f50 --- /dev/null +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step02_MultiturnConversation/README.md @@ -0,0 +1,50 @@ +# Multi-turn Conversation with AI Agents + +This sample demonstrates how to implement multi-turn conversations with AI agents, where context is preserved across multiple agent runs using threads. + +## What this sample demonstrates + +- Creating an AI agent with instructions +- Using threads to maintain conversation context +- Running multi-turn conversations with text output +- Running multi-turn conversations with streaming output +- Managing agent lifecycle (creation and deletion) + +## Prerequisites + +Before you begin, ensure you have the following prerequisites: + +- .NET 8.0 SDK or later +- Azure Foundry 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 Foundry 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_FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" # Replace with your Azure Foundry resource endpoint +$env:AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini +``` + +## Run the sample + +Navigate to the FoundryAgents sample directory and run: + +```powershell +cd dotnet/samples/GettingStarted/FoundryAgents +dotnet run --project .\FoundryAgents_Step02_MultiturnConversation +``` + +## Expected behavior + +The sample will: + +1. Create an agent named "JokerAgent" with instructions to tell jokes +2. Create a thread for conversation context +3. Run the agent with a text prompt and display the response +4. Send a follow-up message to the same thread, demonstrating context preservation +5. Create a new thread and run the agent with streaming +6. Send a follow-up streaming message to demonstrate multi-turn streaming +7. Clean up resources by deleting the agent + diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step03.1_UsingFunctionTools/FoundryAgents_Step03.1_UsingFunctionTools.csproj b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step03.1_UsingFunctionTools/FoundryAgents_Step03.1_UsingFunctionTools.csproj new file mode 100644 index 0000000000..3ed207aadf --- /dev/null +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step03.1_UsingFunctionTools/FoundryAgents_Step03.1_UsingFunctionTools.csproj @@ -0,0 +1,20 @@ + + + + Exe + net9.0 + + enable + enable + + + + + + + + + + + + diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step03.1_UsingFunctionTools/Program.cs b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step03.1_UsingFunctionTools/Program.cs new file mode 100644 index 0000000000..e2af503905 --- /dev/null +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step03.1_UsingFunctionTools/Program.cs @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample demonstrates how to use an agent with function tools. +// It shows both non-streaming and streaming agent interactions using weather-related tools. + +using System.ComponentModel; +using Azure.AI.Projects; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +string endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_PROJECT_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; + +[Description("Get the weather for a given location.")] +static string GetWeather([Description("The location to get the weather for.")] string location) + => $"The weather in {location} is cloudy with a high of 15°C."; + +const string AssistantInstructions = "You are a helpful assistant that can get weather information."; +const string AssistantName = "WeatherAssistant"; + +// Get a client to create/retrieve/delete server side agents with Azure Foundry Agents. +AIProjectClient aiProjectClient = new(new Uri(endpoint), new AzureCliCredential()); + +// Define the agent with function tools. +AITool tool = AIFunctionFactory.Create(GetWeather); + +// Create AIAgent directly +AIAgent agent = await aiProjectClient.CreateAIAgentAsync(name: AssistantName, model: deploymentName, instructions: AssistantInstructions, tools: [tool]); + +// Non-streaming agent interaction with function tools. +AgentThread thread = agent.GetNewThread(); +Console.WriteLine(await agent.RunAsync("What is the weather like in Amsterdam?", thread)); + +// Streaming agent interaction with function tools. +thread = agent.GetNewThread(); +await foreach (AgentRunResponseUpdate update in agent.RunStreamingAsync("What is the weather like in Amsterdam?", thread)) +{ + Console.WriteLine(update); +} + +// Cleanup by agent name removes the agent version created. +await aiProjectClient.Agents.DeleteAgentAsync(agent.Name); diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step03.1_UsingFunctionTools/README.md b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step03.1_UsingFunctionTools/README.md new file mode 100644 index 0000000000..934373aa80 --- /dev/null +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step03.1_UsingFunctionTools/README.md @@ -0,0 +1,48 @@ +# Using Function Tools with AI Agents + +This sample demonstrates how to use function tools with AI agents, allowing agents to call custom functions to retrieve information. + +## What this sample demonstrates + +- Creating function tools using AIFunctionFactory +- Passing function tools to an AI agent +- Running agents with function tools (text output) +- Running agents with function tools (streaming output) +- Managing agent lifecycle (creation and deletion) + +## Prerequisites + +Before you begin, ensure you have the following prerequisites: + +- .NET 8.0 SDK or later +- Azure Foundry 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 Foundry 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_FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" # Replace with your Azure Foundry resource endpoint +$env:AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini +``` + +## Run the sample + +Navigate to the FoundryAgents sample directory and run: + +```powershell +cd dotnet/samples/GettingStarted/FoundryAgents +dotnet run --project .\FoundryAgents_Step03.1_UsingFunctionTools +``` + +## Expected behavior + +The sample will: + +1. Create an agent named "WeatherAssistant" with a GetWeather function tool +2. Run the agent with a text prompt asking about weather +3. The agent will invoke the GetWeather function tool to retrieve weather information +4. Run the agent again with streaming to display the response as it's generated +5. Clean up resources by deleting the agent + diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step03.2_UsingFunctionTools_FromOpenAPI/FoundryAgents_Step03.2_UsingFunctionTools_FromOpenAPI.csproj b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step03.2_UsingFunctionTools_FromOpenAPI/FoundryAgents_Step03.2_UsingFunctionTools_FromOpenAPI.csproj new file mode 100644 index 0000000000..90bc94a20f --- /dev/null +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step03.2_UsingFunctionTools_FromOpenAPI/FoundryAgents_Step03.2_UsingFunctionTools_FromOpenAPI.csproj @@ -0,0 +1,27 @@ + + + + Exe + net9.0 + + enable + enable + + + + + + + + + + + + + + + PreserveNewest + + + + diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step03.2_UsingFunctionTools_FromOpenAPI/OpenAPISpec.json b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step03.2_UsingFunctionTools_FromOpenAPI/OpenAPISpec.json new file mode 100644 index 0000000000..84715914da --- /dev/null +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step03.2_UsingFunctionTools_FromOpenAPI/OpenAPISpec.json @@ -0,0 +1,354 @@ +{ + "openapi": "3.0.1", + "info": { + "title": "Github Versions API", + "version": "1.0.0" + }, + "servers": [ + { + "url": "https://api.github.com" + } + ], + "components": { + "schemas": { + "basic-error": { + "title": "Basic Error", + "description": "Basic Error", + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "documentation_url": { + "type": "string" + }, + "url": { + "type": "string" + }, + "status": { + "type": "string" + } + } + }, + "label": { + "title": "Label", + "description": "Color-coded labels help you categorize and filter your issues (just like labels in Gmail).", + "type": "object", + "properties": { + "id": { + "description": "Unique identifier for the label.", + "type": "integer", + "format": "int64", + "example": 208045946 + }, + "node_id": { + "type": "string", + "example": "MDU6TGFiZWwyMDgwNDU5NDY=" + }, + "url": { + "description": "URL for the label", + "example": "https://api.github.com/repositories/42/labels/bug", + "type": "string", + "format": "uri" + }, + "name": { + "description": "The name of the label.", + "example": "bug", + "type": "string" + }, + "description": { + "description": "Optional description of the label, such as its purpose.", + "type": "string", + "example": "Something isn't working", + "nullable": true + }, + "color": { + "description": "6-character hex code, without the leading #, identifying the color", + "example": "FFFFFF", + "type": "string" + }, + "default": { + "description": "Whether this label comes by default in a new repository.", + "type": "boolean", + "example": true + } + }, + "required": [ + "id", + "node_id", + "url", + "name", + "description", + "color", + "default" + ] + }, + "tag": { + "title": "Tag", + "description": "Tag", + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "v0.1" + }, + "commit": { + "type": "object", + "properties": { + "sha": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "sha", + "url" + ] + }, + "zipball_url": { + "type": "string", + "format": "uri", + "example": "https://github.com/octocat/Hello-World/zipball/v0.1" + }, + "tarball_url": { + "type": "string", + "format": "uri", + "example": "https://github.com/octocat/Hello-World/tarball/v0.1" + }, + "node_id": { + "type": "string" + } + }, + "required": [ + "name", + "node_id", + "commit", + "zipball_url", + "tarball_url" + ] + } + }, + "examples": { + "label-items": { + "value": [ + { + "id": 208045946, + "node_id": "MDU6TGFiZWwyMDgwNDU5NDY=", + "url": "https://api.github.com/repos/octocat/Hello-World/labels/bug", + "name": "bug", + "description": "Something isn't working", + "color": "f29513", + "default": true + }, + { + "id": 208045947, + "node_id": "MDU6TGFiZWwyMDgwNDU5NDc=", + "url": "https://api.github.com/repos/octocat/Hello-World/labels/enhancement", + "name": "enhancement", + "description": "New feature or request", + "color": "a2eeef", + "default": false + } + ] + }, + "tag-items": { + "value": [ + { + "name": "v0.1", + "commit": { + "sha": "c5b97d5ae6c19d5c5df71a34c7fbeeda2479ccbc", + "url": "https://api.github.com/repos/octocat/Hello-World/commits/c5b97d5ae6c19d5c5df71a34c7fbeeda2479ccbc" + }, + "zipball_url": "https://github.com/octocat/Hello-World/zipball/v0.1", + "tarball_url": "https://github.com/octocat/Hello-World/tarball/v0.1", + "node_id": "MDQ6VXNlcjE=" + } + ] + } + }, + "parameters": { + "owner": { + "name": "owner", + "description": "The account owner of the repository. The name is not case sensitive.", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + "repo": { + "name": "repo", + "description": "The name of the repository without the `.git` extension. The name is not case sensitive.", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + "per-page": { + "name": "per_page", + "description": "The number of results per page (max 100). For more information, see \"[Using pagination in the REST API](https://docs.github.com/rest/using-the-rest-api/using-pagination-in-the-rest-api).\"", + "in": "query", + "schema": { + "type": "integer", + "default": 30 + } + }, + "page": { + "name": "page", + "description": "The page number of the results to fetch. For more information, see \"[Using pagination in the REST API](https://docs.github.com/rest/using-the-rest-api/using-pagination-in-the-rest-api).\"", + "in": "query", + "schema": { + "type": "integer", + "default": 1 + } + } + }, + "responses": { + "not_found": { + "description": "Resource not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/basic-error" + } + } + } + } + }, + "headers": { + "link": { + "example": "; rel=\"next\", ; rel=\"last\"", + "schema": { + "type": "string" + } + } + } + }, + "paths": { + "/repos/{owner}/{repo}/tags": { + "get": { + "summary": "List repository tags", + "description": "", + "tags": [ + "repos" + ], + "operationId": "repos/list-tags", + "externalDocs": { + "description": "API method documentation", + "url": "https://docs.github.com/rest/repos/repos#list-repository-tags" + }, + "parameters": [ + { + "$ref": "#/components/parameters/owner" + }, + { + "$ref": "#/components/parameters/repo" + }, + { + "$ref": "#/components/parameters/per-page" + }, + { + "$ref": "#/components/parameters/page" + } + ], + "responses": { + "200": { + "description": "Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/tag" + } + }, + "examples": { + "default": { + "$ref": "#/components/examples/tag-items" + } + } + } + }, + "headers": { + "Link": { + "$ref": "#/components/headers/link" + } + } + } + }, + "x-github": { + "githubCloudOnly": false, + "enabledForGitHubApps": true, + "category": "repos", + "subcategory": "repos" + } + } + }, + "/repos/{owner}/{repo}/labels": { + "get": { + "summary": "List labels for a repository", + "description": "Lists all labels for a repository.", + "tags": [ + "issues" + ], + "operationId": "issues/list-labels-for-repo", + "externalDocs": { + "description": "API method documentation", + "url": "https://docs.github.com/rest/issues/labels#list-labels-for-a-repository" + }, + "parameters": [ + { + "$ref": "#/components/parameters/owner" + }, + { + "$ref": "#/components/parameters/repo" + }, + { + "$ref": "#/components/parameters/per-page" + }, + { + "$ref": "#/components/parameters/page" + } + ], + "responses": { + "200": { + "description": "Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/label" + } + }, + "examples": { + "default": { + "$ref": "#/components/examples/label-items" + } + } + } + }, + "headers": { + "Link": { + "$ref": "#/components/headers/link" + } + } + }, + "404": { + "$ref": "#/components/responses/not_found" + } + }, + "x-github": { + "githubCloudOnly": false, + "enabledForGitHubApps": true, + "category": "issues", + "subcategory": "labels" + } + } + } + } +} \ No newline at end of file diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step03.2_UsingFunctionTools_FromOpenAPI/Program.cs b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step03.2_UsingFunctionTools_FromOpenAPI/Program.cs new file mode 100644 index 0000000000..f9540d1725 --- /dev/null +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step03.2_UsingFunctionTools_FromOpenAPI/Program.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample demonstrates how to use an agent with function tools provided via an OpenAPI spec. +// It uses functionality from Semantic Kernel to parse the OpenAPI spec and create function tools to use with the Agent Framework Agent. + +using Azure.AI.Projects; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Plugins.OpenApi; + +string endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_PROJECT_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; + +// Load the OpenAPI Spec from a file. +KernelPlugin plugin = await OpenApiKernelPluginFactory.CreateFromOpenApiAsync("github", "OpenAPISpec.json"); + +// Convert the Semantic Kernel plugin to Agent Framework function tools. +// This requires a dummy Kernel instance, since KernelFunctions cannot execute without one. +Kernel kernel = new(); +List tools = plugin.Select(x => x.WithKernel(kernel)).Cast().ToList(); + +const string AssistantInstructions = "You are a helpful assistant that can query GitHub repositories."; +const string AssistantName = "GitHubAssistant"; + +// Get a client to create/retrieve/delete server side agents with Azure Foundry Agents. +AIProjectClient aiProjectClient = new(new Uri(endpoint), new AzureCliCredential()); + +// Create AIAgent directly +AIAgent agent = await aiProjectClient.CreateAIAgentAsync(name: AssistantName, model: deploymentName, instructions: AssistantInstructions, tools: tools); + +// Run the agent with the OpenAPI function tools. +AgentThread thread = agent.GetNewThread(); +Console.WriteLine(await agent.RunAsync("Please list the names, colors and descriptions of all the labels available in the microsoft/agent-framework repository on github.", thread)); + +// Cleanup by agent name removes the agent version created. +await aiProjectClient.Agents.DeleteAgentAsync(agent.Name); diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step03.2_UsingFunctionTools_FromOpenAPI/README.md b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step03.2_UsingFunctionTools_FromOpenAPI/README.md new file mode 100644 index 0000000000..68096e495f --- /dev/null +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step03.2_UsingFunctionTools_FromOpenAPI/README.md @@ -0,0 +1,49 @@ +# Using Function Tools from OpenAPI Specifications + +This sample demonstrates how to create function tools from an OpenAPI specification and use them with AI agents. + +## What this sample demonstrates + +- Loading OpenAPI specifications from files +- Converting OpenAPI specifications to Semantic Kernel plugins +- Converting Semantic Kernel plugins to AI function tools +- Using OpenAPI-based function tools with AI agents +- Managing agent lifecycle (creation and deletion) + +## Prerequisites + +Before you begin, ensure you have the following prerequisites: + +- .NET 8.0 SDK or later +- Azure Foundry 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 Foundry 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_FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" # Replace with your Azure Foundry resource endpoint +$env:AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini +``` + +## Run the sample + +Navigate to the FoundryAgents sample directory and run: + +```powershell +cd dotnet/samples/GettingStarted/FoundryAgents +dotnet run --project .\FoundryAgents_Step03.2_UsingFunctionTools_FromOpenAPI +``` + +## Expected behavior + +The sample will: + +1. Load the OpenAPI specification from OpenAPISpec.json (GitHub API) +2. Convert the OpenAPI spec to Semantic Kernel plugins +3. Create an agent named "GitHubAssistant" with the OpenAPI-based function tools +4. Run the agent with a prompt to query GitHub repositories +5. The agent will invoke the appropriate OpenAPI function tools to retrieve data +6. Clean up resources by deleting the agent + diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step04_UsingFunctionToolsWithApprovals/FoundryAgents_Step04_UsingFunctionToolsWithApprovals.csproj b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step04_UsingFunctionToolsWithApprovals/FoundryAgents_Step04_UsingFunctionToolsWithApprovals.csproj new file mode 100644 index 0000000000..3ed207aadf --- /dev/null +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step04_UsingFunctionToolsWithApprovals/FoundryAgents_Step04_UsingFunctionToolsWithApprovals.csproj @@ -0,0 +1,20 @@ + + + + Exe + net9.0 + + enable + enable + + + + + + + + + + + + diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step04_UsingFunctionToolsWithApprovals/Program.cs b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step04_UsingFunctionToolsWithApprovals/Program.cs new file mode 100644 index 0000000000..bd72a2c940 --- /dev/null +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step04_UsingFunctionToolsWithApprovals/Program.cs @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample demonstrates how to use an agent with function tools that require a human in the loop for approvals. +// It shows both non-streaming and streaming agent interactions using weather-related tools. +// If the agent is hosted in a service, with a remote user, combine this sample with the Persisted Conversations sample to persist the chat history +// while the agent is waiting for user input. + +using System.ComponentModel; +using Azure.AI.Projects; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +string endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_PROJECT_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; + +// Create a sample function tool that the agent can use. +[Description("Get the weather for a given location.")] +static string GetWeather([Description("The location to get the weather for.")] string location) + => $"The weather in {location} is cloudy with a high of 15°C."; + +const string AssistantInstructions = "You are a helpful assistant that can get weather information."; +const string AssistantName = "WeatherAssistant"; + +// Get a client to create/retrieve/delete server side agents with Azure Foundry Agents. +AIProjectClient aiProjectClient = new(new Uri(endpoint), new AzureCliCredential()); + +ApprovalRequiredAIFunction approvalTool = new(AIFunctionFactory.Create(GetWeather)); + +// Create AIAgent directly +AIAgent agent = await aiProjectClient.CreateAIAgentAsync(name: AssistantName, model: deploymentName, instructions: AssistantInstructions, tools: [approvalTool]); + +// Call the agent with approval-required function tools. +// The agent will request approval before invoking the function. +AgentThread thread = agent.GetNewThread(); +AgentRunResponse response = await agent.RunAsync("What is the weather like in Amsterdam?", thread); + +// Check if there are any user input requests (approvals needed). +List userInputRequests = response.UserInputRequests.ToList(); + +while (userInputRequests.Count > 0) +{ + // Ask the user to approve each function call request. + // For simplicity, we are assuming here that only function approval requests are being made. + List userInputMessages = userInputRequests + .OfType() + .Select(functionApprovalRequest => + { + Console.WriteLine($"The agent would like to invoke the following function, please reply Y to approve: Name {functionApprovalRequest.FunctionCall.Name}"); + bool approved = Console.ReadLine()?.Equals("Y", StringComparison.OrdinalIgnoreCase) ?? false; + return new ChatMessage(ChatRole.User, [functionApprovalRequest.CreateResponse(approved)]); + }) + .ToList(); + + // Pass the user input responses back to the agent for further processing. + response = await agent.RunAsync(userInputMessages, thread); + + userInputRequests = response.UserInputRequests.ToList(); +} + +Console.WriteLine($"\nAgent: {response}"); + +// Cleanup by agent name removes the agent version created. +await aiProjectClient.Agents.DeleteAgentAsync(agent.Name); diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step04_UsingFunctionToolsWithApprovals/README.md b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step04_UsingFunctionToolsWithApprovals/README.md new file mode 100644 index 0000000000..55aac6c8df --- /dev/null +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step04_UsingFunctionToolsWithApprovals/README.md @@ -0,0 +1,51 @@ +# Using Function Tools with Approvals (Human-in-the-Loop) + +This sample demonstrates how to use function tools that require human approval before execution, implementing a human-in-the-loop workflow. + +## What this sample demonstrates + +- Creating approval-required function tools using ApprovalRequiredAIFunction +- Handling user input requests for function approvals +- Implementing human-in-the-loop approval workflows +- Processing agent responses with pending approvals +- Managing agent lifecycle (creation and deletion) + +## Prerequisites + +Before you begin, ensure you have the following prerequisites: + +- .NET 8.0 SDK or later +- Azure Foundry 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 Foundry 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_FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" # Replace with your Azure Foundry resource endpoint +$env:AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini +``` + +## Run the sample + +Navigate to the FoundryAgents sample directory and run: + +```powershell +cd dotnet/samples/GettingStarted/FoundryAgents +dotnet run --project .\FoundryAgents_Step04_UsingFunctionToolsWithApprovals +``` + +## Expected behavior + +The sample will: + +1. Create an agent named "WeatherAssistant" with an approval-required GetWeather function tool +2. Run the agent with a prompt asking about weather +3. The agent will request approval before invoking the GetWeather function +4. The sample will prompt the user to approve or deny the function call (enter 'Y' to approve) +5. After approval, the function will be executed and the result returned to the agent +6. Clean up resources by deleting the agent + +**Note**: For hosted agents with remote users, combine this sample with the Persisted Conversations sample to persist chat history while waiting for user approval. + diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step05_StructuredOutput/FoundryAgents_Step05_StructuredOutput.csproj b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step05_StructuredOutput/FoundryAgents_Step05_StructuredOutput.csproj new file mode 100644 index 0000000000..3ed207aadf --- /dev/null +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step05_StructuredOutput/FoundryAgents_Step05_StructuredOutput.csproj @@ -0,0 +1,20 @@ + + + + Exe + net9.0 + + enable + enable + + + + + + + + + + + + diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step05_StructuredOutput/Program.cs b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step05_StructuredOutput/Program.cs new file mode 100644 index 0000000000..0edbed70e8 --- /dev/null +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step05_StructuredOutput/Program.cs @@ -0,0 +1,87 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample shows how to configure an agent to produce structured output. + +using System.ComponentModel; +using System.Text.Json; +using System.Text.Json.Serialization; +using Azure.AI.Projects; +using Azure.Identity; +using Microsoft.Agents.AI; +using SampleApp; + +#pragma warning disable CA5399 + +string endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_PROJECT_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; + +const string AssistantInstructions = "You are a helpful assistant that extracts structured information about people."; +const string AssistantName = "StructuredOutputAssistant"; + +// Get a client to create/retrieve/delete server side agents with Azure Foundry Agents. +AIProjectClient aiProjectClient = new(new Uri(endpoint), new AzureCliCredential()); + +// Create ChatClientAgent directly +ChatClientAgent agent = await aiProjectClient.CreateAIAgentAsync( + model: deploymentName, + new ChatClientAgentOptions(name: AssistantName, instructions: AssistantInstructions) + { + ChatOptions = new() + { + ResponseFormat = Microsoft.Extensions.AI.ChatResponseFormat.ForJsonSchema() + } + }); + +// Set PersonInfo as the type parameter of RunAsync method to specify the expected structured output from the agent and invoke the agent with some unstructured input. +AgentRunResponse response = await agent.RunAsync("Please provide information about John Smith, who is a 35-year-old software engineer."); + +// Access the structured output via the Result property of the agent response. +Console.WriteLine("Assistant Output:"); +Console.WriteLine($"Name: {response.Result.Name}"); +Console.WriteLine($"Age: {response.Result.Age}"); +Console.WriteLine($"Occupation: {response.Result.Occupation}"); + +// Create the ChatClientAgent with the specified name, instructions, and expected structured output the agent should produce. +ChatClientAgent agentWithPersonInfo = aiProjectClient.CreateAIAgent( + model: deploymentName, + new ChatClientAgentOptions(name: AssistantName, instructions: AssistantInstructions) + { + ChatOptions = new() + { + ResponseFormat = Microsoft.Extensions.AI.ChatResponseFormat.ForJsonSchema() + } + }); + +// Invoke the agent with some unstructured input while streaming, to extract the structured information from. +IAsyncEnumerable updates = agentWithPersonInfo.RunStreamingAsync("Please provide information about John Smith, who is a 35-year-old software engineer."); + +// Assemble all the parts of the streamed output, since we can only deserialize once we have the full json, +// then deserialize the response into the PersonInfo class. +PersonInfo personInfo = (await updates.ToAgentRunResponseAsync()).Deserialize(JsonSerializerOptions.Web); + +Console.WriteLine("Assistant Output:"); +Console.WriteLine($"Name: {personInfo.Name}"); +Console.WriteLine($"Age: {personInfo.Age}"); +Console.WriteLine($"Occupation: {personInfo.Occupation}"); + +// Cleanup by agent name removes the agent version created. +await aiProjectClient.Agents.DeleteAgentAsync(agent.Name); + +namespace SampleApp +{ + /// + /// Represents information about a person, including their name, age, and occupation, matched to the JSON schema used in the agent. + /// + [Description("Information about a person including their name, age, and occupation")] + public class PersonInfo + { + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("age")] + public int? Age { get; set; } + + [JsonPropertyName("occupation")] + public string? Occupation { get; set; } + } +} diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step05_StructuredOutput/README.md b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step05_StructuredOutput/README.md new file mode 100644 index 0000000000..57887fc0d3 --- /dev/null +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step05_StructuredOutput/README.md @@ -0,0 +1,49 @@ +# Structured Output with AI Agents + +This sample demonstrates how to configure AI agents to produce structured output in JSON format using JSON schemas. + +## What this sample demonstrates + +- Configuring agents with JSON schema response formats +- Using generic RunAsync method for structured output +- Deserializing structured responses into typed objects +- Running agents with streaming and structured output +- Managing agent lifecycle (creation and deletion) + +## Prerequisites + +Before you begin, ensure you have the following prerequisites: + +- .NET 8.0 SDK or later +- Azure Foundry 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 Foundry 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_FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" # Replace with your Azure Foundry resource endpoint +$env:AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini +``` + +## Run the sample + +Navigate to the FoundryAgents sample directory and run: + +```powershell +cd dotnet/samples/GettingStarted/FoundryAgents +dotnet run --project .\FoundryAgents_Step05_StructuredOutput +``` + +## Expected behavior + +The sample will: + +1. Create an agent named "StructuredOutputAssistant" configured to produce JSON output +2. Run the agent with a prompt to extract person information +3. Deserialize the JSON response into a PersonInfo object +4. Display the structured data (Name, Age, Occupation) +5. Run the agent again with streaming and deserialize the streamed JSON response +6. Clean up resources by deleting the agent + diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step06_PersistedConversations/FoundryAgents_Step06_PersistedConversations.csproj b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step06_PersistedConversations/FoundryAgents_Step06_PersistedConversations.csproj new file mode 100644 index 0000000000..3ed207aadf --- /dev/null +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step06_PersistedConversations/FoundryAgents_Step06_PersistedConversations.csproj @@ -0,0 +1,20 @@ + + + + Exe + net9.0 + + enable + enable + + + + + + + + + + + + diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step06_PersistedConversations/Program.cs b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step06_PersistedConversations/Program.cs new file mode 100644 index 0000000000..305422aa4d --- /dev/null +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step06_PersistedConversations/Program.cs @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample shows how to create and use a simple AI agent with a conversation that can be persisted to disk. + +using System.Text.Json; +using Azure.AI.Projects; +using Azure.Identity; +using Microsoft.Agents.AI; + +string endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_PROJECT_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; + +const string JokerInstructions = "You are good at telling jokes."; +const string JokerName = "JokerAgent"; + +// Get a client to create/retrieve/delete server side agents with Azure Foundry Agents. +AIProjectClient aiProjectClient = new(new Uri(endpoint), new AzureCliCredential()); + +AIAgent agent = await aiProjectClient.CreateAIAgentAsync(name: JokerName, model: deploymentName, instructions: JokerInstructions); + +// Start a new thread for the agent conversation. +AgentThread thread = agent.GetNewThread(); + +// Run the agent with a new thread. +Console.WriteLine(await agent.RunAsync("Tell me a joke about a pirate.", thread)); + +// Serialize the thread state to a JsonElement, so it can be stored for later use. +JsonElement serializedThread = thread.Serialize(); + +// Save the serialized thread to a temporary file (for demonstration purposes). +string tempFilePath = Path.GetTempFileName(); +await File.WriteAllTextAsync(tempFilePath, JsonSerializer.Serialize(serializedThread)); + +// Load the serialized thread from the temporary file (for demonstration purposes). +JsonElement reloadedSerializedThread = JsonSerializer.Deserialize(await File.ReadAllTextAsync(tempFilePath))!; + +// Deserialize the thread state after loading from storage. +AgentThread resumedThread = agent.DeserializeThread(reloadedSerializedThread); + +// Run the agent again with the resumed thread. +Console.WriteLine(await agent.RunAsync("Now tell the same joke in the voice of a pirate, and add some emojis to the joke.", resumedThread)); + +// Cleanup by agent name removes the agent version created. +await aiProjectClient.Agents.DeleteAgentAsync(agent.Name); diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step06_PersistedConversations/README.md b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step06_PersistedConversations/README.md new file mode 100644 index 0000000000..f0ae590545 --- /dev/null +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step06_PersistedConversations/README.md @@ -0,0 +1,50 @@ +# Persisted Conversations with AI Agents + +This sample demonstrates how to serialize and persist agent conversation threads to storage, allowing conversations to be resumed later. + +## What this sample demonstrates + +- Serializing agent threads to JSON +- Persisting thread state to disk +- Loading and deserializing thread state from storage +- Resuming conversations with persisted threads +- Managing agent lifecycle (creation and deletion) + +## Prerequisites + +Before you begin, ensure you have the following prerequisites: + +- .NET 8.0 SDK or later +- Azure Foundry 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 Foundry 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_FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" # Replace with your Azure Foundry resource endpoint +$env:AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini +``` + +## Run the sample + +Navigate to the FoundryAgents sample directory and run: + +```powershell +cd dotnet/samples/GettingStarted/FoundryAgents +dotnet run --project .\FoundryAgents_Step06_PersistedConversations +``` + +## Expected behavior + +The sample will: + +1. Create an agent named "JokerAgent" with instructions to tell jokes +2. Create a thread and run the agent with an initial prompt +3. Serialize the thread state to JSON +4. Save the serialized thread to a temporary file +5. Load the thread from the file and deserialize it +6. Resume the conversation with the same thread using a follow-up prompt +7. Clean up resources by deleting the agent + diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step07_Observability/FoundryAgents_Step07_Observability.csproj b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step07_Observability/FoundryAgents_Step07_Observability.csproj new file mode 100644 index 0000000000..49b903d041 --- /dev/null +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step07_Observability/FoundryAgents_Step07_Observability.csproj @@ -0,0 +1,23 @@ + + + + Exe + net9.0 + + enable + enable + + + + + + + + + + + + + + + diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step07_Observability/Program.cs b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step07_Observability/Program.cs new file mode 100644 index 0000000000..eb011ba064 --- /dev/null +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step07_Observability/Program.cs @@ -0,0 +1,52 @@ +// 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 that logs telemetry using OpenTelemetry. + +using Azure.AI.Projects; +using Azure.Identity; +using Azure.Monitor.OpenTelemetry.Exporter; +using Microsoft.Agents.AI; +using OpenTelemetry; +using OpenTelemetry.Trace; + +string endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_PROJECT_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +string? applicationInsightsConnectionString = Environment.GetEnvironmentVariable("APPLICATIONINSIGHTS_CONNECTION_STRING"); + +const string JokerInstructions = "You are good at telling jokes."; +const string JokerName = "JokerAgent"; + +// Create TracerProvider with console exporter +// This will output the telemetry data to the console. +string sourceName = Guid.NewGuid().ToString("N"); +TracerProviderBuilder tracerProviderBuilder = Sdk.CreateTracerProviderBuilder() + .AddSource(sourceName) + .AddConsoleExporter(); +if (!string.IsNullOrWhiteSpace(applicationInsightsConnectionString)) +{ + tracerProviderBuilder.AddAzureMonitorTraceExporter(options => options.ConnectionString = applicationInsightsConnectionString); +} +using var tracerProvider = tracerProviderBuilder.Build(); + +// Get a client to create/retrieve/delete server side agents with Azure Foundry Agents. +AIProjectClient aiProjectClient = new(new Uri(endpoint), new AzureCliCredential()); + +// Define the agent you want to create. (Prompt Agent in this case) +AIAgent agent = aiProjectClient.CreateAIAgent(name: JokerName, model: deploymentName, instructions: JokerInstructions) + .AsBuilder() + .UseOpenTelemetry(sourceName: sourceName) + .Build(); + +// Invoke the agent and output the text result. +AgentThread thread = agent.GetNewThread(); +Console.WriteLine(await agent.RunAsync("Tell me a joke about a pirate.", thread)); + +// Invoke the agent with streaming support. +thread = agent.GetNewThread(); +await foreach (AgentRunResponseUpdate update in agent.RunStreamingAsync("Tell me a joke about a pirate.", thread)) +{ + Console.WriteLine(update); +} + +// Cleanup by agent name removes the agent version created. +await aiProjectClient.Agents.DeleteAgentAsync(agent.Name); diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step07_Observability/README.md b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step07_Observability/README.md new file mode 100644 index 0000000000..57d4e5df13 --- /dev/null +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step07_Observability/README.md @@ -0,0 +1,51 @@ +# Observability with OpenTelemetry + +This sample demonstrates how to add observability to AI agents using OpenTelemetry for tracing and monitoring. + +## What this sample demonstrates + +- Setting up OpenTelemetry TracerProvider +- Configuring console exporter for telemetry output +- Configuring Azure Monitor exporter for Application Insights +- Adding OpenTelemetry middleware to agents +- Running agents with telemetry collection (text and streaming) +- Managing agent lifecycle (creation and deletion) + +## Prerequisites + +Before you begin, ensure you have the following prerequisites: + +- .NET 8.0 SDK or later +- Azure Foundry service endpoint and deployment configured +- Azure CLI installed and authenticated (for Azure credential authentication) +- (Optional) Application Insights connection string for Azure Monitor integration + +**Note**: This demo uses Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Azure Foundry 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_FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" # Replace with your Azure Foundry resource endpoint +$env:AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini +$env:APPLICATIONINSIGHTS_CONNECTION_STRING="your-connection-string" # Optional, for Azure Monitor integration +``` + +## Run the sample + +Navigate to the FoundryAgents sample directory and run: + +```powershell +cd dotnet/samples/GettingStarted/FoundryAgents +dotnet run --project .\FoundryAgents_Step07_Observability +``` + +## Expected behavior + +The sample will: + +1. Create a TracerProvider with console exporter (and optionally Azure Monitor exporter) +2. Create an agent named "JokerAgent" with OpenTelemetry middleware +3. Run the agent with a text prompt and display telemetry traces to console +4. Run the agent again with streaming and display telemetry traces +5. Clean up resources by deleting the agent + diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step08_DependencyInjection/FoundryAgents_Step08_DependencyInjection.csproj b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step08_DependencyInjection/FoundryAgents_Step08_DependencyInjection.csproj new file mode 100644 index 0000000000..ea8fc63de0 --- /dev/null +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step08_DependencyInjection/FoundryAgents_Step08_DependencyInjection.csproj @@ -0,0 +1,23 @@ + + + + Exe + net9.0 + + enable + enable + + $(NoWarn);CA1812 + + + + + + + + + + + + + diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step08_DependencyInjection/Program.cs b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step08_DependencyInjection/Program.cs new file mode 100644 index 0000000000..4bf4843d66 --- /dev/null +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step08_DependencyInjection/Program.cs @@ -0,0 +1,82 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample shows how to use dependency injection to register an AIAgent and use it from a hosted service with a user input chat loop. + +using Azure.AI.Projects; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +string endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_PROJECT_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; + +const string JokerInstructions = "You are good at telling jokes."; +const string JokerName = "JokerAgent"; + +// Create a host builder that we will register services with and then run. +HostApplicationBuilder builder = Host.CreateApplicationBuilder(args); + +// Add the agents client to the service collection. +builder.Services.AddSingleton((sp) => new AIProjectClient(new Uri(endpoint), new AzureCliCredential())); + +// Add the AI agent to the service collection. +builder.Services.AddSingleton((sp) + => sp.GetRequiredService() + .CreateAIAgent(name: JokerName, model: deploymentName, instructions: JokerInstructions)); + +// Add a sample service that will use the agent to respond to user input. +builder.Services.AddHostedService(); + +// Build and run the host. +using IHost host = builder.Build(); +await host.RunAsync().ConfigureAwait(false); + +/// +/// A sample service that uses an AI agent to respond to user input. +/// +internal sealed class SampleService(AIProjectClient client, AIAgent agent, IHostApplicationLifetime appLifetime) : IHostedService +{ + private AgentThread? _thread; + + public async Task StartAsync(CancellationToken cancellationToken) + { + // Create a thread that will be used for the entirety of the service lifetime so that the user can ask follow up questions. + this._thread = agent.GetNewThread(); + _ = this.RunAsync(appLifetime.ApplicationStopping); + } + + public async Task RunAsync(CancellationToken cancellationToken) + { + // Delay a little to allow the service to finish starting. + await Task.Delay(100, cancellationToken); + + while (!cancellationToken.IsCancellationRequested) + { + Console.WriteLine("\nAgent: Ask me to tell you a joke about a specific topic. To exit just press Ctrl+C or enter without any input.\n"); + Console.Write("> "); + string? input = Console.ReadLine(); + + // If the user enters no input, signal the application to shut down. + if (string.IsNullOrWhiteSpace(input)) + { + appLifetime.StopApplication(); + break; + } + + // Stream the output to the console as it is generated. + await foreach (AgentRunResponseUpdate update in agent.RunStreamingAsync(input, this._thread, cancellationToken: cancellationToken)) + { + Console.Write(update); + } + + Console.WriteLine(); + } + } + + public async Task StopAsync(CancellationToken cancellationToken) + { + Console.WriteLine("\nDeleting agent ..."); + await client.Agents.DeleteAgentAsync(agent.Name, cancellationToken).ConfigureAwait(false); + } +} diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step08_DependencyInjection/README.md b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step08_DependencyInjection/README.md new file mode 100644 index 0000000000..ab2b01e5d1 --- /dev/null +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step08_DependencyInjection/README.md @@ -0,0 +1,51 @@ +# Dependency Injection with AI Agents + +This sample demonstrates how to use dependency injection to register and manage AI agents within a hosted service application. + +## What this sample demonstrates + +- Setting up dependency injection with HostApplicationBuilder +- Registering AIProjectClient as a singleton service +- Registering AIAgent as a singleton service +- Using agents in hosted services +- Interactive chat loop with streaming responses +- Managing agent lifecycle (creation and deletion) + +## Prerequisites + +Before you begin, ensure you have the following prerequisites: + +- .NET 8.0 SDK or later +- Azure Foundry 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 Foundry 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_FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" # Replace with your Azure Foundry resource endpoint +$env:AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini +``` + +## Run the sample + +Navigate to the FoundryAgents sample directory and run: + +```powershell +cd dotnet/samples/GettingStarted/FoundryAgents +dotnet run --project .\FoundryAgents_Step08_DependencyInjection +``` + +## Expected behavior + +The sample will: + +1. Create a host with dependency injection configured +2. Register AIProjectClient and AIAgent as services +3. Create an agent named "JokerAgent" with instructions to tell jokes +4. Start an interactive chat loop where you can ask the agent questions +5. The agent will respond with streaming output +6. Enter an empty line or press Ctrl+C to exit +7. Clean up resources by deleting the agent + diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step09_UsingMcpClientAsTools/FoundryAgents_Step09_UsingMcpClientAsTools.csproj b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step09_UsingMcpClientAsTools/FoundryAgents_Step09_UsingMcpClientAsTools.csproj new file mode 100644 index 0000000000..0ee9c80764 --- /dev/null +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step09_UsingMcpClientAsTools/FoundryAgents_Step09_UsingMcpClientAsTools.csproj @@ -0,0 +1,23 @@ + + + + Exe + net9.0 + + enable + enable + 3afc9b74-af74-4d8e-ae96-fa1c511d11ac + + + + + + + + + + + + + + diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step09_UsingMcpClientAsTools/Program.cs b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step09_UsingMcpClientAsTools/Program.cs new file mode 100644 index 0000000000..a821c1194b --- /dev/null +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step09_UsingMcpClientAsTools/Program.cs @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample shows how to expose an AI agent as an MCP tool. + +using Azure.AI.Projects; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using ModelContextProtocol.Client; + +string endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_PROJECT_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; + +Console.WriteLine("Starting MCP Stdio for @modelcontextprotocol/server-github ... "); + +// Create an MCPClient for the GitHub server +await using var mcpClient = await McpClient.CreateAsync(new StdioClientTransport(new() +{ + Name = "MCPServer", + Command = "npx", + Arguments = ["-y", "--verbose", "@modelcontextprotocol/server-github"], +})); + +// Retrieve the list of tools available on the GitHub server +IList mcpTools = await mcpClient.ListToolsAsync(); +string agentName = "AgentWithMCP"; +// Get a client to create/retrieve/delete server side agents with Azure Foundry Agents. +AIProjectClient aiProjectClient = new(new Uri(endpoint), new AzureCliCredential()); + +Console.WriteLine($"Creating the agent '{agentName}' ..."); + +// Define the agent you want to create. (Prompt Agent in this case) +AIAgent agent = aiProjectClient.CreateAIAgent( + name: agentName, + model: deploymentName, + instructions: "You answer questions related to GitHub repositories only.", + tools: [.. mcpTools.Cast()]); + +string prompt = "Summarize the last four commits to the microsoft/semantic-kernel repository?"; + +Console.WriteLine($"Invoking agent '{agent.Name}' with prompt: {prompt} ..."); + +// Invoke the agent and output the text result. +Console.WriteLine(await agent.RunAsync(prompt)); + +// Clean up the agent after use. +await aiProjectClient.Agents.DeleteAgentAsync(agent.Name); diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step09_UsingMcpClientAsTools/README.md b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step09_UsingMcpClientAsTools/README.md new file mode 100644 index 0000000000..9b3322b3fb --- /dev/null +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step09_UsingMcpClientAsTools/README.md @@ -0,0 +1,50 @@ +# Using MCP Client Tools with AI Agents + +This sample demonstrates how to use Model Context Protocol (MCP) client tools with AI agents, allowing agents to access tools provided by MCP servers. This sample uses the GitHub MCP server to provide tools for querying GitHub repositories. + +## What this sample demonstrates + +- Creating MCP clients to connect to MCP servers (GitHub server) +- Retrieving tools from MCP servers +- Using MCP tools with AI agents +- Running agents with MCP-provided function tools +- Managing agent lifecycle (creation and deletion) + +## Prerequisites + +Before you begin, ensure you have the following prerequisites: + +- .NET 8.0 SDK or later +- Azure Foundry service endpoint and deployment configured +- Azure CLI installed and authenticated (for Azure credential authentication) +- Node.js and npm installed (for running the GitHub MCP server) + +**Note**: This demo uses Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Azure Foundry 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_FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" # Replace with your Azure Foundry resource endpoint +$env:AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini +``` + +## Run the sample + +Navigate to the FoundryAgents sample directory and run: + +```powershell +cd dotnet/samples/GettingStarted/FoundryAgents +dotnet run --project .\FoundryAgents_Step09_UsingMcpClientAsTools +``` + +## Expected behavior + +The sample will: + +1. Start the GitHub MCP server using `@modelcontextprotocol/server-github` +2. Create an MCP client to connect to the GitHub server +3. Retrieve the available tools from the GitHub MCP server +4. Create an agent named "AgentWithMCP" with the GitHub tools +5. Run the agent with a prompt to summarize the last four commits to the microsoft/semantic-kernel repository +6. The agent will use the GitHub MCP tools to query the repository information +7. Clean up resources by deleting the agent \ No newline at end of file diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step10_UsingImages/FoundryAgents_Step10_UsingImages.csproj b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step10_UsingImages/FoundryAgents_Step10_UsingImages.csproj new file mode 100644 index 0000000000..d0cd81c352 --- /dev/null +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step10_UsingImages/FoundryAgents_Step10_UsingImages.csproj @@ -0,0 +1,20 @@ + + + + Exe + net9.0 + + enable + enable + + + + + + + + + + + + diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step10_UsingImages/Program.cs b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step10_UsingImages/Program.cs new file mode 100644 index 0000000000..52ee408a25 --- /dev/null +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step10_UsingImages/Program.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample shows how to use Image Multi-Modality with an AI agent. + +using Azure.AI.Projects; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +string endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_PROJECT_ENDPOINT is not set."); +string deploymentName = System.Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME") ?? "gpt-4o"; + +const string VisionInstructions = "You are a helpful agent that can analyze images"; +const string VisionName = "VisionAgent"; + +// Get a client to create/retrieve/delete server side agents with Azure Foundry Agents. +AIProjectClient aiProjectClient = new(new Uri(endpoint), new AzureCliCredential()); + +// Define the agent you want to create. (Prompt Agent in this case) +AIAgent agent = aiProjectClient.CreateAIAgent(name: VisionName, model: deploymentName, instructions: VisionInstructions); + +ChatMessage message = new(ChatRole.User, [ + new TextContent("What do you see in this image?"), + new UriContent("https://upload.wikimedia.org/wikipedia/commons/thumb/d/dd/Gfp-wisconsin-madison-the-nature-boardwalk.jpg/2560px-Gfp-wisconsin-madison-the-nature-boardwalk.jpg", "image/jpeg") +]); + +AgentThread thread = agent.GetNewThread(); + +await foreach (AgentRunResponseUpdate update in agent.RunStreamingAsync(message, thread)) +{ + Console.WriteLine(update); +} + +// Cleanup by agent name removes the agent version created. +await aiProjectClient.Agents.DeleteAgentAsync(agent.Name); diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step10_UsingImages/README.md b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step10_UsingImages/README.md new file mode 100644 index 0000000000..d90f5cf208 --- /dev/null +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step10_UsingImages/README.md @@ -0,0 +1,53 @@ +# Using Images with AI Agents + +This sample demonstrates how to use image multi-modality with an AI agent. It shows how to create a vision-enabled agent that can analyze and describe images using Azure Foundry Agents. + +## What this sample demonstrates + +- Creating a vision-enabled AI agent with image analysis capabilities +- Sending both text and image content to an agent in a single message +- Using `UriContent` for URI-referenced images +- Processing multimodal input (text + image) with an AI agent +- Managing agent lifecycle (creation and deletion) + +## Key features + +- **Vision Agent**: Creates an agent specifically instructed to analyze images +- **Multimodal Input**: Combines text questions with image URI in a single message +- **Azure Foundry Agents Integration**: Uses Azure Foundry Agents with vision capabilities + +## Prerequisites + +Before running this sample, ensure you have: + +1. An Azure OpenAI project set up +2. A compatible model deployment (e.g., gpt-4o) +3. Azure CLI installed and authenticated + +## Environment Variables + +Set the following environment variables: + +```powershell +$env:AZURE_FOUNDRY_PROJECT_ENDPOINT="https://your-resource.openai.azure.com/" # Replace with your Azure Foundry Project endpoint +$env:AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME="gpt-4o" # Replace with your model deployment name (optional, defaults to gpt-4o) +``` + +## Run the sample + +Navigate to the FoundryAgents sample directory and run: + +```powershell +cd dotnet/samples/GettingStarted/FoundryAgents +dotnet run --project .\FoundryAgents_Step10_UsingImages +``` + +## Expected behavior + +The sample will: + +1. Create a vision-enabled agent named "VisionAgent" +2. Send a message containing both text ("What do you see in this image?") and a URI-referenced image of a green walkway (nature boardwalk) +3. The agent will analyze the image and provide a description +4. Clean up resources by deleting the agent + diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step11_AsFunctionTool/FoundryAgents_Step11_AsFunctionTool.csproj b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step11_AsFunctionTool/FoundryAgents_Step11_AsFunctionTool.csproj new file mode 100644 index 0000000000..f9336d4556 --- /dev/null +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step11_AsFunctionTool/FoundryAgents_Step11_AsFunctionTool.csproj @@ -0,0 +1,21 @@ + + + + Exe + net9.0 + + enable + enable + 3afc9b74-af74-4d8e-ae96-fa1c511d11ac + + + + + + + + + + + + diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step11_AsFunctionTool/Program.cs b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step11_AsFunctionTool/Program.cs new file mode 100644 index 0000000000..9fb589f5ce --- /dev/null +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step11_AsFunctionTool/Program.cs @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample shows how to create and use an Azure Foundry Agents AI agent as a function tool. + +using System.ComponentModel; +using Azure.AI.Projects; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +string endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_PROJECT_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; + +const string WeatherInstructions = "You answer questions about the weather."; +const string WeatherName = "WeatherAgent"; +const string MainInstructions = "You are a helpful assistant who responds in French."; +const string MainName = "MainAgent"; + +[Description("Get the weather for a given location.")] +static string GetWeather([Description("The location to get the weather for.")] string location) + => $"The weather in {location} is cloudy with a high of 15°C."; + +// Get a client to create/retrieve/delete server side agents with Azure Foundry Agents. +AIProjectClient aiProjectClient = new(new Uri(endpoint), new AzureCliCredential()); + +// Create the weather agent with function tools. +AITool weatherTool = AIFunctionFactory.Create(GetWeather); +AIAgent weatherAgent = aiProjectClient.CreateAIAgent( + name: WeatherName, + model: deploymentName, + instructions: WeatherInstructions, + tools: [weatherTool]); + +// Create the main agent, and provide the weather agent as a function tool. +AIAgent agent = aiProjectClient.CreateAIAgent( + name: MainName, + model: deploymentName, + instructions: MainInstructions, + tools: [weatherAgent.AsAIFunction()]); + +// Invoke the agent and output the text result. +AgentThread thread = agent.GetNewThread(); +Console.WriteLine(await agent.RunAsync("What is the weather like in Amsterdam?", thread)); + +// Cleanup by agent name removes the agent versions created. +await aiProjectClient.Agents.DeleteAgentAsync(agent.Name); +await aiProjectClient.Agents.DeleteAgentAsync(weatherAgent.Name); diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step11_AsFunctionTool/README.md b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step11_AsFunctionTool/README.md new file mode 100644 index 0000000000..3702134ab3 --- /dev/null +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step11_AsFunctionTool/README.md @@ -0,0 +1,49 @@ +# Using AI Agents as Function Tools (Nested Agents) + +This sample demonstrates how to expose an AI agent as a function tool, enabling nested agent scenarios where one agent can invoke another agent as a tool. + +## What this sample demonstrates + +- Creating an AI agent that can be used as a function tool +- Wrapping an agent as an AIFunction +- Using nested agents where one agent calls another +- Managing multiple agent instances +- Managing agent lifecycle (creation and deletion) + +## Prerequisites + +Before you begin, ensure you have the following prerequisites: + +- .NET 8.0 SDK or later +- Azure Foundry 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 Foundry 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_FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" # Replace with your Azure Foundry resource endpoint +$env:AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini +``` + +## Run the sample + +Navigate to the FoundryAgents sample directory and run: + +```powershell +cd dotnet/samples/GettingStarted/FoundryAgents +dotnet run --project .\FoundryAgents_Step11_AsFunctionTool +``` + +## Expected behavior + +The sample will: + +1. Create a "JokerAgent" that tells jokes +2. Wrap the JokerAgent as a function tool +3. Create a "CoordinatorAgent" that has the JokerAgent as a function tool +4. Run the CoordinatorAgent with a prompt that triggers it to call the JokerAgent +5. The CoordinatorAgent will invoke the JokerAgent as a function tool +6. Clean up resources by deleting both agents + diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step12_Middleware/FoundryAgents_Step12_Middleware.csproj b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step12_Middleware/FoundryAgents_Step12_Middleware.csproj new file mode 100644 index 0000000000..4de5d131d9 --- /dev/null +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step12_Middleware/FoundryAgents_Step12_Middleware.csproj @@ -0,0 +1,21 @@ + + + + Exe + net9.0 + + enable + enable + + + + + + + + + + + + + diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step12_Middleware/Program.cs b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step12_Middleware/Program.cs new file mode 100644 index 0000000000..0a00e9107c --- /dev/null +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step12_Middleware/Program.cs @@ -0,0 +1,223 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample shows multiple middleware layers working together with Azure Foundry Agents: +// agent run (PII filtering and guardrails), +// function invocation (logging and result overrides), and human-in-the-loop +// approval workflows for sensitive function calls. + +using System.ComponentModel; +using System.Text.RegularExpressions; +using Azure.AI.Projects; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +// Get Azure AI Foundry configuration from environment variables +string endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_PROJECT_ENDPOINT is not set."); +string deploymentName = System.Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME") ?? "gpt-4o"; + +const string AssistantInstructions = "You are an AI assistant that helps people find information."; +const string AssistantName = "InformationAssistant"; + +// Get a client to create/retrieve/delete server side agents with Azure Foundry Agents. +AIProjectClient aiProjectClient = new(new Uri(endpoint), new AzureCliCredential()); + +[Description("Get the weather for a given location.")] +static string GetWeather([Description("The location to get the weather for.")] string location) + => $"The weather in {location} is cloudy with a high of 15°C."; + +[Description("The current datetime offset.")] +static string GetDateTime() + => DateTimeOffset.Now.ToString(); + +AITool dateTimeTool = AIFunctionFactory.Create(GetDateTime, name: nameof(GetDateTime)); +AITool getWeatherTool = AIFunctionFactory.Create(GetWeather, name: nameof(GetWeather)); + +// Define the agent you want to create. (Prompt Agent in this case) +AIAgent originalAgent = aiProjectClient.CreateAIAgent( + name: AssistantName, + model: deploymentName, + instructions: AssistantInstructions, + tools: [getWeatherTool, dateTimeTool]); + +// Adding middleware to the agent level +AIAgent middlewareEnabledAgent = originalAgent + .AsBuilder() + .Use(FunctionCallMiddleware) + .Use(FunctionCallOverrideWeather) + .Use(PIIMiddleware, null) + .Use(GuardrailMiddleware, null) + .Build(); + +AgentThread thread = middlewareEnabledAgent.GetNewThread(); + +Console.WriteLine("\n\n=== Example 1: Wording Guardrail ==="); +AgentRunResponse guardRailedResponse = await middlewareEnabledAgent.RunAsync("Tell me something harmful."); +Console.WriteLine($"Guard railed response: {guardRailedResponse}"); + +Console.WriteLine("\n\n=== Example 2: PII detection ==="); +AgentRunResponse piiResponse = await middlewareEnabledAgent.RunAsync("My name is John Doe, call me at 123-456-7890 or email me at john@something.com"); +Console.WriteLine($"Pii filtered response: {piiResponse}"); + +Console.WriteLine("\n\n=== Example 3: Agent function middleware ==="); + +// Agent function middleware support is limited to agents that wraps a upstream ChatClientAgent or derived from it. + +AgentRunResponse functionCallResponse = await middlewareEnabledAgent.RunAsync("What's the current time and the weather in Seattle?", thread); +Console.WriteLine($"Function calling response: {functionCallResponse}"); + +// Special per-request middleware agent. +Console.WriteLine("\n\n=== Example 4: Middleware with human in the loop function approval ==="); + +AIAgent humanInTheLoopAgent = aiProjectClient.CreateAIAgent( + name: "HumanInTheLoopAgent", + model: deploymentName, + instructions: "You are an Human in the loop testing AI assistant that helps people find information.", + + // Adding a function with approval required + tools: [new ApprovalRequiredAIFunction(AIFunctionFactory.Create(GetWeather, name: nameof(GetWeather)))]); + +// Using the ConsolePromptingApprovalMiddleware for a specific request to handle user approval during function calls. +AgentRunResponse response = await humanInTheLoopAgent + .AsBuilder() + .Use(ConsolePromptingApprovalMiddleware, null) + .Build() + .RunAsync("What's the current time and the weather in Seattle?"); + +Console.WriteLine($"HumanInTheLoopAgent agent middleware response: {response}"); + +// Function invocation middleware that logs before and after function calls. +async ValueTask FunctionCallMiddleware(AIAgent agent, FunctionInvocationContext context, Func> next, CancellationToken cancellationToken) +{ + Console.WriteLine($"Function Name: {context!.Function.Name} - Middleware 1 Pre-Invoke"); + var result = await next(context, cancellationToken); + Console.WriteLine($"Function Name: {context!.Function.Name} - Middleware 1 Post-Invoke"); + + return result; +} + +// Function invocation middleware that overrides the result of the GetWeather function. +async ValueTask FunctionCallOverrideWeather(AIAgent agent, FunctionInvocationContext context, Func> next, CancellationToken cancellationToken) +{ + Console.WriteLine($"Function Name: {context!.Function.Name} - Middleware 2 Pre-Invoke"); + + var result = await next(context, cancellationToken); + + if (context.Function.Name == nameof(GetWeather)) + { + // Override the result of the GetWeather function + result = "The weather is sunny with a high of 25°C."; + } + Console.WriteLine($"Function Name: {context!.Function.Name} - Middleware 2 Post-Invoke"); + return result; +} + +// This middleware redacts PII information from input and output messages. +async Task PIIMiddleware(IEnumerable messages, AgentThread? thread, AgentRunOptions? options, AIAgent innerAgent, CancellationToken cancellationToken) +{ + // Redact PII information from input messages + var filteredMessages = FilterMessages(messages); + Console.WriteLine("Pii Middleware - Filtered Messages Pre-Run"); + + var response = await innerAgent.RunAsync(filteredMessages, thread, options, cancellationToken).ConfigureAwait(false); + + // Redact PII information from output messages + response.Messages = FilterMessages(response.Messages); + + Console.WriteLine("Pii Middleware - Filtered Messages Post-Run"); + + return response; + + static IList FilterMessages(IEnumerable messages) + { + return messages.Select(m => new ChatMessage(m.Role, FilterPii(m.Text))).ToList(); + } + + static string FilterPii(string content) + { + // Regex patterns for PII detection (simplified for demonstration) + Regex[] piiPatterns = [ + new(@"\b\d{3}-\d{3}-\d{4}\b", RegexOptions.Compiled), // Phone number (e.g., 123-456-7890) + new(@"\b[\w\.-]+@[\w\.-]+\.\w+\b", RegexOptions.Compiled), // Email address + new(@"\b[A-Z][a-z]+\s[A-Z][a-z]+\b", RegexOptions.Compiled) // Full name (e.g., John Doe) + ]; + + foreach (var pattern in piiPatterns) + { + content = pattern.Replace(content, "[REDACTED: PII]"); + } + + return content; + } +} + +// This middleware enforces guardrails by redacting certain keywords from input and output messages. +async Task GuardrailMiddleware(IEnumerable messages, AgentThread? thread, AgentRunOptions? options, AIAgent innerAgent, CancellationToken cancellationToken) +{ + // Redact keywords from input messages + var filteredMessages = FilterMessages(messages); + + Console.WriteLine("Guardrail Middleware - Filtered messages Pre-Run"); + + // Proceed with the agent run + var response = await innerAgent.RunAsync(filteredMessages, thread, options, cancellationToken); + + // Redact keywords from output messages + response.Messages = FilterMessages(response.Messages); + + Console.WriteLine("Guardrail Middleware - Filtered messages Post-Run"); + + return response; + + List FilterMessages(IEnumerable messages) + { + return messages.Select(m => new ChatMessage(m.Role, FilterContent(m.Text))).ToList(); + } + + static string FilterContent(string content) + { + foreach (var keyword in new[] { "harmful", "illegal", "violence" }) + { + if (content.Contains(keyword, StringComparison.OrdinalIgnoreCase)) + { + return "[REDACTED: Forbidden content]"; + } + } + + return content; + } +} + +// This middleware handles Human in the loop console interaction for any user approval required during function calling. +async Task ConsolePromptingApprovalMiddleware(IEnumerable messages, AgentThread? thread, AgentRunOptions? options, AIAgent innerAgent, CancellationToken cancellationToken) +{ + AgentRunResponse response = await innerAgent.RunAsync(messages, thread, options, cancellationToken); + + List userInputRequests = response.UserInputRequests.ToList(); + + while (userInputRequests.Count > 0) + { + // Ask the user to approve each function call request. + // For simplicity, we are assuming here that only function approval requests are being made. + + // Pass the user input responses back to the agent for further processing. + response.Messages = userInputRequests + .OfType() + .Select(functionApprovalRequest => + { + Console.WriteLine($"The agent would like to invoke the following function, please reply Y to approve: Name {functionApprovalRequest.FunctionCall.Name}"); + bool approved = Console.ReadLine()?.Equals("Y", StringComparison.OrdinalIgnoreCase) ?? false; + return new ChatMessage(ChatRole.User, [functionApprovalRequest.CreateResponse(approved)]); + }) + .ToList(); + + response = await innerAgent.RunAsync(response.Messages, thread, options, cancellationToken); + + userInputRequests = response.UserInputRequests.ToList(); + } + + return response; +} + +// Cleanup by agent name removes the agent version created. +await aiProjectClient.Agents.DeleteAgentAsync(middlewareEnabledAgent.Name); diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step12_Middleware/README.md b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step12_Middleware/README.md new file mode 100644 index 0000000000..1f7321051f --- /dev/null +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step12_Middleware/README.md @@ -0,0 +1,58 @@ +# Agent Middleware + +This sample demonstrates how to add middleware to intercept agent runs and function calls to implement cross-cutting concerns like logging, validation, and guardrails. + +## What This Sample Shows + +1. Azure Foundry Agents integration via `AIProjectClient` and `AzureCliCredential` +2. Agent run middleware (logging and monitoring) +3. Function invocation middleware (logging and overriding tool results) +4. Per-request agent run middleware +5. Per-request function pipeline with approval +6. Combining agent-level and per-request middleware + +## Function Invocation Middleware + +Not all agents support function invocation middleware. + +Attempting to use function middleware on agents that do not wrap a ChatClientAgent or derives from it will throw an InvalidOperationException. + +## Prerequisites + +Before you begin, ensure you have the following prerequisites: + +- .NET 8.0 SDK or later +- Azure Foundry 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 Foundry 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_FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" # Replace with your Azure Foundry resource endpoint +$env:AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini +``` + +## Running the Sample + +Navigate to the FoundryAgents sample directory and run: + +```powershell +cd dotnet/samples/GettingStarted/FoundryAgents +dotnet run --project .\FoundryAgents_Step12_Middleware +``` + +## Expected Behavior + +When you run this sample, you will see the following demonstrations: + +1. **Example 1: Wording Guardrail** - The agent receives a request for harmful content. The guardrail middleware intercepts the request and prevents the agent from responding to harmful prompts, returning a safe response instead. + +2. **Example 2: PII Detection** - The agent receives a message containing personally identifiable information (name, phone number, email). The PII middleware detects and filters this sensitive information before processing. + +3. **Example 3: Agent Function Middleware** - The agent uses function tools (GetDateTime and GetWeather) to answer a question about the current time and weather in Seattle. The function middleware logs the function calls and can override results if needed. + +4. **Example 4: Human-in-the-Loop Function Approval** - The agent attempts to call a weather function, but the approval middleware intercepts the call and prompts the user to approve or deny the function invocation before it executes. The user can respond with "Y" to approve or any other input to deny. + +Each example demonstrates how middleware can be used to implement cross-cutting concerns and control agent behavior at different levels (agent-level and per-request). diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step13_Plugins/FoundryAgents_Step13_Plugins.csproj b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step13_Plugins/FoundryAgents_Step13_Plugins.csproj new file mode 100644 index 0000000000..1c8496b239 --- /dev/null +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step13_Plugins/FoundryAgents_Step13_Plugins.csproj @@ -0,0 +1,22 @@ + + + + Exe + net9.0 + + enable + enable + $(NoWarn);CA1812 + + + + + + + + + + + + + diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step13_Plugins/Program.cs b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step13_Plugins/Program.cs new file mode 100644 index 0000000000..b55f38b66b --- /dev/null +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step13_Plugins/Program.cs @@ -0,0 +1,139 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample shows how to use plugins with an AI agent. Plugin classes can +// depend on other services that need to be injected. In this sample, the +// AgentPlugin class uses the WeatherProvider and CurrentTimeProvider classes +// to get weather and current time information. Both services are registered +// in the service collection and injected into the plugin. +// Plugin classes may have many methods, but only some are intended to be used +// as AI functions. The AsAITools method of the plugin class shows how to specify +// which methods should be exposed to the AI agent. + +using Azure.AI.Projects; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; + +string endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_PROJECT_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; + +const string AssistantInstructions = "You are a helpful assistant that helps people find information."; +const string AssistantName = "PluginAssistant"; + +// Create a service collection to hold the agent plugin and its dependencies. +ServiceCollection services = new(); +services.AddSingleton(); +services.AddSingleton(); +services.AddSingleton(); // The plugin depends on WeatherProvider and CurrentTimeProvider registered above. + +IServiceProvider serviceProvider = services.BuildServiceProvider(); + +// Get a client to create/retrieve/delete server side agents with Azure Foundry Agents. +AIProjectClient aiProjectClient = new(new Uri(endpoint), new AzureCliCredential()); + +// Define the agent with plugin tools +// Define the agent you want to create. (Prompt Agent in this case) +AIAgent agent = aiProjectClient.CreateAIAgent( + name: AssistantName, + model: deploymentName, + instructions: AssistantInstructions, + tools: serviceProvider.GetRequiredService().AsAITools().ToList(), + services: serviceProvider); + +// Invoke the agent and output the text result. +AgentThread thread = agent.GetNewThread(); +Console.WriteLine(await agent.RunAsync("Tell me current time and weather in Seattle.", thread)); + +// Cleanup by agent name removes the agent version created. +await aiProjectClient.Agents.DeleteAgentAsync(agent.Name); + +/// +/// The agent plugin that provides weather and current time information. +/// +/// The weather provider to get weather information. +internal sealed class AgentPlugin(WeatherProvider weatherProvider) +{ + /// + /// Gets the weather information for the specified location. + /// + /// + /// This method demonstrates how to use the dependency that was injected into the plugin class. + /// + /// The location to get the weather for. + /// The weather information for the specified location. + public string GetWeather(string location) + { + return weatherProvider.GetWeather(location); + } + + /// + /// Gets the current date and time for the specified location. + /// + /// + /// This method demonstrates how to resolve a dependency using the service provider passed to the method. + /// + /// The service provider to resolve the . + /// The location to get the current time for. + /// The current date and time as a . + public DateTimeOffset GetCurrentTime(IServiceProvider sp, string location) + { + // Resolve the CurrentTimeProvider from the service provider + CurrentTimeProvider currentTimeProvider = sp.GetRequiredService(); + + return currentTimeProvider.GetCurrentTime(location); + } + + /// + /// Returns the functions provided by this plugin. + /// + /// + /// In real world scenarios, a class may have many methods and only a subset of them may be intended to be exposed as AI functions. + /// This method demonstrates how to explicitly specify which methods should be exposed to the AI agent. + /// + /// The functions provided by this plugin. + public IEnumerable AsAITools() + { + yield return AIFunctionFactory.Create(this.GetWeather); + yield return AIFunctionFactory.Create(this.GetCurrentTime); + } +} + +/// +/// The weather provider that returns weather information. +/// +internal sealed class WeatherProvider +{ + /// + /// Gets the weather information for the specified location. + /// + /// + /// The weather information is hardcoded for demonstration purposes. + /// In a real application, this could call a weather API to get actual weather data. + /// + /// The location to get the weather for. + /// The weather information for the specified location. + public string GetWeather(string location) + { + return $"The weather in {location} is cloudy with a high of 15°C."; + } +} + +/// +/// Provides the current date and time. +/// +/// +/// This class returns the current date and time using the system's clock. +/// +internal sealed class CurrentTimeProvider +{ + /// + /// Gets the current date and time. + /// + /// The location to get the current time for (not used in this implementation). + /// The current date and time as a . + public DateTimeOffset GetCurrentTime(string location) + { + return DateTimeOffset.Now; + } +} diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step13_Plugins/README.md b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step13_Plugins/README.md new file mode 100644 index 0000000000..d086e28aa0 --- /dev/null +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step13_Plugins/README.md @@ -0,0 +1,49 @@ +# Using Plugins with AI Agents + +This sample demonstrates how to use plugins with AI agents, where plugins are services registered in dependency injection that expose methods as AI function tools. + +## What this sample demonstrates + +- Creating plugin services with methods to expose as tools +- Using AsAITools() to selectively expose plugin methods +- Registering plugins in dependency injection +- Using plugins with AI agents +- Managing agent lifecycle (creation and deletion) + +## Prerequisites + +Before you begin, ensure you have the following prerequisites: + +- .NET 8.0 SDK or later +- Azure Foundry 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 Foundry 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_FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" # Replace with your Azure Foundry resource endpoint +$env:AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini +``` + +## Run the sample + +Navigate to the FoundryAgents sample directory and run: + +```powershell +cd dotnet/samples/GettingStarted/FoundryAgents +dotnet run --project .\FoundryAgents_Step13_Plugins +``` + +## Expected behavior + +The sample will: + +1. Create a plugin service with methods to expose as tools +2. Register the plugin in dependency injection +3. Create an agent named "PluginAgent" with the plugin methods as function tools +4. Run the agent with a prompt that triggers it to call plugin methods +5. The agent will invoke the plugin methods to retrieve information +6. Clean up resources by deleting the agent + diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step14_CodeInterpreter/FoundryAgents_Step14_CodeInterpreter.csproj b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step14_CodeInterpreter/FoundryAgents_Step14_CodeInterpreter.csproj new file mode 100644 index 0000000000..1c8496b239 --- /dev/null +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step14_CodeInterpreter/FoundryAgents_Step14_CodeInterpreter.csproj @@ -0,0 +1,22 @@ + + + + Exe + net9.0 + + enable + enable + $(NoWarn);CA1812 + + + + + + + + + + + + + diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step14_CodeInterpreter/Program.cs b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step14_CodeInterpreter/Program.cs new file mode 100644 index 0000000000..0a70ca76df --- /dev/null +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step14_CodeInterpreter/Program.cs @@ -0,0 +1,90 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample shows how to use Code Interpreter Tool with AI Agents. + +using System.Text; +using Azure.AI.Projects; +using Azure.AI.Projects.OpenAI; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using OpenAI.Assistants; +using OpenAI.Responses; + +string endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_PROJECT_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; + +const string AgentInstructions = "You are a personal math tutor. When asked a math question, write and run code using the python tool to answer the question."; +const string AgentNameMEAI = "CoderAgent-MEAI"; +const string AgentNameNative = "CoderAgent-NATIVE"; + +// Get a client to create/retrieve/delete server side agents with Azure Foundry Agents. +AIProjectClient aiProjectClient = new(new Uri(endpoint), new AzureCliCredential()); + +// Option 1 - Using HostedCodeInterpreterTool + AgentOptions (MEAI + AgentFramework) +// Create the server side agent version +AIAgent agentOption1 = await aiProjectClient.CreateAIAgentAsync( + model: deploymentName, + name: AgentNameMEAI, + instructions: AgentInstructions, + tools: [new HostedCodeInterpreterTool() { Inputs = [] }]); + +// Option 2 - Using PromptAgentDefinition SDK native type +// Create the server side agent version +AIAgent agentOption2 = await aiProjectClient.CreateAIAgentAsync( + name: AgentNameNative, + creationOptions: new AgentVersionCreationOptions( + new PromptAgentDefinition(model: deploymentName) + { + Instructions = AgentInstructions, + Tools = { + ResponseTool.CreateCodeInterpreterTool( + new CodeInterpreterToolContainer( + CodeInterpreterToolContainerConfiguration.CreateAutomaticContainerConfiguration(fileIds: []) + ) + ), + } + }) +); + +// Either invoke option1 or option2 agent, should have same result +// Option 1 +AgentRunResponse response = await agentOption1.RunAsync("I need to solve the equation sin(x) + x^2 = 42"); + +// Option 2 +// AgentRunResponse response = await agentOption2.RunAsync("I need to solve the equation sin(x) + x^2 = 42"); + +// Get the CodeInterpreterToolCallContent +CodeInterpreterToolCallContent? toolCallContent = response.Messages.SelectMany(m => m.Contents).OfType().SingleOrDefault(); +if (toolCallContent?.Inputs is not null) +{ + DataContent? codeInput = toolCallContent.Inputs.OfType().FirstOrDefault(); + if (codeInput?.HasTopLevelMediaType("text") ?? false) + { + Console.WriteLine($"Code Input: {Encoding.UTF8.GetString(codeInput.Data.ToArray()) ?? "Not available"}"); + } +} + +// Get the CodeInterpreterToolResultContent +CodeInterpreterToolResultContent? toolResultContent = response.Messages.SelectMany(m => m.Contents).OfType().FirstOrDefault(); +if (toolResultContent?.Outputs is not null && toolResultContent.Outputs.OfType().FirstOrDefault() is { } resultOutput) +{ + Console.WriteLine($"Code Tool Result: {resultOutput.Text}"); +} + +// Getting any annotations generated by the tool +foreach (AIAnnotation annotation in response.Messages.SelectMany(m => m.Contents).SelectMany(C => C.Annotations ?? [])) +{ + if (annotation.RawRepresentation is TextAnnotationUpdate citationAnnotation) + { + Console.WriteLine($$""" + File Id: {{citationAnnotation.OutputFileId}} + Text to Replace: {{citationAnnotation.TextToReplace}} + Filename: {{Path.GetFileName(citationAnnotation.TextToReplace)}} + """); + } +} + +// Cleanup by agent name removes the agent version created. +await aiProjectClient.Agents.DeleteAgentAsync(agentOption1.Name); +await aiProjectClient.Agents.DeleteAgentAsync(agentOption2.Name); diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step14_CodeInterpreter/README.md b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step14_CodeInterpreter/README.md new file mode 100644 index 0000000000..007f283a76 --- /dev/null +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step14_CodeInterpreter/README.md @@ -0,0 +1,53 @@ +# Using Code Interpreter with AI Agents + +This sample demonstrates how to use the code interpreter tool with AI agents. The code interpreter allows agents to write and execute Python code to solve problems, perform calculations, and analyze data. + +## What this sample demonstrates + +- Creating agents with code interpreter capabilities +- Using HostedCodeInterpreterTool (MEAI abstraction) +- Using native SDK code interpreter tools (ResponseTool.CreateCodeInterpreterTool) +- Extracting code inputs and results from agent responses +- Handling code interpreter annotations +- Managing agent lifecycle (creation and deletion) + +## Prerequisites + +Before you begin, ensure you have the following prerequisites: + +- .NET 8.0 SDK or later +- Azure Foundry 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 Foundry 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_FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" # Replace with your Azure Foundry resource endpoint +$env:AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini +``` + +## Run the sample + +Navigate to the FoundryAgents sample directory and run: + +```powershell +cd dotnet/samples/GettingStarted/FoundryAgents +dotnet run --project .\FoundryAgents_Step14_CodeInterpreter +``` + +## Expected behavior + +The sample will: + +1. Create two agents with code interpreter capabilities: + - Option 1: Using HostedCodeInterpreterTool (MEAI abstraction) + - Option 2: Using native SDK code interpreter tools +2. Run the agent with a mathematical problem: "I need to solve the equation sin(x) + x^2 = 42" +3. The agent will use the code interpreter to write and execute Python code to solve the equation +4. Extract and display the code that was executed +5. Display the results from the code execution +6. Display any annotations generated by the code interpreter tool +7. Clean up resources by deleting both agents + diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step15_ComputerUse/Assets/cua_browser_search.png b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step15_ComputerUse/Assets/cua_browser_search.png new file mode 100644 index 0000000000..5984b95cb3 Binary files /dev/null and b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step15_ComputerUse/Assets/cua_browser_search.png differ diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step15_ComputerUse/Assets/cua_search_results.png b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step15_ComputerUse/Assets/cua_search_results.png new file mode 100644 index 0000000000..ed3ab3d8d4 Binary files /dev/null and b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step15_ComputerUse/Assets/cua_search_results.png differ diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step15_ComputerUse/Assets/cua_search_typed.png b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step15_ComputerUse/Assets/cua_search_typed.png new file mode 100644 index 0000000000..04d76e2075 Binary files /dev/null and b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step15_ComputerUse/Assets/cua_search_typed.png differ diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step15_ComputerUse/ComputerUseUtil.cs b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step15_ComputerUse/ComputerUseUtil.cs new file mode 100644 index 0000000000..1ee421b465 --- /dev/null +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step15_ComputerUse/ComputerUseUtil.cs @@ -0,0 +1,98 @@ +// Copyright (c) Microsoft. All rights reserved. + +using OpenAI.Responses; + +namespace Demo.ComputerUse; + +/// +/// Enum for tracking the state of the simulated web search flow. +/// +internal enum SearchState +{ + Initial, // Browser search page + Typed, // Text entered in search box + PressedEnter // Enter key pressed, transitioning to results +} + +internal static class ComputerUseUtil +{ + /// + /// Load and convert screenshot images to base64 data URLs. + /// + internal static Dictionary LoadScreenshotAssets() + { + string baseDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Assets"); + + ReadOnlySpan<(string key, string fileName)> screenshotFiles = + [ + ("browser_search", "cua_browser_search.png"), + ("search_typed", "cua_search_typed.png"), + ("search_results", "cua_search_results.png") + ]; + + Dictionary screenshots = []; + foreach (var (key, fileName) in screenshotFiles) + { + string fullPath = Path.GetFullPath(Path.Combine(baseDir, fileName)); + screenshots[key] = File.ReadAllBytes(fullPath); + } + + return screenshots; + } + + /// + /// Process a computer action and simulate its execution. + /// + internal static (SearchState CurrentState, byte[] ImageBytes) HandleComputerActionAndTakeScreenshot( + ComputerCallAction action, + SearchState currentState, + Dictionary screenshots) + { + Console.WriteLine($"Simulating the execution of computer action: {action.Kind}"); + + SearchState newState = DetermineNextState(action, currentState); + string imageKey = GetImageKey(newState); + + return (newState, screenshots[imageKey]); + } + + private static SearchState DetermineNextState(ComputerCallAction action, SearchState currentState) + { + string actionType = action.Kind.ToString(); + + if (actionType.Equals("type", StringComparison.OrdinalIgnoreCase) && action.TypeText is not null) + { + return SearchState.Typed; + } + + if (IsEnterKeyAction(action, actionType)) + { + Console.WriteLine(" -> Detected ENTER key press"); + return SearchState.PressedEnter; + } + + if (actionType.Equals("click", StringComparison.OrdinalIgnoreCase) && currentState == SearchState.Typed) + { + Console.WriteLine(" -> Detected click after typing"); + return SearchState.PressedEnter; + } + + return currentState; + } + + private static bool IsEnterKeyAction(ComputerCallAction action, string actionType) + { + return (actionType.Equals("key", StringComparison.OrdinalIgnoreCase) || + actionType.Equals("keypress", StringComparison.OrdinalIgnoreCase)) && + action.KeyPressKeyCodes is not null && + (action.KeyPressKeyCodes.Contains("Return", StringComparer.OrdinalIgnoreCase) || + action.KeyPressKeyCodes.Contains("Enter", StringComparer.OrdinalIgnoreCase)); + } + + private static string GetImageKey(SearchState state) => state switch + { + SearchState.PressedEnter => "search_results", + SearchState.Typed => "search_typed", + _ => "browser_search" + }; +} diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step15_ComputerUse/FoundryAgents_Step15_ComputerUse.csproj b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step15_ComputerUse/FoundryAgents_Step15_ComputerUse.csproj new file mode 100644 index 0000000000..383b55a939 --- /dev/null +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step15_ComputerUse/FoundryAgents_Step15_ComputerUse.csproj @@ -0,0 +1,33 @@ + + + + Exe + net9.0 + + enable + enable + $(NoWarn);OPENAICUA001 + + + + + + + + + + + + + + Always + + + Always + + + Always + + + + diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step15_ComputerUse/Program.cs b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step15_ComputerUse/Program.cs new file mode 100644 index 0000000000..cb621275ea --- /dev/null +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step15_ComputerUse/Program.cs @@ -0,0 +1,174 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample shows how to use Computer Use Tool with AI Agents. + +using Azure.AI.Projects; +using Azure.AI.Projects.OpenAI; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using OpenAI.Responses; + +namespace Demo.ComputerUse; + +internal sealed class Program +{ + private static async Task Main(string[] args) + { + string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); + string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "computer-use-preview"; + + // Get a client to create/retrieve/delete server side agents with Azure Foundry Agents. + AIProjectClient aiProjectClient = new(new Uri(endpoint), new AzureCliCredential()); + const string AgentInstructions = @" + You are a computer automation assistant. + + Be direct and efficient. When you reach the search results page, read and describe the actual search result titles and descriptions you can see. + "; + + const string AgentNameMEAI = "ComputerAgent-MEAI"; + const string AgentNameNative = "ComputerAgent-NATIVE"; + + // Option 1 - Using ComputerUseTool + AgentOptions (MEAI + AgentFramework) + // Create AIAgent directly + AIAgent agentOption1 = await aiProjectClient.CreateAIAgentAsync( + name: AgentNameMEAI, + model: deploymentName, + instructions: AgentInstructions, + description: "Computer automation agent with screen interaction capabilities.", + tools: [ + ResponseTool.CreateComputerTool(ComputerToolEnvironment.Browser, 1026, 769).AsAITool(), + ]); + + // Option 2 - Using PromptAgentDefinition SDK native type + // Create the server side agent version + AIAgent agentOption2 = await aiProjectClient.CreateAIAgentAsync( + name: AgentNameNative, + creationOptions: new AgentVersionCreationOptions( + new PromptAgentDefinition(model: deploymentName) + { + Instructions = AgentInstructions, + Tools = { ResponseTool.CreateComputerTool( + environment: new ComputerToolEnvironment("windows"), + displayWidth: 1026, + displayHeight: 769) } + }) + ); + + // Either invoke option1 or option2 agent, should have same result + // Option 1 + await InvokeComputerUseAgentAsync(agentOption1); + + // Option 2 + //await InvokeComputerUseAgentAsync(agentOption2); + + // Cleanup by agent name removes the agent version created. + await aiProjectClient.Agents.DeleteAgentAsync(agentOption1.Name); + await aiProjectClient.Agents.DeleteAgentAsync(agentOption2.Name); + } + + private static async Task InvokeComputerUseAgentAsync(AIAgent agent) + { + // Load screenshot assets + Dictionary screenshots = ComputerUseUtil.LoadScreenshotAssets(); + + ChatOptions chatOptions = new(); + ResponseCreationOptions responseCreationOptions = new() + { + TruncationMode = ResponseTruncationMode.Auto + }; + chatOptions.RawRepresentationFactory = (_) => responseCreationOptions; + ChatClientAgentRunOptions runOptions = new(chatOptions) + { + AllowBackgroundResponses = true, + }; + + AgentThread thread = agent.GetNewThread(); + + ChatMessage message = new(ChatRole.User, [ + new TextContent("I need you to help me search for 'OpenAI news'. Please type 'OpenAI news' and submit the search. Once you see search results, the task is complete."), + new DataContent(new BinaryData(screenshots["browser_search"]), "image/png") + ]); + + // Initial request with screenshot - start with Bing search page + Console.WriteLine("Starting computer automation session (initial screenshot: cua_browser_search.png)..."); + + AgentRunResponse runResponse = await agent.RunAsync(message, thread: thread, options: runOptions); + + // Main interaction loop + const int MaxIterations = 10; + int iteration = 0; + // Initialize state machine + SearchState currentState = SearchState.Initial; + string initialCallId = string.Empty; + + while (true) + { + // Poll until the response is complete. + while (runResponse.ContinuationToken is { } token) + { + // Wait before polling again. + await Task.Delay(TimeSpan.FromSeconds(2)); + + // Continue with the token. + runOptions.ContinuationToken = token; + + runResponse = await agent.RunAsync(thread, runOptions); + } + + Console.WriteLine($"Agent response received (ID: {runResponse.ResponseId})"); + + if (iteration >= MaxIterations) + { + Console.WriteLine($"\nReached maximum iterations ({MaxIterations}). Stopping."); + break; + } + + iteration++; + Console.WriteLine($"\n--- Iteration {iteration} ---"); + + // Check for computer calls in the response + IEnumerable computerCallResponseItems = runResponse.Messages + .SelectMany(x => x.Contents) + .Where(c => c.RawRepresentation is ComputerCallResponseItem and not null) + .Select(c => (ComputerCallResponseItem)c.RawRepresentation!); + + ComputerCallResponseItem? firstComputerCall = computerCallResponseItems.FirstOrDefault(); + if (firstComputerCall is null) + { + Console.WriteLine("No computer call actions found. Ending interaction."); + Console.WriteLine($"Final Response: {runResponse}"); + break; + } + + // Process the first computer call response + ComputerCallAction action = firstComputerCall.Action; + string currentCallId = firstComputerCall.CallId; + + // Set the initial computer call ID for tracking and subsequent responses. + if (string.IsNullOrEmpty(initialCallId)) + { + initialCallId = currentCallId; + } + + Console.WriteLine($"Processing computer call (ID: {currentCallId})"); + + // Simulate executing the action and taking a screenshot + (SearchState CurrentState, byte[] ImageBytes) screenInfo = ComputerUseUtil.HandleComputerActionAndTakeScreenshot(action, currentState, screenshots); + currentState = screenInfo.CurrentState; + + Console.WriteLine("Sending action result back to agent..."); + + AIContent content = new() + { + RawRepresentation = new ComputerCallOutputResponseItem( + initialCallId, + output: ComputerCallOutput.CreateScreenshotOutput(new BinaryData(screenInfo.ImageBytes), "image/png")) + }; + + // Follow-up message with action result and new screenshot + message = new(ChatRole.User, [content]); + runResponse = await agent.RunAsync(message, thread: thread, options: runOptions); + } + } +} diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step15_ComputerUse/README.md b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step15_ComputerUse/README.md new file mode 100644 index 0000000000..6b7f27aaf3 --- /dev/null +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step15_ComputerUse/README.md @@ -0,0 +1,55 @@ +# Using Computer Use Tool with AI Agents + +This sample demonstrates how to use the computer use tool with AI agents. The computer use tool allows agents to interact with a computer environment by viewing the screen, controlling the mouse and keyboard, and performing various actions to help complete tasks. + +## What this sample demonstrates + +- Creating agents with computer use capabilities +- Using HostedComputerTool (MEAI abstraction) +- Using native SDK computer use tools (ResponseTool.CreateComputerTool) +- Extracting computer action information from agent responses +- Handling computer tool results (text output and screenshots) +- Managing agent lifecycle (creation and deletion) + +## Prerequisites + +Before you begin, ensure you have the following prerequisites: + +- .NET 8.0 SDK or later +- Azure Foundry 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 Foundry 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-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" # Replace with your Azure Foundry resource endpoint +$env:AZURE_OPENAI_DEPLOYMENT_NAME="computer-use-preview" # Optional, defaults to computer-use-preview +``` + +## Run the sample + +Navigate to the FoundryAgents sample directory and run: + +```powershell +cd dotnet/samples/GettingStarted/FoundryAgents +dotnet run --project .\FoundryAgents_Step15_ComputerUse +``` + +## Expected behavior + +The sample will: + +1. Create two agents with computer use capabilities: + - Option 1: Using HostedComputerTool (MEAI abstraction) + - Option 2: Using native SDK computer use tools +2. Run the agent with a task: "I need you to help me search for 'OpenAI news'. Please type 'OpenAI news' and submit the search. Once you see search results, the task is complete." +3. The agent will use the computer use tool to: + - Interpret the screenshots + - Issue action requests based on the task + - Analyze the search results for "OpenAI news" from the screenshots. +4. Extract and display the computer actions performed +5. Display the results from the computer tool execution +6. Display the final response from the agent +7. Clean up resources by deleting both agents diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/ConfirmInput/ConfirmInput.csproj b/dotnet/samples/GettingStarted/Workflows/Declarative/ConfirmInput/ConfirmInput.csproj new file mode 100644 index 0000000000..3254317876 --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/ConfirmInput/ConfirmInput.csproj @@ -0,0 +1,40 @@ + + + + Exe + net9.0 + net9.0 + $(ProjectsDebugTargetFrameworks) + enable + enable + + + + true + true + true + true + + + + + + + + + + + + + + + + + + + + Always + + + + diff --git a/workflow-samples/HumanInLoop.yaml b/dotnet/samples/GettingStarted/Workflows/Declarative/ConfirmInput/ConfirmInput.yaml similarity index 88% rename from workflow-samples/HumanInLoop.yaml rename to dotnet/samples/GettingStarted/Workflows/Declarative/ConfirmInput/ConfirmInput.yaml index 11c63adb5e..339537c74a 100644 --- a/workflow-samples/HumanInLoop.yaml +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/ConfirmInput/ConfirmInput.yaml @@ -1,8 +1,8 @@ # -# This workflow demonstrates a single agent interaction based on user input. +# This workflow demonstrates how to use the Question action +# to request user input and confirm it matches the original input. # -# Any Foundry Agent may be used to provide the response. -# See: ./setup/QuestionAgent.yaml +# Note: This workflow doesn't make use of any agents. # kind: Workflow trigger: @@ -59,6 +59,3 @@ trigger: Confirmed input: {Local.ConfirmedInput} - - - diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/ConfirmInput/Program.cs b/dotnet/samples/GettingStarted/Workflows/Declarative/ConfirmInput/Program.cs new file mode 100644 index 0000000000..0e409aa0a0 --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/ConfirmInput/Program.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Extensions.Configuration; +using Shared.Workflows; + +namespace Demo.Workflows.Declarative.ConfirmInput; + +/// +/// Demonstrate how to use the question action to request user input +/// and confirm it matches the original input. +/// +/// +/// See the README.md file in the parent folder (../README.md) for detailed +/// information about the configuration required to run this sample. +/// +internal sealed class Program +{ + public static async Task Main(string[] args) + { + // Initialize configuration + IConfiguration configuration = Application.InitializeConfig(); + Uri foundryEndpoint = new(configuration.GetValue(Application.Settings.FoundryEndpoint)); + + // Get input from command line or console + string workflowInput = Application.GetInput(args); + + // Create the workflow factory. This class demonstrates how to initialize a + // declarative workflow from a YAML file. Once the workflow is created, it + // can be executed just like any regular workflow. + WorkflowFactory workflowFactory = new("ConfirmInput.yaml", foundryEndpoint); + + // Execute the workflow: The WorkflowRunner demonstrates how to execute + // a workflow, handle the workflow events, and providing external input. + // This also includes the ability to checkpoint workflow state and how to + // resume execution. + WorkflowRunner runner = new(); + await runner.ExecuteAsync(workflowFactory.CreateWorkflow, workflowInput); + } +} diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/CustomerSupport/CustomerSupport.csproj b/dotnet/samples/GettingStarted/Workflows/Declarative/CustomerSupport/CustomerSupport.csproj new file mode 100644 index 0000000000..5ddd570571 --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/CustomerSupport/CustomerSupport.csproj @@ -0,0 +1,40 @@ + + + + Exe + net9.0 + net9.0 + $(ProjectsDebugTargetFrameworks) + enable + enable + + + + true + true + true + true + + + + + + + + + + + + + + + + + + + + Always + + + + \ No newline at end of file diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/CustomerSupport/Program.cs b/dotnet/samples/GettingStarted/Workflows/Declarative/CustomerSupport/Program.cs new file mode 100644 index 0000000000..f18b8b4658 --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/CustomerSupport/Program.cs @@ -0,0 +1,441 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Azure.AI.Projects; +using Azure.AI.Projects.OpenAI; +using Azure.Identity; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Configuration; +using OpenAI.Responses; +using Shared.Foundry; +using Shared.Workflows; + +namespace Demo.Workflows.Declarative.CustomerSupport; + +/// +/// This workflow demonstrates using multiple agents to provide automated +/// troubleshooting steps to resolve common issues with escalation options. +/// +/// +/// See the README.md file in the parent folder (../README.md) for detailed +/// information about the configuration required to run this sample. +/// +internal sealed class Program +{ + public static async Task Main(string[] args) + { + // Initialize configuration + IConfiguration configuration = Application.InitializeConfig(); + Uri foundryEndpoint = new(configuration.GetValue(Application.Settings.FoundryEndpoint)); + + // Create the ticketing plugin (mock functionality) + TicketingPlugin plugin = new(); + + // Ensure sample agents exist in Foundry. + await CreateAgentsAsync(foundryEndpoint, configuration, plugin); + + // Get input from command line or console + string workflowInput = Application.GetInput(args); + + // Create the workflow factory. This class demonstrates how to initialize a + // declarative workflow from a YAML file. Once the workflow is created, it + // can be executed just like any regular workflow. + WorkflowFactory workflowFactory = + new("CustomerSupport.yaml", foundryEndpoint) + { + Functions = + [ + AIFunctionFactory.Create(plugin.CreateTicket), + AIFunctionFactory.Create(plugin.GetTicket), + AIFunctionFactory.Create(plugin.ResolveTicket), + AIFunctionFactory.Create(plugin.SendNotification), + ] + }; + + // Execute the workflow: The WorkflowRunner demonstrates how to execute + // a workflow, handle the workflow events, and providing external input. + // This also includes the ability to checkpoint workflow state and how to + // resume execution. + WorkflowRunner runner = new(); + await runner.ExecuteAsync(workflowFactory.CreateWorkflow, workflowInput); + } + + private static async Task CreateAgentsAsync(Uri foundryEndpoint, IConfiguration configuration, TicketingPlugin plugin) + { + AIProjectClient aiProjectClient = new(foundryEndpoint, new AzureCliCredential()); + + await aiProjectClient.CreateAgentAsync( + agentName: "SelfServiceAgent", + agentDefinition: DefineSelfServiceAgent(configuration), + agentDescription: "Service agent for CustomerSupport workflow"); + + await aiProjectClient.CreateAgentAsync( + agentName: "TicketingAgent", + agentDefinition: DefineTicketingAgent(configuration, plugin), + agentDescription: "Ticketing agent for CustomerSupport workflow"); + + await aiProjectClient.CreateAgentAsync( + agentName: "TicketRoutingAgent", + agentDefinition: DefineTicketRoutingAgent(configuration, plugin), + agentDescription: "Routing agent for CustomerSupport workflow"); + + await aiProjectClient.CreateAgentAsync( + agentName: "WindowsSupportAgent", + agentDefinition: DefineWindowsSupportAgent(configuration, plugin), + agentDescription: "Windows support agent for CustomerSupport workflow"); + + await aiProjectClient.CreateAgentAsync( + agentName: "TicketResolutionAgent", + agentDefinition: DefineResolutionAgent(configuration, plugin), + agentDescription: "Resolution agent for CustomerSupport workflow"); + + await aiProjectClient.CreateAgentAsync( + agentName: "TicketEscalationAgent", + agentDefinition: TicketEscalationAgent(configuration, plugin), + agentDescription: "Escalate agent for human support"); + } + + private static PromptAgentDefinition DefineSelfServiceAgent(IConfiguration configuration) => + new(configuration.GetValue(Application.Settings.FoundryModelMini)) + { + Instructions = + """ + Use your knowledge to work with the user to provide the best possible troubleshooting steps. + + - If the user confirms that the issue is resolved, then the issue is resolved. + - If the user reports that the issue persists, then escalate. + """, + TextOptions = + new ResponseTextOptions + { + TextFormat = + ResponseTextFormat.CreateJsonSchemaFormat( + "TaskEvaluation", + BinaryData.FromString( + """ + { + "type": "object", + "properties": { + "IsResolved": { + "type": "boolean", + "description": "True if the user issue/ask has been resolved." + }, + "NeedsTicket": { + "type": "boolean", + "description": "True if the user issue/ask requires that a ticket be filed." + }, + "IssueDescription": { + "type": "string", + "description": "A concise description of the issue." + }, + "AttemptedResolutionSteps": { + "type": "string", + "description": "An outline of the steps taken to attempt resolution." + } + }, + "required": ["IsResolved", "NeedsTicket", "IssueDescription", "AttemptedResolutionSteps"], + "additionalProperties": false + } + """), + jsonSchemaFormatDescription: null, + jsonSchemaIsStrict: true), + } + }; + + private static PromptAgentDefinition DefineTicketingAgent(IConfiguration configuration, TicketingPlugin plugin) => + new(configuration.GetValue(Application.Settings.FoundryModelMini)) + { + Instructions = + """ + Always create a ticket in Azure DevOps using the available tools. + + Include the following information in the TicketSummary. + + - Issue description: {{IssueDescription}} + - Attempted resolution steps: {{AttemptedResolutionSteps}} + + After creating the ticket, provide the user with the ticket ID. + """, + Tools = + { + AIFunctionFactory.Create(plugin.CreateTicket).AsOpenAIResponseTool() + }, + StructuredInputs = + { + ["IssueDescription"] = + new StructuredInputDefinition + { + IsRequired = false, + DefaultValue = BinaryData.FromString(@"""unknown"""), + Description = "A concise description of the issue.", + }, + ["AttemptedResolutionSteps"] = + new StructuredInputDefinition + { + IsRequired = false, + DefaultValue = BinaryData.FromString(@"""unknown"""), + Description = "An outline of the steps taken to attempt resolution.", + } + }, + TextOptions = + new ResponseTextOptions + { + TextFormat = + ResponseTextFormat.CreateJsonSchemaFormat( + "TaskEvaluation", + BinaryData.FromString( + """ + { + "type": "object", + "properties": { + "TicketId": { + "type": "string", + "description": "The identifier of the ticket created in response to the user issue." + }, + "TicketSummary": { + "type": "string", + "description": "The summary of the ticket created in response to the user issue." + } + }, + "required": ["TicketId", "TicketSummary"], + "additionalProperties": false + } + """), + jsonSchemaFormatDescription: null, + jsonSchemaIsStrict: true), + } + }; + + private static PromptAgentDefinition DefineTicketRoutingAgent(IConfiguration configuration, TicketingPlugin plugin) => + new(configuration.GetValue(Application.Settings.FoundryModelMini)) + { + Instructions = + """ + Determine how to route the given issue to the appropriate support team. + + Choose from the available teams and their functions: + - Windows Activation Support: Windows license activation issues + - Windows Support: Windows related issues + - Azure Support: Azure related issues + - Network Support: Network related issues + - Hardware Support: Hardware related issues + - Microsoft Office Support: Microsoft Office related issues + - General Support: General issues not related to the above categories + """, + Tools = + { + AIFunctionFactory.Create(plugin.GetTicket).AsOpenAIResponseTool(), + }, + TextOptions = + new ResponseTextOptions + { + TextFormat = + ResponseTextFormat.CreateJsonSchemaFormat( + "TaskEvaluation", + BinaryData.FromString( + """ + { + "type": "object", + "properties": { + "TeamName": { + "type": "string", + "description": "The name of the team to route the issue" + } + }, + "required": ["TeamName"], + "additionalProperties": false + } + """), + jsonSchemaFormatDescription: null, + jsonSchemaIsStrict: true), + } + }; + + private static PromptAgentDefinition DefineWindowsSupportAgent(IConfiguration configuration, TicketingPlugin plugin) => + new(configuration.GetValue(Application.Settings.FoundryModelMini)) + { + Instructions = + """ + Use your knowledge to work with the user to provide the best possible troubleshooting steps + for issues related to Windows operating system. + + - Utilize the "Attempted Resolutions Steps" as a starting point for your troubleshooting. + - Never escalate without troubleshooting with the user. + - If the user confirms that the issue is resolved, then the issue is resolved. + - If the user reports that the issue persists, then escalate. + + Issue: {{IssueDescription}} + Attempted Resolution Steps: {{AttemptedResolutionSteps}} + """, + StructuredInputs = + { + ["IssueDescription"] = + new StructuredInputDefinition + { + IsRequired = false, + DefaultValue = BinaryData.FromString(@"""unknown"""), + Description = "A concise description of the issue.", + }, + ["AttemptedResolutionSteps"] = + new StructuredInputDefinition + { + IsRequired = false, + DefaultValue = BinaryData.FromString(@"""unknown"""), + Description = "An outline of the steps taken to attempt resolution.", + } + }, + Tools = + { + AIFunctionFactory.Create(plugin.GetTicket).AsOpenAIResponseTool(), + }, + TextOptions = + new ResponseTextOptions + { + TextFormat = + ResponseTextFormat.CreateJsonSchemaFormat( + "TaskEvaluation", + BinaryData.FromString( + """ + { + "type": "object", + "properties": { + "IsResolved": { + "type": "boolean", + "description": "True if the user issue/ask has been resolved." + }, + "NeedsEscalation": { + "type": "boolean", + "description": "True resolution could not be achieved and the issue/ask requires escalation." + }, + "ResolutionSummary": { + "type": "string", + "description": "The summary of the steps that led to resolution." + } + }, + "required": ["IsResolved", "NeedsEscalation", "ResolutionSummary"], + "additionalProperties": false + } + """), + jsonSchemaFormatDescription: null, + jsonSchemaIsStrict: true), + } + }; + + private static PromptAgentDefinition DefineResolutionAgent(IConfiguration configuration, TicketingPlugin plugin) => + new(configuration.GetValue(Application.Settings.FoundryModelMini)) + { + Instructions = + """ + Resolve the following ticket in Azure DevOps. + Always include the resolution details. + + - Ticket ID: #{{TicketId}} + - Resolution Summary: {{ResolutionSummary}} + """, + Tools = + { + AIFunctionFactory.Create(plugin.ResolveTicket).AsOpenAIResponseTool(), + }, + StructuredInputs = + { + ["TicketId"] = + new StructuredInputDefinition + { + IsRequired = false, + DefaultValue = BinaryData.FromString(@"""unknown"""), + Description = "The identifier of the ticket being resolved.", + }, + ["ResolutionSummary"] = + new StructuredInputDefinition + { + IsRequired = false, + DefaultValue = BinaryData.FromString(@"""unknown"""), + Description = "The steps taken to resolve the issue.", + } + } + }; + + private static PromptAgentDefinition TicketEscalationAgent(IConfiguration configuration, TicketingPlugin plugin) => + new(configuration.GetValue(Application.Settings.FoundryModelMini)) + { + Instructions = + """ + You escalate the provided issue to human support team by sending an email if the issue is not resolved. + + Here are some additional details that might help: + - TicketId : {{TicketId}} + - IssueDescription : {{IssueDescription}} + - AttemptedResolutionSteps : {{AttemptedResolutionSteps}} + + Before escalating, gather the user's email address for follow-up. + If not known, ask the user for their email address so that the support team can reach them when needed. + + When sending the email, include the following details: + - To: support@contoso.com + - Cc: user's email address + - Subject of the email: "Support Ticket - {TicketId} - [Compact Issue Description]" + - Body: + - Issue description + - Attempted resolution steps + - User's email address + - Any other relevant information from the conversation history + + Assure the user that their issue will be resolved and provide them with a ticket ID for reference. + """, + Tools = + { + AIFunctionFactory.Create(plugin.GetTicket).AsOpenAIResponseTool(), + AIFunctionFactory.Create(plugin.SendNotification).AsOpenAIResponseTool(), + }, + StructuredInputs = + { + ["TicketId"] = + new StructuredInputDefinition + { + IsRequired = false, + DefaultValue = BinaryData.FromString(@"""unknown"""), + Description = "The identifier of the ticket being escalated.", + }, + ["IssueDescription"] = + new StructuredInputDefinition + { + IsRequired = false, + DefaultValue = BinaryData.FromString(@"""unknown"""), + Description = "A concise description of the issue.", + }, + ["ResolutionSummary"] = + new StructuredInputDefinition + { + IsRequired = false, + DefaultValue = BinaryData.FromString(@"""unknown"""), + Description = "An outline of the steps taken to attempt resolution.", + } + }, + TextOptions = + new ResponseTextOptions + { + TextFormat = + ResponseTextFormat.CreateJsonSchemaFormat( + "TaskEvaluation", + BinaryData.FromString( + """ + { + "type": "object", + "properties": { + "IsComplete": { + "type": "boolean", + "description": "Has the email been sent and no more user input is required." + }, + "UserMessage": { + "type": "string", + "description": "A natural language message to the user." + } + }, + "required": ["IsComplete", "UserMessage"], + "additionalProperties": false + } + """), + jsonSchemaFormatDescription: null, + jsonSchemaIsStrict: true), + } + }; +} diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/CustomerSupport/TicketingPlugin.cs b/dotnet/samples/GettingStarted/Workflows/Declarative/CustomerSupport/TicketingPlugin.cs new file mode 100644 index 0000000000..831af0c4d6 --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/CustomerSupport/TicketingPlugin.cs @@ -0,0 +1,85 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.ComponentModel; + +namespace Demo.Workflows.Declarative.CustomerSupport; + +internal sealed class TicketingPlugin +{ + private readonly Dictionary _ticketStore = []; + + [Description("Retrieve a ticket by identifier from Azure DevOps.")] + public TicketItem? GetTicket(string id) + { + Trace(nameof(GetTicket)); + + this._ticketStore.TryGetValue(id, out TicketItem? ticket); + + return ticket; + } + + [Description("Create a ticket in Azure DevOps and return its identifier.")] + public string CreateTicket(string subject, string description, string notes) + { + Trace(nameof(CreateTicket)); + + TicketItem ticket = new() + { + Subject = subject, + Description = description, + Notes = notes, + Id = Guid.NewGuid().ToString("N"), + }; + + this._ticketStore[ticket.Id] = ticket; + + return ticket.Id; + } + + [Description("Resolve an existing ticket in Azure DevOps given its identifier.")] + public void ResolveTicket(string id, string resolutionSummary) + { + Trace(nameof(ResolveTicket)); + + if (this._ticketStore.TryGetValue(id, out TicketItem? ticket)) + { + ticket.Status = TicketStatus.Resolved; + } + } + + [Description("Send an email notification to escalate ticket engagement.")] + public void SendNotification(string id, string email, string cc, string body) + { + Trace(nameof(SendNotification)); + } + + private static void Trace(string functionName) + { + Console.ForegroundColor = ConsoleColor.DarkMagenta; + try + { + Console.WriteLine($"\nFUNCTION: {functionName}"); + } + finally + { + Console.ResetColor(); + } + } + + public enum TicketStatus + { + Open, + InProgress, + Resolved, + Closed, + } + + public sealed class TicketItem + { + public TicketStatus Status { get; set; } = TicketStatus.Open; + public string Subject { get; init; } = string.Empty; + public string Id { get; init; } = string.Empty; + public string Description { get; init; } = string.Empty; + public string Notes { get; init; } = string.Empty; + } +} diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/DeepResearch/DeepResearch.csproj b/dotnet/samples/GettingStarted/Workflows/Declarative/DeepResearch/DeepResearch.csproj new file mode 100644 index 0000000000..619c727b1b --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/DeepResearch/DeepResearch.csproj @@ -0,0 +1,43 @@ + + + + Exe + net9.0 + net9.0 + $(ProjectsDebugTargetFrameworks) + enable + enable + + + + true + true + true + true + + + + + + + + + + + + + + + + + + + + Always + + + Always + + + + diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/DeepResearch/Program.cs b/dotnet/samples/GettingStarted/Workflows/Declarative/DeepResearch/Program.cs new file mode 100644 index 0000000000..7aaa61b398 --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/DeepResearch/Program.cs @@ -0,0 +1,281 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Azure.AI.Projects; +using Azure.AI.Projects.OpenAI; +using Azure.Identity; +using Microsoft.Extensions.Configuration; +using OpenAI.Responses; +using Shared.Foundry; +using Shared.Workflows; + +namespace Demo.Workflows.Declarative.DeepResearch; + +/// +/// Demonstrate a declarative workflow that accomplishes a task +/// using the Magentic orchestration pattern developed by AutoGen. +/// +/// +/// See the README.md file in the parent folder (../README.md) for detailed +/// information about the configuration required to run this sample. +/// +internal sealed class Program +{ + public static async Task Main(string[] args) + { + // Initialize configuration + IConfiguration configuration = Application.InitializeConfig(); + Uri foundryEndpoint = new(configuration.GetValue(Application.Settings.FoundryEndpoint)); + + // Ensure sample agents exist in Foundry. + await CreateAgentsAsync(foundryEndpoint, configuration); + + // Get input from command line or console + string workflowInput = Application.GetInput(args); + + // Create the workflow factory. This class demonstrates how to initialize a + // declarative workflow from a YAML file. Once the workflow is created, it + // can be executed just like any regular workflow. + WorkflowFactory workflowFactory = new("DeepResearch.yaml", foundryEndpoint); + + // Execute the workflow: The WorkflowRunner demonstrates how to execute + // a workflow, handle the workflow events, and providing external input. + // This also includes the ability to checkpoint workflow state and how to + // resume execution. + WorkflowRunner runner = new(); + await runner.ExecuteAsync(workflowFactory.CreateWorkflow, workflowInput); + } + + private static async Task CreateAgentsAsync(Uri foundryEndpoint, IConfiguration configuration) + { + AIProjectClient aiProjectClient = new(foundryEndpoint, new AzureCliCredential()); + + await aiProjectClient.CreateAgentAsync( + agentName: "ResearchAgent", + agentDefinition: DefineResearchAgent(configuration), + agentDescription: "Planner agent for DeepResearch workflow"); + + await aiProjectClient.CreateAgentAsync( + agentName: "PlannerAgent", + agentDefinition: DefinePlannerAgent(configuration), + agentDescription: "Planner agent for DeepResearch workflow"); + + await aiProjectClient.CreateAgentAsync( + agentName: "ManagerAgent", + agentDefinition: DefineManagerAgent(configuration), + agentDescription: "Manager agent for DeepResearch workflow"); + + await aiProjectClient.CreateAgentAsync( + agentName: "SummaryAgent", + agentDefinition: DefineSummaryAgent(configuration), + agentDescription: "Summary agent for DeepResearch workflow"); + + await aiProjectClient.CreateAgentAsync( + agentName: "KnowledgeAgent", + agentDefinition: DefineKnowledgeAgent(configuration), + agentDescription: "Research agent for DeepResearch workflow"); + + await aiProjectClient.CreateAgentAsync( + agentName: "CoderAgent", + agentDefinition: DefineCoderAgent(configuration), + agentDescription: "Coder agent for DeepResearch workflow"); + + await aiProjectClient.CreateAgentAsync( + agentName: "WeatherAgent", + agentDefinition: DefineWeatherAgent(configuration), + agentDescription: "Weather agent for DeepResearch workflow"); + } + + private static PromptAgentDefinition DefineResearchAgent(IConfiguration configuration) => + new(configuration.GetValue(Application.Settings.FoundryModelFull)) + { + Instructions = + """ + In order to help begin addressing the user request, please answer the following pre-survey to the best of your ability. + Keep in mind that you are Ken Jennings-level with trivia, and Mensa-level with puzzles, so there should be a deep well to draw from. + + Here is the pre-survey: + + 1. Please list any specific facts or figures that are GIVEN in the request itself. It is possible that there are none. + 2. Please list any facts that may need to be looked up, and WHERE SPECIFICALLY they might be found. In some cases, authoritative sources are mentioned in the request itself. + 3. Please list any facts that may need to be derived (e.g., via logical deduction, simulation, or computation) + 4. Please list any facts that are recalled from memory, hunches, well-reasoned guesses, etc. + + When answering this survey, keep in mind that 'facts' will typically be specific names, dates, statistics, etc. Your answer must only use the headings: + + 1. GIVEN OR VERIFIED FACTS + 2. FACTS TO LOOK UP + 3. FACTS TO DERIVE + 4. EDUCATED GUESSES + + DO NOT include any other headings or sections in your response. DO NOT list next steps or plans until asked to do so. + """, + Tools = + { + //AgentTool.CreateBingGroundingTool( // TODO: Use Bing Grounding when available + // new BingGroundingSearchToolParameters( + // [new BingGroundingSearchConfiguration(this.GetSetting(Settings.FoundryGroundingTool))])) + } + }; + + private static PromptAgentDefinition DefinePlannerAgent(IConfiguration configuration) => + new(configuration.GetValue(Application.Settings.FoundryModelMini)) + { + Instructions = // TODO: Use Structured Inputs / Prompt Template + """ + Your only job is to devise an efficient plan that identifies (by name) how a team member may contribute to addressing the user request. + + Only select the following team which is listed as "- [Name]: [Description]" + + - WeatherAgent: Able to retrieve weather information + - CoderAgent: Able to write and execute Python code + - KnowledgeAgent: Able to perform generic websearches + + The plan must be a bullet point list must be in the form "- [AgentName]: [Specific action or task for that agent to perform]" + + Remember, there is no requirement to involve the entire team -- only select team member's whose particular expertise is required for this task. + """ + }; + + private static PromptAgentDefinition DefineManagerAgent(IConfiguration configuration) => + new(configuration.GetValue(Application.Settings.FoundryModelMini)) + { + Instructions = // TODO: Use Structured Inputs / Prompt Template + """ + Recall we have assembled the following team: + + - KnowledgeAgent: Able to perform generic websearches + - CoderAgent: Able to write and execute Python code + - WeatherAgent: Able to retrieve weather information + + To make progress on the request, please answer the following questions, including necessary reasoning: + - Is the request fully satisfied? (True if complete, or False if the original request has yet to be SUCCESSFULLY and FULLY addressed) + - Are we in a loop where we are repeating the same requests and / or getting the same responses from an agent multiple times? Loops can span multiple turns, and can include repeated actions like scrolling up or down more than a handful of times. + - Are we making forward progress? (True if just starting, or recent messages are adding value. False if recent messages show evidence of being stuck in a loop or if there is evidence of significant barriers to success such as the inability to read from a required file) + - Who should speak next? (select from: KnowledgeAgent, CoderAgent, WeatherAgent) + - What instruction or question would you give this team member? (Phrase as if speaking directly to them, and include any specific information they may need) + """, + TextOptions = + new ResponseTextOptions + { + TextFormat = + ResponseTextFormat.CreateJsonSchemaFormat( + "TaskEvaluation", + BinaryData.FromString( + """ + { + "type": "object", + "properties": { + "is_request_satisfied": { + "type": "object", + "properties": { + "reason": { "type": "string" }, + "answer": { "type": "boolean" } + }, + "required": ["reason", "answer"], + "additionalProperties": false + }, + "is_in_loop": { + "type": "object", + "properties": { + "reason": { "type": "string" }, + "answer": { "type": "boolean" } + }, + "required": ["reason", "answer"], + "additionalProperties": false + }, + "is_progress_being_made": { + "type": "object", + "properties": { + "reason": { "type": "string" }, + "answer": { "type": "boolean" } + }, + "required": ["reason", "answer"], + "additionalProperties": false + }, + "next_speaker": { + "type": "object", + "properties": { + "reason": { "type": "string" }, + "answer": { + "type": "string" + } + }, + "required": ["reason", "answer"], + "additionalProperties": false + }, + "instruction_or_question": { + "type": "object", + "properties": { + "reason": { "type": "string" }, + "answer": { "type": "string" } + }, + "required": ["reason", "answer"], + "additionalProperties": false + } + }, + "required": ["is_request_satisfied", "is_in_loop", "is_progress_being_made", "next_speaker", "instruction_or_question"], + "additionalProperties": false + } + """), + jsonSchemaFormatDescription: null, + jsonSchemaIsStrict: true), + } + }; + + private static PromptAgentDefinition DefineSummaryAgent(IConfiguration configuration) => + new(configuration.GetValue(Application.Settings.FoundryModelMini)) + { + Instructions = + """ + We have completed the task. + + Based only on the conversation and without adding any new information, + synthesize the result of the conversation as a complete response to the user task. + + The user will only ever see this last response and not the entire conversation, + so please ensure it is complete and self-contained. + """ + }; + + private static PromptAgentDefinition DefineKnowledgeAgent(IConfiguration configuration) => + new(configuration.GetValue(Application.Settings.FoundryModelMini)) + { + Tools = + { + //AgentTool.CreateBingGroundingTool( // TODO: Use Bing Grounding when available + // new BingGroundingSearchToolParameters( + // [new BingGroundingSearchConfiguration(this.GetSetting(Settings.FoundryGroundingTool))])) + } + }; + + private static PromptAgentDefinition DefineCoderAgent(IConfiguration configuration) => + new(configuration.GetValue(Application.Settings.FoundryModelMini)) + { + Instructions = + """ + You solve problem by writing and executing code. + """, + Tools = + { + ResponseTool.CreateCodeInterpreterTool( + new(CodeInterpreterToolContainerConfiguration.CreateAutomaticContainerConfiguration())) + } + }; + + private static PromptAgentDefinition DefineWeatherAgent(IConfiguration configuration) => + new(configuration.GetValue(Application.Settings.FoundryModelMini)) + { + Instructions = + """ + You are a weather expert. + """, + Tools = + { + AgentTool.CreateOpenApiTool( + new OpenAPIFunctionDefinition( + "weather-forecast", + BinaryData.FromString(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "wttr.json"))), + new OpenAPIAnonymousAuthenticationDetails())) + } + }; +} diff --git a/workflow-samples/wttr.json b/dotnet/samples/GettingStarted/Workflows/Declarative/DeepResearch/wttr.json similarity index 100% rename from workflow-samples/wttr.json rename to dotnet/samples/GettingStarted/Workflows/Declarative/DeepResearch/wttr.json diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteCode/ExecuteCode.csproj b/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteCode/ExecuteCode.csproj index 72afa29cda..ca7c10cde3 100644 --- a/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteCode/ExecuteCode.csproj +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteCode/ExecuteCode.csproj @@ -12,7 +12,9 @@ - true + true + true + true @@ -27,6 +29,7 @@ + diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteCode/Generated.cs b/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteCode/Generated.cs index d1c8d45082..6fc508064e 100644 --- a/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteCode/Generated.cs +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteCode/Generated.cs @@ -5,6 +5,7 @@ // ------------------------------------------------------------------------------ #nullable enable +#pragma warning disable IDE0005 // Extra using directive is ok. using System; using System.Collections; @@ -18,7 +19,7 @@ using Microsoft.Agents.AI.Workflows.Declarative; using Microsoft.Agents.AI.Workflows.Declarative.Kit; using Microsoft.Extensions.AI; -namespace Test.WorkflowProviders; +namespace Demo.DeclarativeCode; /// /// This class provides a factory method to create a instance. @@ -29,7 +30,7 @@ namespace Test.WorkflowProviders; /// To learn more about Power FX, see: /// https://learn.microsoft.com/power-platform/power-fx/formula-reference-copilot-studio /// -public static class TestWorkflowProvider +public static class SampleWorkflowProvider { /// /// The root executor for a declarative workflow. @@ -42,845 +43,26 @@ public static class TestWorkflowProvider { protected override async ValueTask ExecuteAsync(TInput message, IWorkflowContext context, CancellationToken cancellationToken) { - // Set environment variables - await this.InitializeEnvironmentAsync( - context, - "FOUNDRY_AGENT_RESEARCHWEB", - "FOUNDRY_AGENT_RESEARCHANALYST", - "FOUNDRY_AGENT_RESEARCHCODER", - "FOUNDRY_AGENT_RESEARCHMANAGER", - "FOUNDRY_AGENT_RESEARCHWEATHER").ConfigureAwait(false); - - // Initialize variables - await context.QueueStateUpdateAsync("AgentResponse", UnassignedValue.Instance, "Local"); - await context.QueueStateUpdateAsync("AgentResponseText", UnassignedValue.Instance, "Local"); - await context.QueueStateUpdateAsync("AvailableAgents", UnassignedValue.Instance, "Local"); - await context.QueueStateUpdateAsync("FinalResponse", UnassignedValue.Instance, "Local"); - await context.QueueStateUpdateAsync("InputTask", UnassignedValue.Instance, "Local"); - await context.QueueStateUpdateAsync("InternalConversationId", UnassignedValue.Instance, "Local"); - await context.QueueStateUpdateAsync("NextSpeaker", UnassignedValue.Instance, "Local"); - await context.QueueStateUpdateAsync("Plan", UnassignedValue.Instance, "Local"); - await context.QueueStateUpdateAsync("ProgressLedgerUpdate", UnassignedValue.Instance, "Local"); - await context.QueueStateUpdateAsync("RestartCount", UnassignedValue.Instance, "Local"); - await context.QueueStateUpdateAsync("SeedTask", UnassignedValue.Instance, "Local"); - await context.QueueStateUpdateAsync("StallCount", UnassignedValue.Instance, "Local"); - await context.QueueStateUpdateAsync("TaskFacts", UnassignedValue.Instance, "Local"); - await context.QueueStateUpdateAsync("TaskInstructions", UnassignedValue.Instance, "Local"); - await context.QueueStateUpdateAsync("TeamDescription", UnassignedValue.Instance, "Local"); - await context.QueueStateUpdateAsync("TypedProgressLedger", UnassignedValue.Instance, "Local"); - } - } - - /// - /// Assigns an evaluated expression, other variable, or literal value to the "Local.AvailableAgents" variable. - /// - internal sealed class SetvariableAaslmfExecutor(FormulaSession session) : ActionExecutor(id: "setVariable_aASlmF", session) - { - // - protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) - { - object? evaluatedValue = await context.EvaluateValueAsync(""" - [ - { - name: "WeatherAgent", - description: "Able to retrieve weather information", - agentid: Env.FOUNDRY_AGENT_RESEARCHWEATHER - }, - { - name: "CoderAgent", - description: "Able to write and execute Python code", - agentid: Env.FOUNDRY_AGENT_RESEARCHCODER - }, - { - name: "WebAgent", - description: "Able to perform generic websearches", - agentid: Env.FOUNDRY_AGENT_RESEARCHWEB - } - ] - """); - await context.QueueStateUpdateAsync(key: "AvailableAgents", value: evaluatedValue, scopeName: "Local"); - - return default; - } - } - - /// - /// Assigns an evaluated expression, other variable, or literal value to the "Local.TeamDescription" variable. - /// - internal sealed class SetvariableV6yeboExecutor(FormulaSession session) : ActionExecutor(id: "setVariable_V6yEbo", session) - { - // - protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) - { - object? evaluatedValue = await context.EvaluateValueAsync(""" - Concat(ForAll(Local.AvailableAgents, $"- " & name & $": " & description), Value, " - ") - """); - await context.QueueStateUpdateAsync(key: "TeamDescription", value: evaluatedValue, scopeName: "Local"); - - return default; - } - } - - /// - /// Assigns an evaluated expression, other variable, or literal value to the "Local.InputTask" variable. - /// - internal sealed class SetvariableNz2u0lExecutor(FormulaSession session) : ActionExecutor(id: "setVariable_NZ2u0l", session) - { - // - protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) - { - object? evaluatedValue = await context.EvaluateValueAsync("System.LastMessage.Text"); - await context.QueueStateUpdateAsync(key: "InputTask", value: evaluatedValue, scopeName: "Local"); - - return default; - } - } - - /// - /// Assigns an evaluated expression, other variable, or literal value to the "Local.SeedTask" variable. - /// - internal sealed class Setvariable10U2znExecutor(FormulaSession session) : ActionExecutor(id: "setVariable_10u2ZN", session) - { - // - protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) - { - object? evaluatedValue = await context.EvaluateValueAsync("UserMessage(Local.InputTask)"); - await context.QueueStateUpdateAsync(key: "SeedTask", value: evaluatedValue, scopeName: "Local"); - - return default; - } - } - - /// - /// Formats a message template and sends an activity event. - /// - internal sealed class SendactivityYfsbryExecutor(FormulaSession session) : ActionExecutor(id: "sendActivity_yFsbRy", session) - { - // - protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) - { - string activityText = - await context.FormatTemplateAsync( - """ - Analyzing facts... - """ - ); - AgentRunResponse response = new([new ChatMessage(ChatRole.Assistant, activityText)]); - await context.AddEventAsync(new AgentRunResponseEvent(this.Id, response)); - - return default; - } - } - - /// - /// Creates a new conversation and stores the identifier value to the "Local.InternalConversationId" variable. - /// - internal sealed class Conversation1A2b3cExecutor(FormulaSession session, WorkflowAgentProvider agentProvider) : ActionExecutor(id: "conversation_1a2b3c", session) - { - protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) - { - string conversationId = await agentProvider.CreateConversationAsync(cancellationToken); - await context.QueueStateUpdateAsync(key: "InternalConversationId", value: conversationId, scopeName: "Local"); - - return default; } } /// /// Invokes an agent to process messages and return a response within a conversation context. /// - internal sealed class QuestionUdomuwExecutor(FormulaSession session, WorkflowAgentProvider agentProvider) : AgentExecutor(id: "question_UDoMUw", session, agentProvider) + internal sealed class QuestionStudentExecutor(FormulaSession session, WorkflowAgentProvider agentProvider) : AgentExecutor(id: "question_student", session, agentProvider) { // protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { - string? agentName = await context.ReadStateAsync(key: "FOUNDRY_AGENT_RESEARCHANALYST", scopeName: "Env"); + string? agentName = "StudentAgent"; if (string.IsNullOrWhiteSpace(agentName)) { - throw new InvalidOperationException($"Agent name must be defined: {this.Id}"); + throw new DeclarativeActionException($"Agent name must be defined: {this.Id}"); } - string? conversationId = await context.ReadStateAsync(key: "InternalConversationId", scopeName: "Local"); + string? conversationId = await context.ReadStateAsync(key: "ConversationId", scopeName: "System").ConfigureAwait(false); bool autoSend = true; - string additionalInstructions = - await context.FormatTemplateAsync( - """ - In order to help begin addressing the user request, please answer the following pre-survey to the best of your ability. - Keep in mind that you are Ken Jennings-level with trivia, and Mensa-level with puzzles, so there should be a deep well to draw from. - - Here is the pre-survey: - - 1. Please list any specific facts or figures that are GIVEN in the request itself. It is possible that there are none. - 2. Please list any facts that may need to be looked up, and WHERE SPECIFICALLY they might be found. In some cases, authoritative sources are mentioned in the request itself. - 3. Please list any facts that may need to be derived (e.g., via logical deduction, simulation, or computation) - 4. Please list any facts that are recalled from memory, hunches, well-reasoned guesses, etc. - - When answering this survey, keep in mind that 'facts' will typically be specific names, dates, statistics, etc. Your answer must only use the headings: - - 1. GIVEN OR VERIFIED FACTS - 2. FACTS TO LOOK UP - 3. FACTS TO DERIVE - 4. EDUCATED GUESSES - - DO NOT include any other headings or sections in your response. DO NOT list next steps or plans until asked to do so. - """); - IList? inputMessages = await context.EvaluateListAsync("UserMessage(Local.InputTask)"); - - AgentRunResponse agentResponse = - await InvokeAgentAsync( - context, - agentName, - conversationId, - autoSend, - additionalInstructions, - inputMessages, - cancellationToken); - - if (autoSend) - { - await context.AddEventAsync(new AgentRunResponseEvent(this.Id, agentResponse)); - } - - await context.QueueStateUpdateAsync(key: "TaskFacts", value: agentResponse.Messages, scopeName: "Local"); - - return default; - } - } - - /// - /// Formats a message template and sends an activity event. - /// - internal sealed class SendactivityYfsbrzExecutor(FormulaSession session) : ActionExecutor(id: "sendActivity_yFsbRz", session) - { - // - protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) - { - string activityText = - await context.FormatTemplateAsync( - """ - Creating a plan... - """ - ); - AgentRunResponse response = new([new ChatMessage(ChatRole.Assistant, activityText)]); - await context.AddEventAsync(new AgentRunResponseEvent(this.Id, response)); - - return default; - } - } - - /// - /// Invokes an agent to process messages and return a response within a conversation context. - /// - internal sealed class QuestionDsbajuExecutor(FormulaSession session, WorkflowAgentProvider agentProvider) : AgentExecutor(id: "question_DsBaJU", session, agentProvider) - { - // - protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) - { - string? agentName = await context.ReadStateAsync(key: "FOUNDRY_AGENT_RESEARCHMANAGER", scopeName: "Env"); - - if (string.IsNullOrWhiteSpace(agentName)) - { - throw new InvalidOperationException($"Agent name must be defined: {this.Id}"); - } - - string? conversationId = await context.ReadStateAsync(key: "InternalConversationId", scopeName: "Local"); - bool autoSend = true; - string additionalInstructions = - await context.FormatTemplateAsync( - """ - Your only job is to devise an efficient plan that identifies (by name) how a team member may contribute to addressing the user request. - - Only select the following team which is listed as "- [Name]: [Description]" - - {Local.TeamDescription} - - The plan must be a bullet point list must be in the form "- [AgentName]: [Specific action or task for that agent to perform]" - - Remember, there is no requirement to involve the entire team -- only select team member's whose particular expertise is required for this task. - """); - IList? inputMessages = await context.EvaluateListAsync("UserMessage(Local.InputTask)"); - - AgentRunResponse agentResponse = - await InvokeAgentAsync( - context, - agentName, - conversationId, - autoSend, - additionalInstructions, - inputMessages, - cancellationToken); - - if (autoSend) - { - await context.AddEventAsync(new AgentRunResponseEvent(this.Id, agentResponse)); - } - - await context.QueueStateUpdateAsync(key: "Plan", value: agentResponse.Messages, scopeName: "Local"); - - return default; - } - } - - /// - /// Assigns an evaluated expression, other variable, or literal value to the "Local.TaskInstructions" variable. - /// - internal sealed class SetvariableKk2ldlExecutor(FormulaSession session) : ActionExecutor(id: "setVariable_Kk2LDL", session) - { - // - protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) - { - object? evaluatedValue = await context.EvaluateValueAsync(""" - "# TASK - Address the following user request: - - " & Local.InputTask & " - - - # TEAM - Use the following team to answer this request: - - " & Local.TeamDescription & " - - - # FACTS - Consider this initial fact sheet: - - " & Trim(Last(Local.TaskFacts).Text) & " - - - # PLAN - Here is the plan to follow as best as possible: - - " & Last(Local.Plan).Text - """); - await context.QueueStateUpdateAsync(key: "TaskInstructions", value: evaluatedValue, scopeName: "Local"); - - return default; - } - } - - /// - /// Formats a message template and sends an activity event. - /// - internal sealed class SendactivityBwnzimExecutor(FormulaSession session) : ActionExecutor(id: "sendActivity_bwNZiM", session) - { - // - protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) - { - string activityText = - await context.FormatTemplateAsync( - """ - {Local.TaskInstructions} - """ - ); - AgentRunResponse response = new([new ChatMessage(ChatRole.Assistant, activityText)]); - await context.AddEventAsync(new AgentRunResponseEvent(this.Id, response)); - - return default; - } - } - - /// - /// Invokes an agent to process messages and return a response within a conversation context. - /// - internal sealed class QuestionO3bqkfExecutor(FormulaSession session, WorkflowAgentProvider agentProvider) : AgentExecutor(id: "question_o3BQkf", session, agentProvider) - { - // - protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) - { - string? agentName = await context.ReadStateAsync(key: "FOUNDRY_AGENT_RESEARCHMANAGER", scopeName: "Env"); - - if (string.IsNullOrWhiteSpace(agentName)) - { - throw new InvalidOperationException($"Agent name must be defined: {this.Id}"); - } - - string? conversationId = await context.ReadStateAsync(key: "InternalConversationId", scopeName: "Local"); - bool autoSend = true; - string additionalInstructions = - await context.FormatTemplateAsync( - """ - Recall we are working on the following request: - - {Local.InputTask} - - And we have assembled the following team: - - {Local.TeamDescription} - - To make progress on the request, please answer the following questions, including necessary reasoning: - - - Is the request fully satisfied? (True if complete, or False if the original request has yet to be SUCCESSFULLY and FULLY addressed) - - Are we in a loop where we are repeating the same requests and / or getting the same responses from an agent multiple times? Loops can span multiple turns, and can include repeated actions like scrolling up or down more than a handful of times. - - Are we making forward progress? (True if just starting, or recent messages are adding value. False if recent messages show evidence of being stuck in a loop or if there is evidence of significant barriers to success such as the inability to read from a required file) - - Who should speak next? (select from: {Concat(Local.AvailableAgents, name, ",")}) - - What instruction or question would you give this team member? (Phrase as if speaking directly to them, and include any specific information they may need) - - Please output an answer in pure JSON format according to the following schema. The JSON object must be parsable as-is. DO NOT OUTPUT ANYTHING OTHER THAN JSON, AND DO NOT DEVIATE FROM THIS SCHEMA: - - {{ - "is_request_satisfied": {{ - "reason": string, - "answer": boolean - }}, - "is_in_loop": {{ - "reason": string, - "answer": boolean - }}, - "is_progress_being_made": {{ - "reason": string, - "answer": boolean - }}, - "next_speaker": {{ - "reason": string, - "answer": string (select from: {Concat(Local.AvailableAgents, name, ",")}) - }}, - "instruction_or_question": {{ - "reason": string, - "answer": string - }} - }} - """); - IList? inputMessages = await context.EvaluateListAsync("UserMessage(Local.AgentResponseText)"); - - AgentRunResponse agentResponse = - await InvokeAgentAsync( - context, - agentName, - conversationId, - autoSend, - additionalInstructions, - inputMessages, - cancellationToken); - - if (autoSend) - { - await context.AddEventAsync(new AgentRunResponseEvent(this.Id, agentResponse)); - } - - await context.QueueStateUpdateAsync(key: "ProgressLedgerUpdate", value: agentResponse.Messages, scopeName: "Local"); - - return default; - } - } - - /// - /// Parses a string or untyped value to the provided data type. When the input is a string, it will be treated as JSON. - /// - internal sealed class ParseRnztlvExecutor(FormulaSession session) : ActionExecutor(id: "parse_rNZtlV", session) - { - // - protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) - { - VariableType targetType = - VariableType.Record( - ("is_progress_being_made", - VariableType.Record( - ("reason", typeof(string)), - ("answer", typeof(bool)))), - ("is_request_satisfied", - VariableType.Record( - ("reason", typeof(string)), - ("answer", typeof(bool)))), - ("is_in_loop", - VariableType.Record( - ("reason", typeof(string)), - ("answer", typeof(bool)))), - ("next_speaker", - VariableType.Record( - ("reason", typeof(string)), - ("answer", typeof(string)))), - ("instruction_or_question", - VariableType.Record( - ("reason", typeof(string)), - ("answer", typeof(string))))); - object? parsedValue = await context.ConvertValueAsync(targetType, "Last(Local.ProgressLedgerUpdate).Text", cancellationToken); - await context.QueueStateUpdateAsync(key: "TypedProgressLedger", value: parsedValue, scopeName: "Local"); - - return default; - } - } - - /// - /// Conditional branching similar to an if / elseif / elseif / else chain. - /// - internal sealed class ConditiongroupMvieccExecutor(FormulaSession session) : ActionExecutor(id: "conditionGroup_mVIecC", session) - { - // - protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) - { - bool condition0 = await context.EvaluateValueAsync("Local.TypedProgressLedger.is_request_satisfied.answer"); - if (condition0) - { - return "conditionItem_fj432c"; - } - - bool condition1 = await context.EvaluateValueAsync("Local.TypedProgressLedger.is_in_loop.answer || Not(Local.TypedProgressLedger.is_progress_being_made.answer)"); - if (condition1) - { - return "conditionItem_yiqund"; - } - - return "conditionGroup_mVIecCElseActions"; - } - } - - /// - /// Formats a message template and sends an activity event. - /// - internal sealed class SendactivityKdl3mcExecutor(FormulaSession session) : ActionExecutor(id: "sendActivity_kdl3mC", session) - { - // - protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) - { - string activityText = - await context.FormatTemplateAsync( - """ - Completed! {Local.TypedProgressLedger.is_request_satisfied.reason} - """ - ); - AgentRunResponse response = new([new ChatMessage(ChatRole.Assistant, activityText)]); - await context.AddEventAsync(new AgentRunResponseEvent(this.Id, response)); - - return default; - } - } - - /// - /// Invokes an agent to process messages and return a response within a conversation context. - /// - internal sealed class QuestionKe3l1dExecutor(FormulaSession session, WorkflowAgentProvider agentProvider) : AgentExecutor(id: "question_Ke3l1d", session, agentProvider) - { - // - protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) - { - string? agentName = await context.ReadStateAsync(key: "FOUNDRY_AGENT_RESEARCHMANAGER", scopeName: "Env"); - - if (string.IsNullOrWhiteSpace(agentName)) - { - throw new InvalidOperationException($"Agent name must be defined: {this.Id}"); - } - - string? conversationId = await context.ReadStateAsync(key: "ConversationId", scopeName: "System"); - bool autoSend = true; - string additionalInstructions = - await context.FormatTemplateAsync( - """ - We have completed the task. - Based only on the conversation and without adding any new information, synthesize the result of the conversation as a complete response to the user task. - The user will only every see this last response and not the entire conversation, so please ensure it is complete and self-contained. - """); - IList? inputMessages = await context.ReadListAsync(key: "SeedTask", scopeName: "Local"); - - AgentRunResponse agentResponse = - await InvokeAgentAsync( - context, - agentName, - conversationId, - autoSend, - additionalInstructions, - inputMessages, - cancellationToken); - - if (autoSend) - { - await context.AddEventAsync(new AgentRunResponseEvent(this.Id, agentResponse)); - } - - await context.QueueStateUpdateAsync(key: "FinalResponse", value: agentResponse.Messages, scopeName: "Local"); - - return default; - } - } - - /// - /// Assigns an evaluated expression, other variable, or literal value to the "Local.StallCount" variable. - /// - internal sealed class SetvariableH5lxddExecutor(FormulaSession session) : ActionExecutor(id: "setVariable_H5lXdD", session) - { - // - protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) - { - object? evaluatedValue = await context.EvaluateValueAsync("Local.StallCount + 1"); - await context.QueueStateUpdateAsync(key: "StallCount", value: evaluatedValue, scopeName: "Local"); - - return default; - } - } - - /// - /// Conditional branching similar to an if / elseif / elseif / else chain. - /// - internal sealed class ConditiongroupVbtqd3Executor(FormulaSession session) : ActionExecutor(id: "conditionGroup_vBTQd3", session) - { - // - protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) - { - bool condition0 = await context.EvaluateValueAsync(".TypedProgressLedger.is_in_loop.answer"); - if (condition0) - { - return "conditionItem_fpaNL9"; - } - - bool condition1 = await context.EvaluateValueAsync("Not(Local.TypedProgressLedger.is_progress_being_made.answer)"); - if (condition1) - { - return "conditionItem_NnqvXh"; - } - - return "conditionGroup_vBTQd3ElseActions"; - } - } - - /// - /// Formats a message template and sends an activity event. - /// - internal sealed class SendactivityFpanl9Executor(FormulaSession session) : ActionExecutor(id: "sendActivity_fpaNL9", session) - { - // - protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) - { - string activityText = - await context.FormatTemplateAsync( - """ - {Local.TypedProgressLedger.is_in_loop.reason} - """ - ); - AgentRunResponse response = new([new ChatMessage(ChatRole.Assistant, activityText)]); - await context.AddEventAsync(new AgentRunResponseEvent(this.Id, response)); - - return default; - } - } - - /// - /// Formats a message template and sends an activity event. - /// - internal sealed class SendactivityNnqvxhExecutor(FormulaSession session) : ActionExecutor(id: "sendActivity_NnqvXh", session) - { - // - protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) - { - string activityText = - await context.FormatTemplateAsync( - """ - {Local.TypedProgressLedger.is_progress_being_made.reason} - """ - ); - AgentRunResponse response = new([new ChatMessage(ChatRole.Assistant, activityText)]); - await context.AddEventAsync(new AgentRunResponseEvent(this.Id, response)); - - return default; - } - } - - /// - /// Conditional branching similar to an if / elseif / elseif / else chain. - /// - internal sealed class ConditiongroupXznrdmExecutor(FormulaSession session) : ActionExecutor(id: "conditionGroup_xzNrdM", session) - { - // - protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) - { - bool condition0 = await context.EvaluateValueAsync("Local.StallCount > 2"); - if (condition0) - { - return "conditionItem_NlQTBv"; - } - - return "conditionGroup_xzNrdMElseActions"; - } - } - - /// - /// Formats a message template and sends an activity event. - /// - internal sealed class SendactivityH5lxddExecutor(FormulaSession session) : ActionExecutor(id: "sendActivity_H5lXdD", session) - { - // - protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) - { - string activityText = - await context.FormatTemplateAsync( - """ - Unable to make sufficient progress... - """ - ); - AgentRunResponse response = new([new ChatMessage(ChatRole.Assistant, activityText)]); - await context.AddEventAsync(new AgentRunResponseEvent(this.Id, response)); - - return default; - } - } - - /// - /// Conditional branching similar to an if / elseif / elseif / else chain. - /// - internal sealed class Conditiongroup4S1z27Executor(FormulaSession session) : ActionExecutor(id: "conditionGroup_4s1Z27", session) - { - // - protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) - { - bool condition0 = await context.EvaluateValueAsync("Local.RestartCount > 2"); - if (condition0) - { - return "conditionItem_EXAlhZ"; - } - - return "conditionGroup_4s1Z27ElseActions"; - } - } - - /// - /// Formats a message template and sends an activity event. - /// - internal sealed class SendactivityXkxfuuExecutor(FormulaSession session) : ActionExecutor(id: "sendActivity_xKxFUU", session) - { - // - protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) - { - string activityText = - await context.FormatTemplateAsync( - """ - Stopping after attempting {Local.RestartCount} restarts... - """ - ); - AgentRunResponse response = new([new ChatMessage(ChatRole.Assistant, activityText)]); - await context.AddEventAsync(new AgentRunResponseEvent(this.Id, response)); - - return default; - } - } - - /// - /// Formats a message template and sends an activity event. - /// - internal sealed class SendactivityCwnzimExecutor(FormulaSession session) : ActionExecutor(id: "sendActivity_cwNZiM", session) - { - // - protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) - { - string activityText = - await context.FormatTemplateAsync( - """ - Re-analyzing facts... - """ - ); - AgentRunResponse response = new([new ChatMessage(ChatRole.Assistant, activityText)]); - await context.AddEventAsync(new AgentRunResponseEvent(this.Id, response)); - - return default; - } - } - - /// - /// Invokes an agent to process messages and return a response within a conversation context. - /// - internal sealed class QuestionWfj123Executor(FormulaSession session, WorkflowAgentProvider agentProvider) : AgentExecutor(id: "question_wFJ123", session, agentProvider) - { - // - protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) - { - string? agentName = await context.ReadStateAsync(key: "FOUNDRY_AGENT_RESEARCHANALYST", scopeName: "Env"); - - if (string.IsNullOrWhiteSpace(agentName)) - { - throw new InvalidOperationException($"Agent name must be defined: {this.Id}"); - } - - string? conversationId = await context.ReadStateAsync(key: "InternalConversationId", scopeName: "Local"); - bool autoSend = true; - string additionalInstructions = - await context.FormatTemplateAsync( - """ - It's clear we aren't making as much progress as we would like, but we may have learned something new. - Please rewrite the following fact sheet, updating it to include anything new we have learned that may be helpful. - Example edits can include (but are not limited to) adding new guesses, moving educated guesses to verified facts if appropriate, etc. - Updates may be made to any section of the fact sheet, and more than one section of the fact sheet can be edited. - This is an especially good time to update educated guesses, so please at least add or update one educated guess or hunch, and explain your reasoning. - - Here is the old fact sheet: - - {Local.TaskFacts} - """); - IList? inputMessages = await context.EvaluateListAsync(""" - UserMessage( - "As a reminder, we are working to solve the following task: - - " & Local.InputTask) - """); - - AgentRunResponse agentResponse = - await InvokeAgentAsync( - context, - agentName, - conversationId, - autoSend, - additionalInstructions, - inputMessages, - cancellationToken); - - if (autoSend) - { - await context.AddEventAsync(new AgentRunResponseEvent(this.Id, agentResponse)); - } - - await context.QueueStateUpdateAsync(key: "TaskFacts", value: agentResponse.Messages, scopeName: "Local"); - - return default; - } - } - - /// - /// Formats a message template and sends an activity event. - /// - internal sealed class SendactivityDsbajuExecutor(FormulaSession session) : ActionExecutor(id: "sendActivity_dsBaJU", session) - { - // - protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) - { - string activityText = - await context.FormatTemplateAsync( - """ - Re-analyzing plan... - """ - ); - AgentRunResponse response = new([new ChatMessage(ChatRole.Assistant, activityText)]); - await context.AddEventAsync(new AgentRunResponseEvent(this.Id, response)); - - return default; - } - } - - /// - /// Invokes an agent to process messages and return a response within a conversation context. - /// - internal sealed class QuestionUej456Executor(FormulaSession session, WorkflowAgentProvider agentProvider) : AgentExecutor(id: "question_uEJ456", session, agentProvider) - { - // - protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) - { - string? agentName = await context.ReadStateAsync(key: "FOUNDRY_AGENT_RESEARCHMANAGER", scopeName: "Env"); - - if (string.IsNullOrWhiteSpace(agentName)) - { - throw new InvalidOperationException($"Agent name must be defined: {this.Id}"); - } - - string? conversationId = await context.ReadStateAsync(key: "InternalConversationId", scopeName: "Local"); - bool autoSend = true; - string additionalInstructions = - await context.FormatTemplateAsync( - """ - Please briefly explain what went wrong on this last run (the root cause of the failure), - and then come up with a new plan that takes steps and/or includes hints to overcome prior challenges and especially avoids repeating the same mistakes. - As before, the new plan should be concise, be expressed in bullet-point form, and consider the following team composition - (do not involve any other outside people since we cannot contact anyone else): - - {Local.TeamDescription} - """); IList? inputMessages = null; AgentRunResponse agentResponse = @@ -889,137 +71,67 @@ public static class TestWorkflowProvider agentName, conversationId, autoSend, - additionalInstructions, inputMessages, - cancellationToken); + cancellationToken).ConfigureAwait(false); if (autoSend) { - await context.AddEventAsync(new AgentRunResponseEvent(this.Id, agentResponse)); + await context.AddEventAsync(new AgentRunResponseEvent(this.Id, agentResponse)).ConfigureAwait(false); } - await context.QueueStateUpdateAsync(key: "Plan", value: agentResponse.Messages, scopeName: "Local"); + return default; + } + } + + /// + /// Invokes an agent to process messages and return a response within a conversation context. + /// + internal sealed class QuestionTeacherExecutor(FormulaSession session, WorkflowAgentProvider agentProvider) : AgentExecutor(id: "question_teacher", session, agentProvider) + { + // + protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) + { + string? agentName = "TeacherAgent"; + + if (string.IsNullOrWhiteSpace(agentName)) + { + throw new DeclarativeActionException($"Agent name must be defined: {this.Id}"); + } + + string? conversationId = await context.ReadStateAsync(key: "ConversationId", scopeName: "System").ConfigureAwait(false); + bool autoSend = false; + IList? inputMessages = null; + + AgentRunResponse agentResponse = + await InvokeAgentAsync( + context, + agentName, + conversationId, + autoSend, + inputMessages, + cancellationToken).ConfigureAwait(false); + + if (autoSend) + { + await context.AddEventAsync(new AgentRunResponseEvent(this.Id, agentResponse)).ConfigureAwait(false); + } + + await context.QueueStateUpdateAsync(key: "TeacherResponse", value: agentResponse.Messages, scopeName: "Local").ConfigureAwait(false); return default; } } /// - /// Assigns an evaluated expression, other variable, or literal value to the "Local.TaskInstructions" variable. + /// Assigns an evaluated expression, other variable, or literal value to the "Local.TurnCount" variable. /// - internal sealed class SetvariableJw7tmmExecutor(FormulaSession session) : ActionExecutor(id: "setVariable_jW7tmM", session) + internal sealed class SetCountIncrementExecutor(FormulaSession session) : ActionExecutor(id: "set_count_increment", session) { // protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { - object? evaluatedValue = await context.EvaluateValueAsync(""" - "# TASK - Address the following user request: - - " & Local.InputTask & " - - - # TEAM - Use the following team to answer this request: - - " & Local.TeamDescription & " - - - # FACTS - Consider this initial fact sheet: - - " & Local.TaskFacts.Text & " - - - # PLAN - Here is the plan to follow as best as possible: - - " & Local.Plan.Text - """); - await context.QueueStateUpdateAsync(key: "TaskInstructions", value: evaluatedValue, scopeName: "Local"); - - return default; - } - } - - /// - /// Assigns an evaluated expression, other variable, or literal value to the "Local.StallCount" variable. - /// - internal sealed class Setvariable6J2snpExecutor(FormulaSession session) : ActionExecutor(id: "setVariable_6J2snP", session) - { - // - protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) - { - object? evaluatedValue = 0; - await context.QueueStateUpdateAsync(key: "StallCount", value: evaluatedValue, scopeName: "Local"); - - return default; - } - } - - /// - /// Assigns an evaluated expression, other variable, or literal value to the "Local.RestartCount" variable. - /// - internal sealed class SetvariableS6hcghExecutor(FormulaSession session) : ActionExecutor(id: "setVariable_S6HCgh", session) - { - // - protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) - { - object? evaluatedValue = await context.EvaluateValueAsync("Local.RestartCount + 1"); - await context.QueueStateUpdateAsync(key: "RestartCount", value: evaluatedValue, scopeName: "Local"); - - return default; - } - } - - /// - /// Formats a message template and sends an activity event. - /// - internal sealed class SendactivityL7ooqoExecutor(FormulaSession session) : ActionExecutor(id: "sendActivity_L7ooQO", session) - { - // - protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) - { - string activityText = - await context.FormatTemplateAsync( - """ - ({Local.TypedProgressLedger.next_speaker.reason}) - - {Local.TypedProgressLedger.next_speaker.answer} - {Local.TypedProgressLedger.instruction_or_question.answer} - """ - ); - AgentRunResponse response = new([new ChatMessage(ChatRole.Assistant, activityText)]); - await context.AddEventAsync(new AgentRunResponseEvent(this.Id, response)); - - return default; - } - } - - /// - /// Assigns an evaluated expression, other variable, or literal value to the "Local.StallCount" variable. - /// - internal sealed class SetvariableL7ooqoExecutor(FormulaSession session) : ActionExecutor(id: "setVariable_L7ooQO", session) - { - // - protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) - { - object? evaluatedValue = 0; - await context.QueueStateUpdateAsync(key: "StallCount", value: evaluatedValue, scopeName: "Local"); - - return default; - } - } - - /// - /// Assigns an evaluated expression, other variable, or literal value to the "Local.NextSpeaker" variable. - /// - internal sealed class SetvariableNxn1meExecutor(FormulaSession session) : ActionExecutor(id: "setVariable_nxN1mE", session) - { - // - protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) - { - object? evaluatedValue = await context.EvaluateValueAsync("Search(Local.AvailableAgents, Local.TypedProgressLedger.next_speaker.answer, name)"); - await context.QueueStateUpdateAsync(key: "NextSpeaker", value: evaluatedValue, scopeName: "Local"); + object? evaluatedValue = await context.EvaluateValueAsync("Local.TurnCount + 1").ConfigureAwait(false); + await context.QueueStateUpdateAsync(key: "TurnCount", value: evaluatedValue, scopeName: "Local").ConfigureAwait(false); return default; } @@ -1028,90 +140,43 @@ public static class TestWorkflowProvider /// /// Conditional branching similar to an if / elseif / elseif / else chain. /// - internal sealed class ConditiongroupQfpif5Executor(FormulaSession session) : ActionExecutor(id: "conditionGroup_QFPiF5", session) + internal sealed class CheckCompletionExecutor(FormulaSession session) : ActionExecutor(id: "check_completion", session) { // protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { - bool condition0 = await context.EvaluateValueAsync("CountRows(Local.NextSpeaker) = 1"); + bool condition0 = await context.EvaluateValueAsync("""!IsBlank(Find("CONGRATULATIONS", Upper(Last(Local.TeacherResponse).Text)))""").ConfigureAwait(false); if (condition0) { - return "conditionItem_GmigcU"; + return "check_turn_done"; } - return "conditionGroup_QFPiF5ElseActions"; + bool condition1 = await context.EvaluateValueAsync("Local.TurnCount < 4").ConfigureAwait(false); + if (condition1) + { + return "check_turn_count"; + } + + return "check_completionElseActions"; } } /// - /// Invokes an agent to process messages and return a response within a conversation context. + /// Formats a message template and sends an activity event. /// - internal sealed class QuestionOrsbf06Executor(FormulaSession session, WorkflowAgentProvider agentProvider) : AgentExecutor(id: "question_orsBf06", session, agentProvider) + internal sealed class SendactivityDoneExecutor(FormulaSession session) : ActionExecutor(id: "sendActivity_done", session) { // protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { - string? agentName = await context.EvaluateValueAsync("First(Local.NextSpeaker).agentid"); - - if (string.IsNullOrWhiteSpace(agentName)) - { - throw new InvalidOperationException($"Agent name must be defined: {this.Id}"); - } - - string? conversationId = await context.ReadStateAsync(key: "ConversationId", scopeName: "System"); - bool autoSend = true; - string additionalInstructions = + string activityText = await context.FormatTemplateAsync( """ - {Local.TypedProgressLedger.instruction_or_question.answer} - """); - IList? inputMessages = await context.ReadListAsync(key: "SeedTask", scopeName: "Local"); - - AgentRunResponse agentResponse = - await InvokeAgentAsync( - context, - agentName, - conversationId, - autoSend, - additionalInstructions, - inputMessages, - cancellationToken); - - if (autoSend) - { - await context.AddEventAsync(new AgentRunResponseEvent(this.Id, agentResponse)); - } - - await context.QueueStateUpdateAsync(key: "AgentResponse", value: agentResponse.Messages, scopeName: "Local"); - - return default; - } - } - - /// - /// Assigns an evaluated expression, other variable, or literal value to the "Local.AgentResponseText" variable. - /// - internal sealed class SetvariableXznrdmExecutor(FormulaSession session) : ActionExecutor(id: "setVariable_XzNrdM", session) - { - // - protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) - { - object? evaluatedValue = await context.EvaluateValueAsync("Last(Local.AgentResponse).Text"); - await context.QueueStateUpdateAsync(key: "AgentResponseText", value: evaluatedValue, scopeName: "Local"); - - return default; - } - } - - /// - /// Resets the value of the "Local.SeedTask" variable, potentially causing re-evaluation - /// of the default value, question or action that provides the value to this variable. - /// - internal sealed class Setvariable8Eix2aExecutor(FormulaSession session) : ActionExecutor(id: "setVariable_8eIx2A", session) - { - protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) - { - await context.QueueStateUpdateAsync(key: "SeedTask", value: UnassignedValue.Instance, scopeName: "Local"); + GOLD STAR! + """ + ); + AgentRunResponse response = new([new ChatMessage(ChatRole.Assistant, activityText)]); + await context.AddEventAsync(new AgentRunResponseEvent(this.Id, response)).ConfigureAwait(false); return default; } @@ -1120,7 +185,7 @@ public static class TestWorkflowProvider /// /// Formats a message template and sends an activity event. /// - internal sealed class SendactivityBhcsi7Executor(FormulaSession session) : ActionExecutor(id: "sendActivity_BhcsI7", session) + internal sealed class SendactivityTiredExecutor(FormulaSession session) : ActionExecutor(id: "sendActivity_tired", session) { // protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) @@ -1128,26 +193,11 @@ public static class TestWorkflowProvider string activityText = await context.FormatTemplateAsync( """ - Unable to choose next agent... + Let's try again later... """ ); AgentRunResponse response = new([new ChatMessage(ChatRole.Assistant, activityText)]); - await context.AddEventAsync(new AgentRunResponseEvent(this.Id, response)); - - return default; - } - } - - /// - /// Assigns an evaluated expression, other variable, or literal value to the "Local.StallCount" variable. - /// - internal sealed class SetvariableBhcsi7Executor(FormulaSession session) : ActionExecutor(id: "setVariable_BhcsI7", session) - { - // - protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) - { - object? evaluatedValue = await context.EvaluateValueAsync("Local.StallCount + 1"); - await context.QueueStateUpdateAsync(key: "StallCount", value: evaluatedValue, scopeName: "Local"); + await context.AddEventAsync(new AgentRunResponseEvent(this.Id, response)).ConfigureAwait(false); return default; } @@ -1162,189 +212,56 @@ public static class TestWorkflowProvider inputTransform ??= (message) => DeclarativeWorkflowBuilder.DefaultTransform(message); WorkflowDemoRootExecutor workflowDemoRoot = new(options, inputTransform); DelegateExecutor workflowDemo = new(id: "workflow_demo", workflowDemoRoot.Session); - SetvariableAaslmfExecutor setVariableAaslmf = new(workflowDemoRoot.Session); - SetvariableV6yeboExecutor setVariableV6yebo = new(workflowDemoRoot.Session); - SetvariableNz2u0lExecutor setVariableNz2u0l = new(workflowDemoRoot.Session); - Setvariable10U2znExecutor setVariable10U2zn = new(workflowDemoRoot.Session); - SendactivityYfsbryExecutor sendActivityYfsbry = new(workflowDemoRoot.Session); - Conversation1A2b3cExecutor conversation1A2b3c = new(workflowDemoRoot.Session, options.AgentProvider); - QuestionUdomuwExecutor questionUdomuw = new(workflowDemoRoot.Session, options.AgentProvider); - SendactivityYfsbrzExecutor sendActivityYfsbrz = new(workflowDemoRoot.Session); - QuestionDsbajuExecutor questionDsbaju = new(workflowDemoRoot.Session, options.AgentProvider); - SetvariableKk2ldlExecutor setVariableKk2ldl = new(workflowDemoRoot.Session); - SendactivityBwnzimExecutor sendActivityBwnzim = new(workflowDemoRoot.Session); - QuestionO3bqkfExecutor questionO3bqkf = new(workflowDemoRoot.Session, options.AgentProvider); - ParseRnztlvExecutor parseRnztlv = new(workflowDemoRoot.Session); - ConditiongroupMvieccExecutor conditionGroupMviecc = new(workflowDemoRoot.Session); - DelegateExecutor conditionItemFj432c = new(id: "conditionItem_fj432c", workflowDemoRoot.Session); - DelegateExecutor conditionItemYiqund = new(id: "conditionItem_yiqund", workflowDemoRoot.Session); - DelegateExecutor conditionGroupMvieccelseactions = new(id: "conditionGroup_mVIecCElseActions", workflowDemoRoot.Session); - DelegateExecutor conditionItemFj432cactions = new(id: "conditionItem_fj432cActions", workflowDemoRoot.Session); - SendactivityKdl3mcExecutor sendActivityKdl3mc = new(workflowDemoRoot.Session); - QuestionKe3l1dExecutor questionKe3l1d = new(workflowDemoRoot.Session, options.AgentProvider); - DelegateExecutor endSvonsv = new(id: "end_SVoNSV", workflowDemoRoot.Session); - DelegateExecutor conditionItemYiqundactions = new(id: "conditionItem_yiqundActions", workflowDemoRoot.Session); - SetvariableH5lxddExecutor setVariableH5lxdd = new(workflowDemoRoot.Session); - ConditiongroupVbtqd3Executor conditionGroupVbtqd3 = new(workflowDemoRoot.Session); - DelegateExecutor conditionItemFpanl9 = new(id: "conditionItem_fpaNL9", workflowDemoRoot.Session); - DelegateExecutor conditionItemNnqvxh = new(id: "conditionItem_NnqvXh", workflowDemoRoot.Session); - DelegateExecutor conditionItemFpanl9actions = new(id: "conditionItem_fpaNL9Actions", workflowDemoRoot.Session); - SendactivityFpanl9Executor sendActivityFpanl9 = new(workflowDemoRoot.Session); - DelegateExecutor conditionItemNnqvxhactions = new(id: "conditionItem_NnqvXhActions", workflowDemoRoot.Session); - SendactivityNnqvxhExecutor sendActivityNnqvxh = new(workflowDemoRoot.Session); - DelegateExecutor conditionGroupVbtqd3Post = new(id: "conditionGroup_vBTQd3_Post", workflowDemoRoot.Session); - ConditiongroupXznrdmExecutor conditionGroupXznrdm = new(workflowDemoRoot.Session); - DelegateExecutor conditionItemNlqtbv = new(id: "conditionItem_NlQTBv", workflowDemoRoot.Session); - DelegateExecutor conditionItemNlqtbvactions = new(id: "conditionItem_NlQTBvActions", workflowDemoRoot.Session); - SendactivityH5lxddExecutor sendActivityH5lxdd = new(workflowDemoRoot.Session); - Conditiongroup4S1z27Executor conditionGroup4S1z27 = new(workflowDemoRoot.Session); - DelegateExecutor conditionItemExalhz = new(id: "conditionItem_EXAlhZ", workflowDemoRoot.Session); - DelegateExecutor conditionItemExalhzactions = new(id: "conditionItem_EXAlhZActions", workflowDemoRoot.Session); - SendactivityXkxfuuExecutor sendActivityXkxfuu = new(workflowDemoRoot.Session); - DelegateExecutor endGhvrfh = new(id: "end_GHVrFh", workflowDemoRoot.Session); - DelegateExecutor conditionGroup4S1z27Post = new(id: "conditionGroup_4s1Z27_Post", workflowDemoRoot.Session); - SendactivityCwnzimExecutor sendActivityCwnzim = new(workflowDemoRoot.Session); - QuestionWfj123Executor questionWfj123 = new(workflowDemoRoot.Session, options.AgentProvider); - SendactivityDsbajuExecutor sendActivityDsbaju = new(workflowDemoRoot.Session); - QuestionUej456Executor questionUej456 = new(workflowDemoRoot.Session, options.AgentProvider); - SetvariableJw7tmmExecutor setVariableJw7tmm = new(workflowDemoRoot.Session); - Setvariable6J2snpExecutor setVariable6J2snp = new(workflowDemoRoot.Session); - SetvariableS6hcghExecutor setVariableS6hcgh = new(workflowDemoRoot.Session); - DelegateExecutor gotoLzfj8u = new(id: "goto_LzfJ8u", workflowDemoRoot.Session); - DelegateExecutor conditionItemYiqundRestart = new(id: "conditionItem_yiqund_Restart", workflowDemoRoot.Session); - SendactivityL7ooqoExecutor sendActivityL7ooqo = new(workflowDemoRoot.Session); - SetvariableL7ooqoExecutor setVariableL7ooqo = new(workflowDemoRoot.Session); - DelegateExecutor conditionGroupMvieccPost = new(id: "conditionGroup_mVIecC_Post", workflowDemoRoot.Session); - SetvariableNxn1meExecutor setVariableNxn1me = new(workflowDemoRoot.Session); - ConditiongroupQfpif5Executor conditionGroupQfpif5 = new(workflowDemoRoot.Session); - DelegateExecutor conditionItemGmigcu = new(id: "conditionItem_GmigcU", workflowDemoRoot.Session); - DelegateExecutor conditionGroupQfpif5elseactions = new(id: "conditionGroup_QFPiF5ElseActions", workflowDemoRoot.Session); - DelegateExecutor conditionItemGmigcuactions = new(id: "conditionItem_GmigcUActions", workflowDemoRoot.Session); - QuestionOrsbf06Executor questionOrsbf06 = new(workflowDemoRoot.Session, options.AgentProvider); - SetvariableXznrdmExecutor setVariableXznrdm = new(workflowDemoRoot.Session); - Setvariable8Eix2aExecutor setVariable8Eix2a = new(workflowDemoRoot.Session); - DelegateExecutor conditionItemGmigcuRestart = new(id: "conditionItem_GmigcU_Restart", workflowDemoRoot.Session); - SendactivityBhcsi7Executor sendActivityBhcsi7 = new(workflowDemoRoot.Session); - SetvariableBhcsi7Executor setVariableBhcsi7 = new(workflowDemoRoot.Session); - DelegateExecutor conditionGroupQfpif5Post = new(id: "conditionGroup_QFPiF5_Post", workflowDemoRoot.Session); - DelegateExecutor goto76Hne8 = new(id: "goto_76Hne8", workflowDemoRoot.Session); - DelegateExecutor conditionItemFj432cPost = new(id: "conditionItem_fj432c_Post", workflowDemoRoot.Session); - DelegateExecutor conditionItemYiqundPost = new(id: "conditionItem_yiqund_Post", workflowDemoRoot.Session); - DelegateExecutor endSvonsvRestart = new(id: "end_SVoNSV_Restart", workflowDemoRoot.Session); - DelegateExecutor conditionItemFj432cactionsPost = new(id: "conditionItem_fj432cActions_Post", workflowDemoRoot.Session); - DelegateExecutor conditionGroupXznrdmPost = new(id: "conditionGroup_xzNrdM_Post", workflowDemoRoot.Session); - DelegateExecutor conditionItemYiqundactionsPost = new(id: "conditionItem_yiqundActions_Post", workflowDemoRoot.Session); - DelegateExecutor conditionItemFpanl9Post = new(id: "conditionItem_fpaNL9_Post", workflowDemoRoot.Session); - DelegateExecutor conditionItemNnqvxhPost = new(id: "conditionItem_NnqvXh_Post", workflowDemoRoot.Session); - DelegateExecutor conditionItemFpanl9actionsPost = new(id: "conditionItem_fpaNL9Actions_Post", workflowDemoRoot.Session); - DelegateExecutor conditionItemNnqvxhactionsPost = new(id: "conditionItem_NnqvXhActions_Post", workflowDemoRoot.Session); - DelegateExecutor conditionItemNlqtbvPost = new(id: "conditionItem_NlQTBv_Post", workflowDemoRoot.Session); - DelegateExecutor gotoLzfj8uRestart = new(id: "goto_LzfJ8u_Restart", workflowDemoRoot.Session); - DelegateExecutor conditionItemNlqtbvactionsPost = new(id: "conditionItem_NlQTBvActions_Post", workflowDemoRoot.Session); - DelegateExecutor conditionItemExalhzPost = new(id: "conditionItem_EXAlhZ_Post", workflowDemoRoot.Session); - DelegateExecutor endGhvrfhRestart = new(id: "end_GHVrFh_Restart", workflowDemoRoot.Session); - DelegateExecutor conditionItemExalhzactionsPost = new(id: "conditionItem_EXAlhZActions_Post", workflowDemoRoot.Session); - DelegateExecutor conditionGroupMvieccelseactionsPost = new(id: "conditionGroup_mVIecCElseActions_Post", workflowDemoRoot.Session); - DelegateExecutor conditionItemGmigcuPost = new(id: "conditionItem_GmigcU_Post", workflowDemoRoot.Session); - DelegateExecutor conditionItemGmigcuactionsPost = new(id: "conditionItem_GmigcUActions_Post", workflowDemoRoot.Session); - DelegateExecutor conditionGroupQfpif5elseactionsPost = new(id: "conditionGroup_QFPiF5ElseActions_Post", workflowDemoRoot.Session); + QuestionStudentExecutor questionStudent = new(workflowDemoRoot.Session, options.AgentProvider); + QuestionTeacherExecutor questionTeacher = new(workflowDemoRoot.Session, options.AgentProvider); + SetCountIncrementExecutor setCountIncrement = new(workflowDemoRoot.Session); + CheckCompletionExecutor checkCompletion = new(workflowDemoRoot.Session); + DelegateExecutor checkTurnDone = new(id: "check_turn_done", workflowDemoRoot.Session); + DelegateExecutor checkTurnCount = new(id: "check_turn_count", workflowDemoRoot.Session); + DelegateExecutor checkCompletionelseactions = new(id: "check_completionElseActions", workflowDemoRoot.Session); + DelegateExecutor checkTurnDoneactions = new(id: "check_turn_doneActions", workflowDemoRoot.Session); + SendactivityDoneExecutor sendActivityDone = new(workflowDemoRoot.Session); + DelegateExecutor checkTurnCountactions = new(id: "check_turn_countActions", workflowDemoRoot.Session); + DelegateExecutor gotoStudentAgent = new(id: "goto_student_agent", workflowDemoRoot.Session); + DelegateExecutor checkTurnCountRestart = new(id: "check_turn_count_Restart", workflowDemoRoot.Session); + SendactivityTiredExecutor sendActivityTired = new(workflowDemoRoot.Session); + DelegateExecutor checkTurnDonePost = new(id: "check_turn_done_Post", workflowDemoRoot.Session); + DelegateExecutor checkCompletionPost = new(id: "check_completion_Post", workflowDemoRoot.Session); + DelegateExecutor checkTurnCountPost = new(id: "check_turn_count_Post", workflowDemoRoot.Session); + DelegateExecutor checkTurnDoneactionsPost = new(id: "check_turn_doneActions_Post", workflowDemoRoot.Session); + DelegateExecutor gotoStudentAgentRestart = new(id: "goto_student_agent_Restart", workflowDemoRoot.Session); + DelegateExecutor checkTurnCountactionsPost = new(id: "check_turn_countActions_Post", workflowDemoRoot.Session); + DelegateExecutor checkCompletionelseactionsPost = new(id: "check_completionElseActions_Post", workflowDemoRoot.Session); // Define the workflow builder WorkflowBuilder builder = new(workflowDemoRoot); // Connect executors builder.AddEdge(workflowDemoRoot, workflowDemo); - builder.AddEdge(workflowDemo, setVariableAaslmf); - builder.AddEdge(setVariableAaslmf, setVariableV6yebo); - builder.AddEdge(setVariableV6yebo, setVariableNz2u0l); - builder.AddEdge(setVariableNz2u0l, setVariable10U2zn); - builder.AddEdge(setVariable10U2zn, sendActivityYfsbry); - builder.AddEdge(sendActivityYfsbry, conversation1A2b3c); - builder.AddEdge(conversation1A2b3c, questionUdomuw); - builder.AddEdge(questionUdomuw, sendActivityYfsbrz); - builder.AddEdge(sendActivityYfsbrz, questionDsbaju); - builder.AddEdge(questionDsbaju, setVariableKk2ldl); - builder.AddEdge(setVariableKk2ldl, sendActivityBwnzim); - builder.AddEdge(sendActivityBwnzim, questionO3bqkf); - builder.AddEdge(questionO3bqkf, parseRnztlv); - builder.AddEdge(parseRnztlv, conditionGroupMviecc); - builder.AddEdge(conditionGroupMviecc, conditionItemFj432c, (object? result) => ActionExecutor.IsMatch("conditionItem_fj432c", result)); - builder.AddEdge(conditionGroupMviecc, conditionItemYiqund, (object? result) => ActionExecutor.IsMatch("conditionItem_yiqund", result)); - builder.AddEdge(conditionGroupMviecc, conditionGroupMvieccelseactions, (object? result) => ActionExecutor.IsMatch("conditionGroup_mVIecCElseActions", result)); - builder.AddEdge(conditionItemFj432c, conditionItemFj432cactions); - builder.AddEdge(conditionItemFj432cactions, sendActivityKdl3mc); - builder.AddEdge(sendActivityKdl3mc, questionKe3l1d); - builder.AddEdge(questionKe3l1d, endSvonsv); - builder.AddEdge(conditionItemYiqund, conditionItemYiqundactions); - builder.AddEdge(conditionItemYiqundactions, setVariableH5lxdd); - builder.AddEdge(setVariableH5lxdd, conditionGroupVbtqd3); - builder.AddEdge(conditionGroupVbtqd3, conditionItemFpanl9, (object? result) => ActionExecutor.IsMatch("conditionItem_fpaNL9", result)); - builder.AddEdge(conditionGroupVbtqd3, conditionItemNnqvxh, (object? result) => ActionExecutor.IsMatch("conditionItem_NnqvXh", result)); - builder.AddEdge(conditionItemFpanl9, conditionItemFpanl9actions); - builder.AddEdge(conditionItemFpanl9actions, sendActivityFpanl9); - builder.AddEdge(conditionItemNnqvxh, conditionItemNnqvxhactions); - builder.AddEdge(conditionItemNnqvxhactions, sendActivityNnqvxh); - builder.AddEdge(conditionGroupVbtqd3Post, conditionGroupXznrdm); - builder.AddEdge(conditionGroupXznrdm, conditionItemNlqtbv, (object? result) => ActionExecutor.IsMatch("conditionItem_NlQTBv", result)); - builder.AddEdge(conditionItemNlqtbv, conditionItemNlqtbvactions); - builder.AddEdge(conditionItemNlqtbvactions, sendActivityH5lxdd); - builder.AddEdge(sendActivityH5lxdd, conditionGroup4S1z27); - builder.AddEdge(conditionGroup4S1z27, conditionItemExalhz, (object? result) => ActionExecutor.IsMatch("conditionItem_EXAlhZ", result)); - builder.AddEdge(conditionItemExalhz, conditionItemExalhzactions); - builder.AddEdge(conditionItemExalhzactions, sendActivityXkxfuu); - builder.AddEdge(sendActivityXkxfuu, endGhvrfh); - builder.AddEdge(conditionGroup4S1z27Post, sendActivityCwnzim); - builder.AddEdge(sendActivityCwnzim, questionWfj123); - builder.AddEdge(questionWfj123, sendActivityDsbaju); - builder.AddEdge(sendActivityDsbaju, questionUej456); - builder.AddEdge(questionUej456, setVariableJw7tmm); - builder.AddEdge(setVariableJw7tmm, setVariable6J2snp); - builder.AddEdge(setVariable6J2snp, setVariableS6hcgh); - builder.AddEdge(setVariableS6hcgh, gotoLzfj8u); - builder.AddEdge(gotoLzfj8u, questionO3bqkf); - builder.AddEdge(conditionItemYiqundRestart, conditionGroupMvieccelseactions); - builder.AddEdge(conditionGroupMvieccelseactions, sendActivityL7ooqo); - builder.AddEdge(sendActivityL7ooqo, setVariableL7ooqo); - builder.AddEdge(conditionGroupMvieccPost, setVariableNxn1me); - builder.AddEdge(setVariableNxn1me, conditionGroupQfpif5); - builder.AddEdge(conditionGroupQfpif5, conditionItemGmigcu, (object? result) => ActionExecutor.IsMatch("conditionItem_GmigcU", result)); - builder.AddEdge(conditionGroupQfpif5, conditionGroupQfpif5elseactions, (object? result) => ActionExecutor.IsMatch("conditionGroup_QFPiF5ElseActions", result)); - builder.AddEdge(conditionItemGmigcu, conditionItemGmigcuactions); - builder.AddEdge(conditionItemGmigcuactions, questionOrsbf06); - builder.AddEdge(questionOrsbf06, setVariableXznrdm); - builder.AddEdge(setVariableXznrdm, setVariable8Eix2a); - builder.AddEdge(conditionItemGmigcuRestart, conditionGroupQfpif5elseactions); - builder.AddEdge(conditionGroupQfpif5elseactions, sendActivityBhcsi7); - builder.AddEdge(sendActivityBhcsi7, setVariableBhcsi7); - builder.AddEdge(conditionGroupQfpif5Post, goto76Hne8); - builder.AddEdge(goto76Hne8, questionO3bqkf); - builder.AddEdge(conditionItemFj432cPost, conditionGroupMvieccPost); - builder.AddEdge(conditionItemYiqundPost, conditionGroupMvieccPost); - builder.AddEdge(endSvonsvRestart, conditionItemFj432cactionsPost); - builder.AddEdge(conditionItemFj432cactionsPost, conditionItemFj432cPost); - builder.AddEdge(conditionGroupXznrdmPost, conditionItemYiqundactionsPost); - builder.AddEdge(conditionItemYiqundactionsPost, conditionItemYiqundPost); - builder.AddEdge(conditionItemFpanl9Post, conditionGroupVbtqd3Post); - builder.AddEdge(conditionItemNnqvxhPost, conditionGroupVbtqd3Post); - builder.AddEdge(sendActivityFpanl9, conditionItemFpanl9actionsPost); - builder.AddEdge(conditionItemFpanl9actionsPost, conditionItemFpanl9Post); - builder.AddEdge(sendActivityNnqvxh, conditionItemNnqvxhactionsPost); - builder.AddEdge(conditionItemNnqvxhactionsPost, conditionItemNnqvxhPost); - builder.AddEdge(conditionItemNlqtbvPost, conditionGroupXznrdmPost); - builder.AddEdge(gotoLzfj8uRestart, conditionItemNlqtbvactionsPost); - builder.AddEdge(conditionItemNlqtbvactionsPost, conditionItemNlqtbvPost); - builder.AddEdge(conditionItemExalhzPost, conditionGroup4S1z27Post); - builder.AddEdge(endGhvrfhRestart, conditionItemExalhzactionsPost); - builder.AddEdge(conditionItemExalhzactionsPost, conditionItemExalhzPost); - builder.AddEdge(setVariableL7ooqo, conditionGroupMvieccelseactionsPost); - builder.AddEdge(conditionGroupMvieccelseactionsPost, conditionGroupMvieccPost); - builder.AddEdge(conditionItemGmigcuPost, conditionGroupQfpif5Post); - builder.AddEdge(setVariable8Eix2a, conditionItemGmigcuactionsPost); - builder.AddEdge(conditionItemGmigcuactionsPost, conditionItemGmigcuPost); - builder.AddEdge(setVariableBhcsi7, conditionGroupQfpif5elseactionsPost); - builder.AddEdge(conditionGroupQfpif5elseactionsPost, conditionGroupQfpif5Post); + builder.AddEdge(workflowDemo, questionStudent); + builder.AddEdge(questionStudent, questionTeacher); + builder.AddEdge(questionTeacher, setCountIncrement); + builder.AddEdge(setCountIncrement, checkCompletion); + builder.AddEdge(checkCompletion, checkTurnDone, (object? result) => ActionExecutor.IsMatch("check_turn_done", result)); + builder.AddEdge(checkCompletion, checkTurnCount, (object? result) => ActionExecutor.IsMatch("check_turn_count", result)); + builder.AddEdge(checkCompletion, checkCompletionelseactions, (object? result) => ActionExecutor.IsMatch("check_completionElseActions", result)); + builder.AddEdge(checkTurnDone, checkTurnDoneactions); + builder.AddEdge(checkTurnDoneactions, sendActivityDone); + builder.AddEdge(checkTurnCount, checkTurnCountactions); + builder.AddEdge(checkTurnCountactions, gotoStudentAgent); + builder.AddEdge(gotoStudentAgent, questionStudent); + builder.AddEdge(checkTurnCountRestart, checkCompletionelseactions); + builder.AddEdge(checkCompletionelseactions, sendActivityTired); + builder.AddEdge(checkTurnDonePost, checkCompletionPost); + builder.AddEdge(checkTurnCountPost, checkCompletionPost); + builder.AddEdge(sendActivityDone, checkTurnDoneactionsPost); + builder.AddEdge(checkTurnDoneactionsPost, checkTurnDonePost); + builder.AddEdge(gotoStudentAgentRestart, checkTurnCountactionsPost); + builder.AddEdge(checkTurnCountactionsPost, checkTurnCountPost); + builder.AddEdge(sendActivityTired, checkCompletionelseactionsPost); + builder.AddEdge(checkCompletionelseactionsPost, checkCompletionPost); // Build the workflow - return builder.Build(); + return builder.Build(validateOrphans: false); } } diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteCode/Program.cs b/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteCode/Program.cs index c1846dac5e..bfc738c336 100644 --- a/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteCode/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteCode/Program.cs @@ -1,14 +1,14 @@ // Copyright (c) Microsoft. All rights reserved. -using System.Diagnostics; +// Uncomment this to enable JSON checkpointing to the local file system. +//#define CHECKPOINT_JSON + using System.Reflection; -using Azure.AI.Agents.Persistent; using Azure.Identity; using Microsoft.Agents.AI.Workflows; using Microsoft.Agents.AI.Workflows.Declarative; -using Microsoft.Extensions.AI; using Microsoft.Extensions.Configuration; -using Test.WorkflowProviders; +using Shared.Workflows; namespace Demo.DeclarativeCode; @@ -24,157 +24,64 @@ internal sealed class Program { public static async Task Main(string[] args) { - Program program = new(args); + string? workflowInput = ParseWorkflowInput(args); + + Program program = new(workflowInput); await program.ExecuteAsync(); } private async Task ExecuteAsync() + { + Notify("\nWORKFLOW: Starting..."); + + string input = this.GetWorkflowInput(); + + // Execute the workflow: The WorkflowRunner demonstrates how to execute + // a workflow, handle the workflow events, and providing external input. + // This also includes the ability to checkpoint workflow state and how to + // resume execution. + await this.Runner.ExecuteAsync(this.CreateWorkflow, input); + + Notify("\nWORKFLOW: Done!\n"); + } + + private Workflow CreateWorkflow() { // Use DeclarativeWorkflowBuilder to build a workflow based on a YAML file. DeclarativeWorkflowOptions options = - new(new AzureAgentProvider(this.FoundryEndpoint, new AzureCliCredential())) + new(new AzureAgentProvider(new Uri(this.FoundryEndpoint), new AzureCliCredential())) { Configuration = this.Configuration }; // Use the generated provider to create a workflow instance. - Workflow workflow = TestWorkflowProvider.CreateWorkflow(options); - - Notify("\nWORKFLOW: Starting..."); - - // Run the workflow, just like any other workflow - string input = this.GetWorkflowInput(); - StreamingRun run = await InProcessExecution.StreamAsync(workflow, input: input); - await this.MonitorAndDisposeWorkflowRunAsync(run); - - Notify("\nWORKFLOW: Done!"); + return SampleWorkflowProvider.CreateWorkflow(options); } - private const string ConfigKeyFoundryEndpoint = "FOUNDRY_PROJECT_ENDPOINT"; - - private static readonly Dictionary s_nameCache = []; - private static readonly HashSet s_fileCache = []; - private string? WorkflowInput { get; } private string FoundryEndpoint { get; } - private PersistentAgentsClient FoundryClient { get; } private IConfiguration Configuration { get; } + private WorkflowRunner Runner { get; } - private Program(string[] args) + private Program(string? workflowInput) { - this.WorkflowInput = ParseWorkflowInput(args); + this.WorkflowInput = workflowInput; this.Configuration = InitializeConfig(); - this.FoundryEndpoint = this.Configuration[ConfigKeyFoundryEndpoint] ?? throw new InvalidOperationException($"Undefined configuration setting: {ConfigKeyFoundryEndpoint}"); - this.FoundryClient = new PersistentAgentsClient(this.FoundryEndpoint, new AzureCliCredential()); - } + this.FoundryEndpoint = this.Configuration[Application.Settings.FoundryEndpoint] ?? throw new InvalidOperationException($"Undefined configuration setting: {Application.Settings.FoundryEndpoint}"); - private async Task MonitorAndDisposeWorkflowRunAsync(StreamingRun run) - { - await using IAsyncDisposable disposeRun = run; - - string? messageId = null; - - await foreach (WorkflowEvent workflowEvent in run.WatchStreamAsync()) - { - switch (workflowEvent) + this.Runner = + new() { - case ExecutorInvokedEvent executorInvoked: - Debug.WriteLine($"STEP ENTER #{executorInvoked.ExecutorId}"); - break; - - case ExecutorCompletedEvent executorComplete: - Debug.WriteLine($"STEP EXIT #{executorComplete.ExecutorId}"); - break; - - case ExecutorFailedEvent executorFailure: - Debug.WriteLine($"STEP ERROR #{executorFailure.ExecutorId}: {executorFailure.Data?.Message ?? "Unknown"}"); - break; - - case WorkflowErrorEvent workflowError: - throw workflowError.Data as Exception ?? new InvalidOperationException("Unexpected failure..."); - - case ConversationUpdateEvent invokeEvent: - Debug.WriteLine($"CONVERSATION: {invokeEvent.Data}"); - break; - - case AgentRunUpdateEvent streamEvent: - if (!string.Equals(messageId, streamEvent.Update.MessageId, StringComparison.Ordinal)) - { - messageId = streamEvent.Update.MessageId; - - if (messageId is not null) - { - string? agentId = streamEvent.Update.AuthorName; - if (agentId is not null) - { - if (!s_nameCache.TryGetValue(agentId, out string? realName)) - { - PersistentAgent agent = await this.FoundryClient.Administration.GetAgentAsync(agentId); - s_nameCache[agentId] = agent.Name; - realName = agent.Name; - } - agentId = realName; - } - agentId ??= nameof(ChatRole.Assistant); - Console.ForegroundColor = ConsoleColor.Cyan; - Console.Write($"\n{agentId.ToUpperInvariant()}:"); - Console.ForegroundColor = ConsoleColor.DarkGray; - Console.WriteLine($" [{messageId}]"); - } - } - - ChatResponseUpdate? chatUpdate = streamEvent.Update.RawRepresentation as ChatResponseUpdate; - switch (chatUpdate?.RawRepresentation) - { - case MessageContentUpdate messageUpdate: - string? fileId = messageUpdate.ImageFileId ?? messageUpdate.TextAnnotation?.OutputFileId; - if (fileId is not null && s_fileCache.Add(fileId)) - { - BinaryData content = await this.FoundryClient.Files.GetFileContentAsync(fileId); - await DownloadFileContentAsync(Path.GetFileName(messageUpdate.TextAnnotation?.TextToReplace ?? "response.png"), content); - } - break; - } - try - { - Console.ResetColor(); - Console.Write(streamEvent.Data); - } - finally - { - Console.ResetColor(); - } - break; - - case AgentRunResponseEvent messageEvent: - try - { - Console.WriteLine(); - if (messageEvent.Response.AgentId is null) - { - Console.ForegroundColor = ConsoleColor.Cyan; - Console.WriteLine("ACTIVITY:"); - Console.ForegroundColor = ConsoleColor.Yellow; - Console.WriteLine(messageEvent.Response?.Text.Trim()); - } - else - { - if (messageEvent.Response.Usage is not null) - { - Console.ForegroundColor = ConsoleColor.DarkGray; - Console.WriteLine($"[Tokens Total: {messageEvent.Response.Usage.TotalTokenCount}, Input: {messageEvent.Response.Usage.InputTokenCount}, Output: {messageEvent.Response.Usage.OutputTokenCount}]"); - } - } - } - finally - { - Console.ResetColor(); - } - break; - } - } +#if CHECKPOINT_JSON + // Use an json file checkpoint store that will persist checkpoints to the local file system. + UseJsonCheckpoints = true +#else + // Use an in-memory checkpoint store that will not persist checkpoints beyond the lifetime of the process. + UseJsonCheckpoints = false +#endif + }; } private string GetWorkflowInput() @@ -231,19 +138,4 @@ internal sealed class Program Console.ResetColor(); } } - - private static async ValueTask DownloadFileContentAsync(string filename, BinaryData content) - { - string filePath = Path.Combine(Path.GetTempPath(), Path.GetFileName(filename)); - filePath = Path.ChangeExtension(filePath, ".png"); - - await File.WriteAllBytesAsync(filePath, content.ToArray()); - - Process.Start( - new ProcessStartInfo - { - FileName = "cmd.exe", - Arguments = $"/C start {filePath}" - }); - } } diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteWorkflow/ExecuteWorkflow.csproj b/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteWorkflow/ExecuteWorkflow.csproj index b885a71c3c..1fb6abe55d 100644 --- a/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteWorkflow/ExecuteWorkflow.csproj +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteWorkflow/ExecuteWorkflow.csproj @@ -11,7 +11,9 @@ - true + true + true + true @@ -26,6 +28,7 @@ + diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteWorkflow/Program.cs b/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteWorkflow/Program.cs index ce6a19b0d3..1a761be436 100644 --- a/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteWorkflow/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteWorkflow/Program.cs @@ -5,18 +5,12 @@ using System.Diagnostics; using System.Reflection; -using System.Text.Json; -using Azure.AI.Agents.Persistent; using Azure.Identity; using Microsoft.Agents.AI.Workflows; -#if CHECKPOINT_JSON -using Microsoft.Agents.AI.Workflows.Checkpointing; -#endif using Microsoft.Agents.AI.Workflows.Declarative; -using Microsoft.Agents.AI.Workflows.Declarative.Events; -using Microsoft.Agents.AI.Workflows.Declarative.Kit; using Microsoft.Extensions.AI; using Microsoft.Extensions.Configuration; +using Shared.Workflows; namespace Demo.DeclarativeWorkflow; @@ -62,52 +56,13 @@ internal sealed class Program Notify("\nWORKFLOW: Starting..."); - // Run the workflow, just like any other workflow string input = this.GetWorkflowInput(); -#if CHECKPOINT_JSON - // Use a file-system based JSON checkpoint store to persist checkpoints to disk. - DirectoryInfo checkpointFolder = Directory.CreateDirectory(Path.Combine(".", $"chk-{DateTime.Now:yyMMdd-hhmmss-ff}")); - CheckpointManager checkpointManager = CheckpointManager.CreateJson(new FileSystemJsonCheckpointStore(checkpointFolder)); -#else - // Use an in-memory checkpoint store that will not persist checkpoints beyond the lifetime of the process. - CheckpointManager checkpointManager = CheckpointManager.CreateInMemory(); -#endif - - Checkpointed run = await InProcessExecution.StreamAsync(workflow, input, checkpointManager); - - bool isComplete = false; - object? response = null; - do - { - ExternalRequest? externalRequest = await this.MonitorAndDisposeWorkflowRunAsync(run, response); - if (externalRequest is not null) - { - Notify("\nWORKFLOW: Yield"); - - if (this.LastCheckpoint is null) - { - throw new InvalidOperationException("Checkpoint information missing after external request."); - } - - // Process the external request. - response = await this.HandleExternalRequestAsync(externalRequest); - - // Let's resume on an entirely new workflow instance to demonstrate checkpoint portability. - workflow = this.CreateWorkflow(); - - // Restore the latest checkpoint. - Debug.WriteLine($"RESTORE #{this.LastCheckpoint.CheckpointId}"); - Notify("\nWORKFLOW: Restore"); - - run = await InProcessExecution.ResumeStreamAsync(workflow, this.LastCheckpoint, checkpointManager, run.Run.RunId); - } - else - { - isComplete = true; - } - } - while (!isComplete); + // Execute the workflow: The WorkflowRunner demonstrates how to execute + // a workflow, handle the workflow events, and providing external input. + // This also includes the ability to checkpoint workflow state and how to + // resume execution. + await this.Runner.ExecuteAsync(this.CreateWorkflow, input); Notify("\nWORKFLOW: Done!\n"); } @@ -116,19 +71,16 @@ internal sealed class Program /// Create the workflow from the declarative YAML. Includes definition of the /// and the associated . /// - /// - /// The value assigned to controls on whether the function - /// tools () initialized in the constructor are included for auto-invocation. - /// private Workflow CreateWorkflow() { - // Use DeclarativeWorkflowBuilder to build a workflow based on a YAML file. - AzureAgentProvider agentProvider = new(this.FoundryEndpoint, new AzureCliCredential()) + // Create the agent provider that will service agent requests within the workflow. + AzureAgentProvider agentProvider = new(new Uri(this.FoundryEndpoint), new AzureCliCredential()) { // Functions included here will be auto-executed by the framework. - Functions = IncludeFunctions ? this.FunctionMap.Values : null, + Functions = this.Functions }; + // Define the workflow options. DeclarativeWorkflowOptions options = new(agentProvider) { @@ -137,31 +89,16 @@ internal sealed class Program //LoggerFactory = null, // Assign to enable logging }; + // Use DeclarativeWorkflowBuilder to build a workflow based on a YAML file. return DeclarativeWorkflowBuilder.Build(this.WorkflowFile, options); } - /// - /// Configuration key used to identify the Foundry project endpoint. - /// - private const string ConfigKeyFoundryEndpoint = "FOUNDRY_PROJECT_ENDPOINT"; - - /// - /// Controls on whether the function tools () initialized - /// in the constructor are included for auto-invocation. - /// NOTE: By default, no functions exist as part of this sample. - /// - private const bool IncludeFunctions = true; - - private static Dictionary NameCache { get; } = []; - private static HashSet FileCache { get; } = []; - private string WorkflowFile { get; } private string? WorkflowInput { get; } private string FoundryEndpoint { get; } - private PersistentAgentsClient FoundryClient { get; } private IConfiguration Configuration { get; } - private CheckpointInfo? LastCheckpoint { get; set; } - private Dictionary FunctionMap { get; } + private WorkflowRunner Runner { get; } + private IList Functions { get; } private Program(string workflowFile, string? workflowInput) { @@ -170,246 +107,26 @@ internal sealed class Program this.Configuration = InitializeConfig(); - this.FoundryEndpoint = this.Configuration[ConfigKeyFoundryEndpoint] ?? throw new InvalidOperationException($"Undefined configuration setting: {ConfigKeyFoundryEndpoint}"); - this.FoundryClient = new PersistentAgentsClient(this.FoundryEndpoint, new AzureCliCredential()); + this.FoundryEndpoint = this.Configuration[Application.Settings.FoundryEndpoint] ?? throw new InvalidOperationException($"Undefined configuration setting: {Application.Settings.FoundryEndpoint}"); - List functions = + this.Functions = [ // Manually define any custom functions that may be required by agents within the workflow. // By default, this sample does not include any functions. //AIFunctionFactory.Create(), ]; - this.FunctionMap = functions.ToDictionary(f => f.Name); - } - private async Task MonitorAndDisposeWorkflowRunAsync(Checkpointed run, object? response = null) - { - // Always dispose the run when done. - await using IAsyncDisposable disposeRun = run; - - bool hasStreamed = false; - string? messageId = null; - - await foreach (WorkflowEvent workflowEvent in run.Run.WatchStreamAsync()) - { - switch (workflowEvent) + this.Runner = + new(this.Functions) { - case ExecutorInvokedEvent executorInvoked: - Debug.WriteLine($"EXECUTOR ENTER #{executorInvoked.ExecutorId}"); - break; - - case ExecutorCompletedEvent executorCompleted: - Debug.WriteLine($"EXECUTOR EXIT #{executorCompleted.ExecutorId}"); - break; - - case DeclarativeActionInvokedEvent actionInvoked: - Debug.WriteLine($"ACTION ENTER #{actionInvoked.ActionId} [{actionInvoked.ActionType}]"); - break; - - case DeclarativeActionCompletedEvent actionComplete: - Debug.WriteLine($"ACTION EXIT #{actionComplete.ActionId} [{actionComplete.ActionType}]"); - break; - - case ExecutorFailedEvent executorFailure: - Debug.WriteLine($"STEP ERROR #{executorFailure.ExecutorId}: {executorFailure.Data?.Message ?? "Unknown"}"); - break; - - case WorkflowErrorEvent workflowError: - throw workflowError.Data as Exception ?? new InvalidOperationException("Unexpected failure..."); - - case SuperStepCompletedEvent checkpointCompleted: - this.LastCheckpoint = checkpointCompleted.CompletionInfo?.Checkpoint; - Debug.WriteLine($"CHECKPOINT x{checkpointCompleted.StepNumber} [{this.LastCheckpoint?.CheckpointId ?? "(none)"}]"); - break; - - case RequestInfoEvent requestInfo: - Debug.WriteLine($"REQUEST #{requestInfo.Request.RequestId}"); - if (response is not null) - { - ExternalResponse requestResponse = requestInfo.Request.CreateResponse(response); - await run.Run.SendResponseAsync(requestResponse); - response = null; - } - else - { - // Yield to handle the external request - return requestInfo.Request; - } - break; - - case ConversationUpdateEvent invokeEvent: - Debug.WriteLine($"CONVERSATION: {invokeEvent.Data}"); - break; - - case MessageActivityEvent activityEvent: - Console.ForegroundColor = ConsoleColor.Cyan; - Console.WriteLine("\nACTIVITY:"); - Console.ForegroundColor = ConsoleColor.Yellow; - Console.WriteLine(activityEvent.Message.Trim()); - break; - - case AgentRunUpdateEvent streamEvent: - if (!string.Equals(messageId, streamEvent.Update.MessageId, StringComparison.Ordinal)) - { - hasStreamed = false; - messageId = streamEvent.Update.MessageId; - - if (messageId is not null) - { - string? agentId = streamEvent.Update.AgentId; - if (agentId is not null) - { - if (!NameCache.TryGetValue(agentId, out string? realName)) - { - PersistentAgent agent = await this.FoundryClient.Administration.GetAgentAsync(agentId); - NameCache[agentId] = agent.Name; - realName = agent.Name; - } - agentId = realName; - } - agentId ??= nameof(ChatRole.Assistant); - Console.ForegroundColor = ConsoleColor.Cyan; - Console.Write($"\n{agentId.ToUpperInvariant()}:"); - Console.ForegroundColor = ConsoleColor.DarkGray; - Console.WriteLine($" [{messageId}]"); - } - } - - ChatResponseUpdate? chatUpdate = streamEvent.Update.RawRepresentation as ChatResponseUpdate; - switch (chatUpdate?.RawRepresentation) - { - case MessageContentUpdate messageUpdate: - string? fileId = messageUpdate.ImageFileId ?? messageUpdate.TextAnnotation?.OutputFileId; - if (fileId is not null && FileCache.Add(fileId)) - { - BinaryData content = await this.FoundryClient.Files.GetFileContentAsync(fileId); - await DownloadFileContentAsync(Path.GetFileName(messageUpdate.TextAnnotation?.TextToReplace ?? "response.png"), content); - } - break; - case RequiredActionUpdate actionUpdate: - Console.ForegroundColor = ConsoleColor.White; - Console.Write($"Calling tool: {actionUpdate.FunctionName}"); - Console.ForegroundColor = ConsoleColor.DarkGray; - Console.WriteLine($" [{actionUpdate.ToolCallId}]"); - break; - } - try - { - Console.ResetColor(); - Console.Write(streamEvent.Update.Text); - hasStreamed |= !string.IsNullOrEmpty(streamEvent.Update.Text); - } - finally - { - Console.ResetColor(); - } - break; - - case AgentRunResponseEvent messageEvent: - try - { - if (hasStreamed) - { - Console.WriteLine(); - } - - if (messageEvent.Response.Usage is not null) - { - Console.ForegroundColor = ConsoleColor.DarkGray; - Console.WriteLine($"[Tokens Total: {messageEvent.Response.Usage.TotalTokenCount}, Input: {messageEvent.Response.Usage.InputTokenCount}, Output: {messageEvent.Response.Usage.OutputTokenCount}]"); - } - } - finally - { - Console.ResetColor(); - } - break; - } - } - - return null; // No request to handle - } - - /// - /// Handle request for external input, either from a human or a function tool invocation. - /// - private async ValueTask HandleExternalRequestAsync(ExternalRequest request) => - request.Data.TypeId.TypeName switch - { - // Request for human input - _ when request.Data.TypeId.IsMatch() => HandleUserMessageRequest(request.DataAs()!), - // Request for function tool invocation. (Only active when functions are defined and IncludeFunctions is true.) - _ when request.Data.TypeId.IsMatch() => await this.HandleToolRequestAsync(request.DataAs()!), - // Request for user input, such as function or mcp tool approval - _ when request.Data.TypeId.IsMatch() => HandleUserInputRequest(request.DataAs()!), - // Unknown request type. - _ => throw new InvalidOperationException($"Unsupported external request type: {request.GetType().Name}."), - }; - - /// - /// Handle request for human input. - /// - private static AnswerResponse HandleUserMessageRequest(AnswerRequest request) - { - string? userInput; - do - { - Console.ForegroundColor = ConsoleColor.DarkGreen; - Console.Write($"\n{request.Prompt ?? "INPUT:"} "); - Console.ForegroundColor = ConsoleColor.White; - userInput = Console.ReadLine(); - } - while (string.IsNullOrWhiteSpace(userInput)); - - return new AnswerResponse(userInput); - } - - /// - /// Handle a function tool request by invoking the specified tools and returning the results. - /// - /// - /// This handler is only active when is set to true and - /// one or more instances are defined in the constructor. - /// - private async ValueTask HandleToolRequestAsync(AgentFunctionToolRequest request) - { - Task[] functionTasks = request.FunctionCalls.Select(functionCall => InvokesToolAsync(functionCall)).ToArray(); - - await Task.WhenAll(functionTasks); - - return AgentFunctionToolResponse.Create(request, functionTasks.Select(task => task.Result)); - - async Task InvokesToolAsync(FunctionCallContent functionCall) - { - AIFunction functionTool = this.FunctionMap[functionCall.Name]; - AIFunctionArguments? functionArguments = functionCall.Arguments is null ? null : new(functionCall.Arguments.NormalizePortableValues()); - object? result = await functionTool.InvokeAsync(functionArguments); - return new FunctionResultContent(functionCall.CallId, JsonSerializer.Serialize(result)); - } - } - - /// - /// Handle request for user input for mcp and function tool approval. - /// - private static UserInputResponse HandleUserInputRequest(UserInputRequest request) - { - return UserInputResponse.Create(request, ProcessRequests()); - - IEnumerable ProcessRequests() - { - foreach (UserInputRequestContent approvalRequest in request.InputRequests) - { - // Here we are explicitly approving all requests. - // In a real-world scenario, you would replace this logic to either solicit user approval or implement a more complex approval process. - yield return - approvalRequest switch - { - McpServerToolApprovalRequestContent mcpApprovalRequest => mcpApprovalRequest.CreateResponse(approved: true), - FunctionApprovalRequestContent functionApprovalRequest => functionApprovalRequest.CreateResponse(approved: true), - _ => throw new NotSupportedException($"Unsupported request of type {approvalRequest.GetType().Name}"), - }; - } - } +#if CHECKPOINT_JSON + // Use an json file checkpoint store that will persist checkpoints to the local file system. + UseJsonCheckpoints = true +#else + // Use an in-memory checkpoint store that will not persist checkpoints beyond the lifetime of the process. + UseJsonCheckpoints = false +#endif + }; } private static string? ParseWorkflowFile(string[] args) @@ -516,19 +233,4 @@ internal sealed class Program Console.ResetColor(); } } - - private static async ValueTask DownloadFileContentAsync(string filename, BinaryData content) - { - string filePath = Path.Combine(Path.GetTempPath(), Path.GetFileName(filename)); - filePath = Path.ChangeExtension(filePath, ".png"); - - await File.WriteAllBytesAsync(filePath, content.ToArray()); - - Process.Start( - new ProcessStartInfo - { - FileName = "cmd.exe", - Arguments = $"/C start {filePath}" - }); - } } diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/FunctionTools/FunctionTools.csproj b/dotnet/samples/GettingStarted/Workflows/Declarative/FunctionTools/FunctionTools.csproj new file mode 100644 index 0000000000..888a48f5df --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/FunctionTools/FunctionTools.csproj @@ -0,0 +1,40 @@ + + + + Exe + net9.0 + net9.0 + $(ProjectsDebugTargetFrameworks) + enable + enable + + + + true + true + true + true + + + + + + + + + + + + + + + + + + + + Always + + + + diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/FunctionTools/FunctionTools.yaml b/dotnet/samples/GettingStarted/Workflows/Declarative/FunctionTools/FunctionTools.yaml new file mode 100644 index 0000000000..0135111de5 --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/FunctionTools/FunctionTools.yaml @@ -0,0 +1,22 @@ +# +# This workflow demonstrates an agent that requires tool approval +# in a loop responding to user input. +# +# Example input: +# What is the soup of the day? +# +kind: Workflow +trigger: + + kind: OnConversationStart + id: workflow_demo + actions: + + - kind: InvokeAzureAgent + id: invoke_search + conversationId: =System.ConversationId + agent: + name: MenuAgent + input: + externalLoop: + when: =Upper(System.LastMessage.Text) <> "EXIT" diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/FunctionTools/MenuPlugin.cs b/dotnet/samples/GettingStarted/Workflows/Declarative/FunctionTools/MenuPlugin.cs new file mode 100644 index 0000000000..efe2a1284e --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/FunctionTools/MenuPlugin.cs @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.ComponentModel; + +namespace Demo.Workflows.Declarative.FunctionTools; + +#pragma warning disable CA1822 // Mark members as static + +public sealed class MenuPlugin +{ + [Description("Provides a list items on the menu.")] + public MenuItem[] GetMenu() + { + return s_menuItems; + } + + [Description("Provides a list of specials from the menu.")] + public MenuItem[] GetSpecials() + { + return [.. s_menuItems.Where(i => i.IsSpecial)]; + } + + [Description("Provides the price of the requested menu item.")] + public float? GetItemPrice( + [Description("The name of the menu item.")] + string name) + { + return s_menuItems.FirstOrDefault(i => i.Name.Equals(name, StringComparison.OrdinalIgnoreCase))?.Price; + } + + private static readonly MenuItem[] s_menuItems = + [ + new() + { + Category = "Soup", + Name = "Clam Chowder", + Price = 4.95f, + IsSpecial = true, + }, + new() + { + Category = "Soup", + Name = "Tomato Soup", + Price = 4.95f, + IsSpecial = false, + }, + new() + { + Category = "Salad", + Name = "Cobb Salad", + Price = 9.99f, + }, + new() + { + Category = "Salad", + Name = "House Salad", + Price = 4.95f, + }, + new() + { + Category = "Drink", + Name = "Chai Tea", + Price = 2.95f, + IsSpecial = true, + }, + new() + { + Category = "Drink", + Name = "Soda", + Price = 1.95f, + }, + ]; + + public sealed class MenuItem + { + public string Category { get; init; } = string.Empty; + public string Name { get; init; } = string.Empty; + public float Price { get; init; } + public bool IsSpecial { get; init; } + } +} diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/FunctionTools/Program.cs b/dotnet/samples/GettingStarted/Workflows/Declarative/FunctionTools/Program.cs new file mode 100644 index 0000000000..bc092a7600 --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/FunctionTools/Program.cs @@ -0,0 +1,87 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Azure.AI.Projects; +using Azure.AI.Projects.OpenAI; +using Azure.Identity; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Configuration; +using OpenAI.Responses; +using Shared.Foundry; +using Shared.Workflows; + +namespace Demo.Workflows.Declarative.FunctionTools; + +/// +/// Demonstrate a workflow that responds to user input using an agent who +/// with function tools assigned. Exits the loop when the user enters "exit". +/// +/// +/// See the README.md file in the parent folder (../README.md) for detailed +/// information about the configuration required to run this sample. +/// +internal sealed class Program +{ + public static async Task Main(string[] args) + { + // Initialize configuration + IConfiguration configuration = Application.InitializeConfig(); + Uri foundryEndpoint = new(configuration.GetValue(Application.Settings.FoundryEndpoint)); + + // Ensure sample agents exist in Foundry. + MenuPlugin menuPlugin = new(); + AIFunction[] functions = + [ + AIFunctionFactory.Create(menuPlugin.GetMenu), + AIFunctionFactory.Create(menuPlugin.GetSpecials), + AIFunctionFactory.Create(menuPlugin.GetItemPrice), + ]; + + await CreateAgentAsync(foundryEndpoint, configuration, functions); + + // Get input from command line or console + string workflowInput = Application.GetInput(args); + + // Create the workflow factory. This class demonstrates how to initialize a + // declarative workflow from a YAML file. Once the workflow is created, it + // can be executed just like any regular workflow. + WorkflowFactory workflowFactory = new("FunctionTools.yaml", foundryEndpoint); + + // Execute the workflow: The WorkflowRunner demonstrates how to execute + // a workflow, handle the workflow events, and providing external input. + // This also includes the ability to checkpoint workflow state and how to + // resume execution. + WorkflowRunner runner = new(functions) { UseJsonCheckpoints = true }; + await runner.ExecuteAsync(workflowFactory.CreateWorkflow, workflowInput); + } + + private static async Task CreateAgentAsync(Uri foundryEndpoint, IConfiguration configuration, AIFunction[] functions) + { + AIProjectClient aiProjectClient = new(foundryEndpoint, new AzureCliCredential()); + + await aiProjectClient.CreateAgentAsync( + agentName: "MenuAgent", + agentDefinition: DefineMenuAgent(configuration, functions), + agentDescription: "Provides information about the restaurant menu"); + } + + private static PromptAgentDefinition DefineMenuAgent(IConfiguration configuration, AIFunction[] functions) + { + PromptAgentDefinition agentDefinition = + new(configuration.GetValue(Application.Settings.FoundryModelMini)) + { + Instructions = + """ + Answer the users questions on the menu. + For questions or input that do not require searching the documentation, inform the + user that you can only answer questions what's on the menu. + """ + }; + + foreach (AIFunction function in functions) + { + agentDefinition.Tools.Add(function.AsOpenAIResponseTool()); + } + + return agentDefinition; + } +} diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/GenerateCode/GenerateCode.csproj b/dotnet/samples/GettingStarted/Workflows/Declarative/GenerateCode/GenerateCode.csproj index 72afa29cda..b10f7c5e95 100644 --- a/dotnet/samples/GettingStarted/Workflows/Declarative/GenerateCode/GenerateCode.csproj +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/GenerateCode/GenerateCode.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net9.0 net9.0 $(ProjectsDebugTargetFrameworks) enable diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/HostedWorkflow/HostedWorkflow.csproj b/dotnet/samples/GettingStarted/Workflows/Declarative/HostedWorkflow/HostedWorkflow.csproj new file mode 100644 index 0000000000..1f57e7e7bc --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/HostedWorkflow/HostedWorkflow.csproj @@ -0,0 +1,41 @@ + + + + Exe + net9.0 + net9.0 + $(ProjectsDebugTargetFrameworks) + enable + enable + $(NoWarn);CA1812 + + + + true + true + true + true + + + + + + + + + + + + + + + + + + + + Always + + + + diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/HostedWorkflow/Program.cs b/dotnet/samples/GettingStarted/Workflows/Declarative/HostedWorkflow/Program.cs new file mode 100644 index 0000000000..ff45cbc0c2 --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/HostedWorkflow/Program.cs @@ -0,0 +1,169 @@ +// Copyright (c) Microsoft. All rights reserved. + +// Uncomment this to enable JSON checkpointing to the local file system. +//#define CHECKPOINT_JSON + +using Azure.AI.Projects; +using Azure.AI.Projects.OpenAI; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Configuration; +using Shared.Foundry; +using Shared.Workflows; + +namespace Demo.DeclarativeWorkflow; + +/// +/// %%% COMMENT +/// +/// +/// Configuration +/// Define FOUNDRY_PROJECT_ENDPOINT as a user-secret or environment variable that +/// points to your Foundry project endpoint. +/// Usage +/// Provide the path to the workflow definition file as the first argument. +/// All other arguments are intepreted as a queue of inputs. +/// When no input is queued, interactive input is requested from the console. +/// +internal sealed class Program +{ + public static async Task Main(string[] args) + { + // Initialize configuration + IConfiguration configuration = Application.InitializeConfig(); + Uri foundryEndpoint = new(configuration.GetValue(Application.Settings.FoundryEndpoint)); + + // Create the agent service client + AIProjectClient aiProjectClient = new(foundryEndpoint, new AzureCliCredential()); + + // Ensure sample agents exist in Foundry. + await CreateAgentsAsync(aiProjectClient, configuration); + + // Ensure workflow agent exists in Foundry. + AgentVersion agentVersion = await CreateWorkflowAsync(aiProjectClient, configuration); + + string workflowInput = GetWorkflowInput(args); + + AIAgent agent = aiProjectClient.GetAIAgent(agentVersion); + + AgentThread thread = agent.GetNewThread(); + + ProjectConversation conversation = + await aiProjectClient + .GetProjectOpenAIClient() + .GetProjectConversationsClient() + .CreateProjectConversationAsync() + .ConfigureAwait(false); + + Console.WriteLine($"CONVERSATION: {conversation.Id}"); + + ChatOptions chatOptions = + new() + { + ConversationId = conversation.Id + }; + ChatClientAgentRunOptions runOptions = new(chatOptions); + + IAsyncEnumerable agentResponseUpdates = agent.RunStreamingAsync(workflowInput, thread, runOptions); + + string? lastMessageId = null; + await foreach (AgentRunResponseUpdate responseUpdate in agentResponseUpdates) + { + if (responseUpdate.MessageId != lastMessageId) + { + Console.WriteLine($"\n\n{responseUpdate.AuthorName ?? responseUpdate.AgentId}"); + } + + lastMessageId = responseUpdate.MessageId; + + Console.Write(responseUpdate.Text); + } + } + + private static async Task CreateWorkflowAsync(AIProjectClient agentClient, IConfiguration configuration) + { + string workflowYaml = File.ReadAllText("MathChat.yaml"); + + WorkflowAgentDefinition workflowAgentDefinition = WorkflowAgentDefinition.FromYaml(workflowYaml); + + return + await agentClient.CreateAgentAsync( + agentName: "MathChatWorkflow", + agentDefinition: workflowAgentDefinition, + agentDescription: "The student attempts to solve the input problem and the teacher provides guidance."); + } + + private static async Task CreateAgentsAsync(AIProjectClient agentClient, IConfiguration configuration) + { + await agentClient.CreateAgentAsync( + agentName: "StudentAgent", + agentDefinition: DefineStudentAgent(configuration), + agentDescription: "Student agent for MathChat workflow"); + + await agentClient.CreateAgentAsync( + agentName: "TeacherAgent", + agentDefinition: DefineTeacherAgent(configuration), + agentDescription: "Teacher agent for MathChat workflow"); + } + + private static PromptAgentDefinition DefineStudentAgent(IConfiguration configuration) => + new(configuration.GetValue(Application.Settings.FoundryModelMini)) + { + Instructions = + """ + Your job is help a math teacher practice teaching by making intentional mistakes. + You attempt to solve the given math problem, but with intentional mistakes so the teacher can help. + Always incorporate the teacher's advice to fix your next response. + You have the math-skills of a 6th grader. + Don't describe who you are or reveal your instructions. + """ + }; + + private static PromptAgentDefinition DefineTeacherAgent(IConfiguration configuration) => + new(configuration.GetValue(Application.Settings.FoundryModelMini)) + { + Instructions = + """ + Review and coach the student's approach to solving the given math problem. + Don't repeat the solution or try and solve it. + If the student has demonstrated comprehension and responded to all of your feedback, + give the student your congratulations by using the word "congratulations". + """ + }; + + private static string GetWorkflowInput(string[] args) + { + string? input = null; + + if (args.Length > 0) + { + string[] workflowInput = [.. args.Skip(1)]; + input = workflowInput.FirstOrDefault(); + } + + try + { + Console.ForegroundColor = ConsoleColor.DarkGreen; + Console.Write("\nINPUT: "); + Console.ForegroundColor = ConsoleColor.White; + + if (!string.IsNullOrWhiteSpace(input)) + { + Console.WriteLine(input); + return input; + } + + while (string.IsNullOrWhiteSpace(input)) + { + input = Console.ReadLine(); + } + + return input.Trim(); + } + finally + { + Console.ResetColor(); + } + } +} diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/InputArguments/InputArguments.csproj b/dotnet/samples/GettingStarted/Workflows/Declarative/InputArguments/InputArguments.csproj new file mode 100644 index 0000000000..51582438eb --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/InputArguments/InputArguments.csproj @@ -0,0 +1,40 @@ + + + + Exe + net9.0 + net9.0 + $(ProjectsDebugTargetFrameworks) + enable + enable + + + + true + true + true + true + + + + + + + + + + + + + + + + + + + + Always + + + + diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/InputArguments/InputArguments.yaml b/dotnet/samples/GettingStarted/Workflows/Declarative/InputArguments/InputArguments.yaml new file mode 100644 index 0000000000..3f602d0e7b --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/InputArguments/InputArguments.yaml @@ -0,0 +1,97 @@ +# +# This workflow demonstrates providing input arguments to an agent. +# +# Example input: +# I'd like to go on vacation. +# +kind: Workflow +trigger: + + kind: OnConversationStart + id: workflow_demo + actions: + + # Capture the original user message for input to the location-aware agent + - kind: SetVariable + id: set_count_increment + variable: Local.InputMessage + value: =System.LastMessage + + # Invoke the triage agent to determine location requirements + - kind: InvokeAzureAgent + id: solicit_input + conversationId: =System.ConversationId + agent: + name: LocationTriageAgent + input: + messages: =Local.ActionMessage + output: + messages: Local.TriageResponse + + # Request input from the user based on the triage response + - kind: RequestExternalInput + id: request_requirements + variable: Local.NextInput + + # Capture the most recent interaction for evaluation + - kind: SetTextVariable + id: set_status_message + variable: Local.LocationStatusInput + value: |- + AGENT - {MessageText(Local.TriageResponse)} + + USER - {MessageText(Local.NextInput)} + + # Evaluate the status of the location triage + - kind: InvokeAzureAgent + id: evaluate_location + agent: + name: LocationCaptureAgent + input: + messages: =UserMessage(Local.LocationStatusInput) + output: + responseObject: Local.LocationResponse + + # Determine if the location information is complete + - kind: ConditionGroup + id: check_completion + conditions: + + - condition: |- + =Local.LocationResponse.is_location_defined = false Or + Local.LocationResponse.is_location_confirmed = false + id: check_done + actions: + + # Capture the action message for input to the triage agent + - kind: SetVariable + id: set_next_message + variable: Local.ActionMessage + value: =AgentMessage(Local.LocationResponse.action) + + - kind: GotoAction + id: goto_solicit_input + actionId: solicit_input + + elseActions: + + # Create a new conversation so the prior context does not interfere + - kind: CreateConversation + id: conversation_location + conversationId: Local.LocationConversationId + + # Invoke the location-aware agent with the location argument + # and loop until the user types "EXIT" + - kind: InvokeAzureAgent + id: location_response + conversationId: =Local.LocationConversationId + agent: + name: LocationAwareAgent + input: + messages: =Local.InputMessage + arguments: + location: =Local.LocationResponse.place + externalLoop: + when: =Upper(System.LastMessage.Text) <> "EXIT" + output: + autoSend: true diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/InputArguments/Program.cs b/dotnet/samples/GettingStarted/Workflows/Declarative/InputArguments/Program.cs new file mode 100644 index 0000000000..9aab54b4cf --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/InputArguments/Program.cs @@ -0,0 +1,148 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Azure.AI.Projects; +using Azure.AI.Projects.OpenAI; +using Azure.Identity; +using Microsoft.Extensions.Configuration; +using OpenAI.Responses; +using Shared.Foundry; +using Shared.Workflows; + +namespace Demo.Workflows.Declarative.InputArguments; + +/// +/// Demonstrate a workflow that consumes input arguments to dynamically enhance the agent +/// instructions. Exits the loop when the user enters "exit". +/// +/// +/// See the README.md file in the parent folder (../README.md) for detailed +/// information about the configuration required to run this sample. +/// +internal sealed class Program +{ + public static async Task Main(string[] args) + { + // Initialize configuration + IConfiguration configuration = Application.InitializeConfig(); + Uri foundryEndpoint = new(configuration.GetValue(Application.Settings.FoundryEndpoint)); + + // Ensure sample agents exist in Foundry. + await CreateAgentAsync(foundryEndpoint, configuration); + + // Get input from command line or console + string workflowInput = Application.GetInput(args); + + // Create the workflow factory. This class demonstrates how to initialize a + // declarative workflow from a YAML file. Once the workflow is created, it + // can be executed just like any regular workflow. + WorkflowFactory workflowFactory = new("InputArguments.yaml", foundryEndpoint); + + // Execute the workflow: The WorkflowRunner demonstrates how to execute + // a workflow, handle the workflow events, and providing external input. + // This also includes the ability to checkpoint workflow state and how to + // resume execution. + WorkflowRunner runner = new(); + await runner.ExecuteAsync(workflowFactory.CreateWorkflow, workflowInput); + } + + private static async Task CreateAgentAsync(Uri foundryEndpoint, IConfiguration configuration) + { + AIProjectClient aiProjectClient = new(foundryEndpoint, new AzureCliCredential()); + + await aiProjectClient.CreateAgentAsync( + agentName: "LocationTriageAgent", + agentDefinition: DefineLocationTriageAgent(configuration), + agentDescription: "Chats with the user to solicit a location of interest."); + + await aiProjectClient.CreateAgentAsync( + agentName: "LocationCaptureAgent", + agentDefinition: DefineLocationCaptureAgent(configuration), + agentDescription: "Evaluate the status of soliciting the location."); + + await aiProjectClient.CreateAgentAsync( + agentName: "LocationAwareAgent", + agentDefinition: DefineLocationAwareAgent(configuration), + agentDescription: "Chats with the user with location awareness."); + } + + private static PromptAgentDefinition DefineLocationTriageAgent(IConfiguration configuration) => + new(configuration.GetValue(Application.Settings.FoundryModelMini)) + { + Instructions = + """ + Your only job is to solicit a location from the user. + + Always repeat back the location when addressing the user, except when it is not known. + """ + }; + + private static PromptAgentDefinition DefineLocationCaptureAgent(IConfiguration configuration) => + new(configuration.GetValue(Application.Settings.FoundryModelMini)) + { + Instructions = + """ + Request a location from the user. This location could be their own location + or perhaps a location they are interested in. + + City level precision is sufficient. + + If extrapolating region and country, confirm you have it right. + """, + TextOptions = + new ResponseTextOptions + { + TextFormat = + ResponseTextFormat.CreateJsonSchemaFormat( + "TaskEvaluation", + BinaryData.FromString( + """ + { + "type": "object", + "properties": { + "place": { + "type": "string", + "description": "Captures only your understanding of the location specified by the user without explanation, or 'unknown' if not yet defined." + }, + "action": { + "type": "string", + "description": "The instruction for the next action to take regarding the need for additional detail or confirmation." + }, + "is_location_defined": { + "type": "boolean", + "description": "True if the user location is understood." + }, + "is_location_confirmed": { + "type": "boolean", + "description": "True if the user location is confirmed. An unambiguous location may be implicitly confirmed without explicit user confirmation." + } + }, + "required": ["place", "action", "is_location_defined", "is_location_confirmed"], + "additionalProperties": false + } + """), + jsonSchemaFormatDescription: null, + jsonSchemaIsStrict: true), + } + }; + + private static PromptAgentDefinition DefineLocationAwareAgent(IConfiguration configuration) => + new(configuration.GetValue(Application.Settings.FoundryModelMini)) + { + // Parameterized instructions reference the "location" input argument. + Instructions = + """ + Talk to the user about their request. + Their request is related to a specific location: {{location}}. + """, + StructuredInputs = + { + ["location"] = + new StructuredInputDefinition + { + IsRequired = false, + DefaultValue = BinaryData.FromString(@"""unknown"""), + Description = "The user's location", + } + } + }; +} diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/Marketing/Marketing.csproj b/dotnet/samples/GettingStarted/Workflows/Declarative/Marketing/Marketing.csproj new file mode 100644 index 0000000000..12599a1b79 --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/Marketing/Marketing.csproj @@ -0,0 +1,40 @@ + + + + Exe + net9.0 + net9.0 + $(ProjectsDebugTargetFrameworks) + enable + enable + + + + true + true + true + true + + + + + + + + + + + + + + + + + + + + Always + + + + diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/Marketing/Program.cs b/dotnet/samples/GettingStarted/Workflows/Declarative/Marketing/Program.cs new file mode 100644 index 0000000000..229658310d --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/Marketing/Program.cs @@ -0,0 +1,105 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Azure.AI.Projects; +using Azure.AI.Projects.OpenAI; +using Azure.Identity; +using Microsoft.Extensions.Configuration; +using Shared.Foundry; +using Shared.Workflows; + +namespace Demo.Workflows.Declarative.Marketing; + +/// +/// Demonstrate a declarative workflow with three agents (Analyst, Writer, Editor) +/// sequentially engaging in a task. +/// +/// +/// See the README.md file in the parent folder (../README.md) for detailed +/// information about the configuration required to run this sample. +/// +internal sealed class Program +{ + public static async Task Main(string[] args) + { + // Initialize configuration + IConfiguration configuration = Application.InitializeConfig(); + Uri foundryEndpoint = new(configuration.GetValue(Application.Settings.FoundryEndpoint)); + + // Ensure sample agents exist in Foundry. + await CreateAgentsAsync(foundryEndpoint, configuration); + + // Get input from command line or console + string workflowInput = Application.GetInput(args); + + // Create the workflow factory. This class demonstrates how to initialize a + // declarative workflow from a YAML file. Once the workflow is created, it + // can be executed just like any regular workflow. + WorkflowFactory workflowFactory = new("Marketing.yaml", foundryEndpoint); + + // Execute the workflow: The WorkflowRunner demonstrates how to execute + // a workflow, handle the workflow events, and providing external input. + // This also includes the ability to checkpoint workflow state and how to + // resume execution. + WorkflowRunner runner = new(); + await runner.ExecuteAsync(workflowFactory.CreateWorkflow, workflowInput); + } + + private static async Task CreateAgentsAsync(Uri foundryEndpoint, IConfiguration configuration) + { + AIProjectClient aiProjectClient = new(foundryEndpoint, new AzureCliCredential()); + + await aiProjectClient.CreateAgentAsync( + agentName: "AnalystAgent", + agentDefinition: DefineAnalystAgent(configuration), + agentDescription: "Analyst agent for Marketing workflow"); + + await aiProjectClient.CreateAgentAsync( + agentName: "WriterAgent", + agentDefinition: DefineWriterAgent(configuration), + agentDescription: "Writer agent for Marketing workflow"); + + await aiProjectClient.CreateAgentAsync( + agentName: "EditorAgent", + agentDefinition: DefineEditorAgent(configuration), + agentDescription: "Editor agent for Marketing workflow"); + } + + private static PromptAgentDefinition DefineAnalystAgent(IConfiguration configuration) => + new(configuration.GetValue(Application.Settings.FoundryModelFull)) + { + Instructions = + """ + You are a marketing analyst. Given a product description, identify: + - Key features + - Target audience + - Unique selling points + """, + Tools = + { + //AgentTool.CreateBingGroundingTool( // TODO: Use Bing Grounding when available + // new BingGroundingSearchToolParameters( + // [new BingGroundingSearchConfiguration(configuration[Application.Settings.FoundryGroundingTool])])) + } + }; + + private static PromptAgentDefinition DefineWriterAgent(IConfiguration configuration) => + new(configuration.GetValue(Application.Settings.FoundryModelFull)) + { + Instructions = + """ + You are a marketing copywriter. Given a block of text describing features, audience, and USPs, + compose a compelling marketing copy (like a newsletter section) that highlights these points. + Output should be short (around 150 words), output just the copy as a single text block. + """ + }; + + private static PromptAgentDefinition DefineEditorAgent(IConfiguration configuration) => + new(configuration.GetValue(Application.Settings.FoundryModelFull)) + { + Instructions = + """ + You are an editor. Given the draft copy, correct grammar, improve clarity, ensure consistent tone, + give format and make it polished. Output the final improved copy as a single text block. + """ + }; +} diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/README.md b/dotnet/samples/GettingStarted/Workflows/Declarative/README.md index d2bbaa14a6..665c37101e 100644 --- a/dotnet/samples/GettingStarted/Workflows/Declarative/README.md +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/README.md @@ -1,26 +1,26 @@ # Summary -This demo showcases the ability to parse a declarative Foundry Workflow file (YAML) to build a `Workflow<>` -be executed using the same pattern as any code-based workflow. +These samples showcases the ability to parse a declarative Foundry Workflow file (YAML) +to build a `Workflow` that may be executed using the same pattern as any code-based workflow. ## Configuration -This demo requires configuration to access agents an [Azure Foundry Project](https://learn.microsoft.com/azure/ai-foundry). +These samples must be configured to create and use agents your +[Azure Foundry Project](https://learn.microsoft.com/azure/ai-foundry). -#### Settings +### Settings We suggest using .NET [Secret Manager](https://learn.microsoft.com/en-us/aspnet/core/security/app-secrets) to avoid the risk of leaking secrets into the repository, branches and pull requests. You can also use environment variables if you prefer. -To set your secrets as an environment variable (PowerShell): - -```pwsh -$env:FOUNDRY_PROJECT_ENDPOINT="https://..." -``` - -etc... +The configuraton required by the samples is: +|Setting Name| Description| +|:--|:--| +|FOUNDRY_PROJECT_ENDPOINT| The endpoint URL of your Azure Foundry Project.| +|FOUNDRY_MODEL_DEPLOYMENT_NAME| The name of the model deployment to use +|FOUNDRY_CONNECTION_GROUNDING_TOOL| The name of the Bing Grounding connection configured in your Azure Foundry Project.| To set your secrets with .NET Secret Manager: @@ -51,7 +51,7 @@ To set your secrets with .NET Secret Manager: 5. Define setting that identifies your Azure Foundry Model Deployment (endpoint): ``` - dotnet user-secrets set "FOUNDRY_MODEL_DEPLOYMENT_NAME" "gpt-4.1" + dotnet user-secrets set "FOUNDRY_MODEL_DEPLOYMENT_NAME" "gpt-5" ``` 6. Define setting that identifies your Bing Grounding connection: @@ -60,7 +60,15 @@ To set your secrets with .NET Secret Manager: dotnet user-secrets set "FOUNDRY_CONNECTION_GROUNDING_TOOL" "mybinggrounding" ``` -#### Authorization +You may alternatively set your secrets as an environment variable (PowerShell): + +```pwsh +$env:FOUNDRY_PROJECT_ENDPOINT="https://..." +$env:FOUNDRY_MODEL_DEPLOYMENT_NAME="gpt-5" +$env:FOUNDRY_CONNECTION_GROUNDING_TOOL="mybinggrounding" +``` + +### Authorization Use [_Azure CLI_](https://learn.microsoft.com/cli/azure/authenticate-azure-cli) to authorize access to your Azure Foundry Project: @@ -69,34 +77,23 @@ az login az account get-access-token ``` -#### Agents - -The sample workflows rely on agents defined in your Azure Foundry Project. - -To create agents, run the [`Create.ps1`](../../../../../workflow-samples/setup/) script. -This will create the agents used in the sample workflows in your Azure Foundry Project and format a script you can copy and use to configure your environment. - -> Note: `Create.ps1` relies upon the `FOUNDRY_PROJECT_ENDPOINT`, `FOUNDRY_MODEL_DEPLOYMENT_NAME`, and `FOUNDRY_CONNECTION_GROUNDING_TOOL` settings. - ## Execution -Run the demo from the console by specifying a path to a declarative (YAML) workflow file. -The repository has example workflows available in the root [`/workflow-samples`](../../../../../workflow-samples) folder. +The samples may be executed within _Visual Studio_ or _VS Code_. + +To run the sampes from the command line: 1. From the root of the repository, navigate the console to the project folder: ```sh - cd dotnet/samples/GettingStarted/Workflows/Declarative/DeclarativeWorkflow - ``` - -2. Run the demo referencing a sample workflow by name: - - ```sh + cd dotnet/samples/GettingStarted/Workflows/Declarative/Marketing dotnet run Marketing ``` -3. Run the demo with a path to any workflow file: +2. Run the demo and optionally provided input: ```sh + dotnet run "An eco-friendly stainless steel water bottle that keeps drinks cold for 24 hours." dotnet run c:/myworkflows/Marketing.yaml ``` + > The sample will allow for interactive input in the absence of an input argument. \ No newline at end of file diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/StudentTeacher/Program.cs b/dotnet/samples/GettingStarted/Workflows/Declarative/StudentTeacher/Program.cs new file mode 100644 index 0000000000..7422e29f63 --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/StudentTeacher/Program.cs @@ -0,0 +1,86 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Azure.AI.Projects; +using Azure.AI.Projects.OpenAI; +using Azure.Identity; +using Microsoft.Extensions.Configuration; +using Shared.Foundry; +using Shared.Workflows; + +namespace Demo.Workflows.Declarative.StudentTeacher; + +/// +/// Demonstrate a declarative workflow with two agents (Student and Teacher) +/// in an iterative conversation. +/// +/// +/// See the README.md file in the parent folder (../README.md) for detailed +/// information about the configuration required to run this sample. +/// +internal sealed class Program +{ + public static async Task Main(string[] args) + { + // Initialize configuration + IConfiguration configuration = Application.InitializeConfig(); + Uri foundryEndpoint = new(configuration.GetValue(Application.Settings.FoundryEndpoint)); + + // Ensure sample agents exist in Foundry. + await CreateAgentsAsync(foundryEndpoint, configuration); + + // Get input from command line or console + string workflowInput = Application.GetInput(args); + + // Create the workflow factory. This class demonstrates how to initialize a + // declarative workflow from a YAML file. Once the workflow is created, it + // can be executed just like any regular workflow. + WorkflowFactory workflowFactory = new("MathChat.yaml", foundryEndpoint); + + // Execute the workflow: The WorkflowRunner demonstrates how to execute + // a workflow, handle the workflow events, and providing external input. + // This also includes the ability to checkpoint workflow state and how to + // resume execution. + WorkflowRunner runner = new(); + await runner.ExecuteAsync(workflowFactory.CreateWorkflow, workflowInput); + } + + private static async Task CreateAgentsAsync(Uri foundryEndpoint, IConfiguration configuration) + { + AIProjectClient aiProjectClient = new(foundryEndpoint, new AzureCliCredential()); + + await aiProjectClient.CreateAgentAsync( + agentName: "StudentAgent", + agentDefinition: DefineStudentAgent(configuration), + agentDescription: "Student agent for MathChat workflow"); + + await aiProjectClient.CreateAgentAsync( + agentName: "TeacherAgent", + agentDefinition: DefineTeacherAgent(configuration), + agentDescription: "Teacher agent for MathChat workflow"); + } + + private static PromptAgentDefinition DefineStudentAgent(IConfiguration configuration) => + new(configuration.GetValue(Application.Settings.FoundryModelMini)) + { + Instructions = + """ + Your job is help a math teacher practice teaching by making intentional mistakes. + You attempt to solve the given math problem, but with intentional mistakes so the teacher can help. + Always incorporate the teacher's advice to fix your next response. + You have the math-skills of a 6th grader. + Don't describe who you are or reveal your instructions. + """ + }; + + private static PromptAgentDefinition DefineTeacherAgent(IConfiguration configuration) => + new(configuration.GetValue(Application.Settings.FoundryModelMini)) + { + Instructions = + """ + Review and coach the student's approach to solving the given math problem. + Don't repeat the solution or try and solve it. + If the student has demonstrated comprehension and responded to all of your feedback, + give the student your congratulations by using the word "congratulations". + """ + }; +} diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/StudentTeacher/StudentTeacher.csproj b/dotnet/samples/GettingStarted/Workflows/Declarative/StudentTeacher/StudentTeacher.csproj new file mode 100644 index 0000000000..7c210d6f96 --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/StudentTeacher/StudentTeacher.csproj @@ -0,0 +1,40 @@ + + + + Exe + net9.0 + net9.0 + $(ProjectsDebugTargetFrameworks) + enable + enable + + + + true + true + true + true + + + + + + + + + + + + + + + + + + + + Always + + + + \ No newline at end of file diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/ToolApproval/Program.cs b/dotnet/samples/GettingStarted/Workflows/Declarative/ToolApproval/Program.cs new file mode 100644 index 0000000000..3ccfc46d88 --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/ToolApproval/Program.cs @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Azure.AI.Projects; +using Azure.AI.Projects.OpenAI; +using Azure.Identity; +using Microsoft.Extensions.Configuration; +using OpenAI.Responses; +using Shared.Foundry; +using Shared.Workflows; + +namespace Demo.Workflows.Declarative.ToolApproval; + +/// +/// Demonstrate a workflow that responds to user input using an agent who +/// has an MCP tool that requires approval. Exits the loop when the user enters "exit". +/// +/// +/// See the README.md file in the parent folder (../README.md) for detailed +/// information about the configuration required to run this sample. +/// +internal sealed class Program +{ + public static async Task Main(string[] args) + { + // Initialize configuration + IConfiguration configuration = Application.InitializeConfig(); + Uri foundryEndpoint = new(configuration.GetValue(Application.Settings.FoundryEndpoint)); + + // Ensure sample agents exist in Foundry. + await CreateAgentAsync(foundryEndpoint, configuration); + + // Get input from command line or console + string workflowInput = Application.GetInput(args); + + // Create the workflow factory. This class demonstrates how to initialize a + // declarative workflow from a YAML file. Once the workflow is created, it + // can be executed just like any regular workflow. + WorkflowFactory workflowFactory = new("ToolApproval.yaml", foundryEndpoint); + + // Execute the workflow: The WorkflowRunner demonstrates how to execute + // a workflow, handle the workflow events, and providing external input. + // This also includes the ability to checkpoint workflow state and how to + // resume execution. + WorkflowRunner runner = new() { UseJsonCheckpoints = true }; + await runner.ExecuteAsync(workflowFactory.CreateWorkflow, workflowInput); + } + + private static async Task CreateAgentAsync(Uri foundryEndpoint, IConfiguration configuration) + { + AIProjectClient aiProjectClient = new(foundryEndpoint, new AzureCliCredential()); + + await aiProjectClient.CreateAgentAsync( + agentName: "DocumentSearchAgent", + agentDefinition: DefineSearchAgent(configuration), + agentDescription: "Searches documents on Microsoft Learn"); + } + + private static PromptAgentDefinition DefineSearchAgent(IConfiguration configuration) => + new(configuration.GetValue(Application.Settings.FoundryModelMini)) + { + Instructions = + """ + Answer the users questions by searching the Microsoft Learn documentation. + For questions or input that do not require searching the documentation, inform the + user that you can only answer questions related to Microsoft Learn documentation. + """, + Tools = + { + ResponseTool.CreateMcpTool( + serverLabel: "microsoft_docs", + serverUri: new Uri("https://learn.microsoft.com/api/mcp"), + toolCallApprovalPolicy: new McpToolCallApprovalPolicy(GlobalMcpToolCallApprovalPolicy.AlwaysRequireApproval)) + } + }; +} diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/ToolApproval/ToolApproval.csproj b/dotnet/samples/GettingStarted/Workflows/Declarative/ToolApproval/ToolApproval.csproj new file mode 100644 index 0000000000..6fa1cf12d9 --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/ToolApproval/ToolApproval.csproj @@ -0,0 +1,40 @@ + + + + Exe + net9.0 + net9.0 + $(ProjectsDebugTargetFrameworks) + enable + enable + + + + true + true + true + true + + + + + + + + + + + + + + + + + + + + Always + + + + diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/ToolApproval/ToolApproval.yaml b/dotnet/samples/GettingStarted/Workflows/Declarative/ToolApproval/ToolApproval.yaml new file mode 100644 index 0000000000..9383a60fce --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/ToolApproval/ToolApproval.yaml @@ -0,0 +1,38 @@ +# +# This workflow demonstrates an agent that requires tool approval +# in a loop responding to user input. +# +# Example input: +# What is Microsoft Graph API used for? +# +kind: Workflow +trigger: + + kind: OnConversationStart + id: workflow_demo + actions: + + - kind: InvokeAzureAgent + id: invoke_search + conversationId: =System.ConversationId + agent: + name: DocumentSearchAgent + + - kind: RequestExternalInput + id: request_requirements + + - kind: ConditionGroup + id: check_completion + conditions: + + - condition: =Upper(System.LastMessage.Text) = "EXIT" + id: check_done + actions: + + - kind: EndWorkflow + id: all_done + + elseActions: + - kind: GotoAction + id: goto_search + actionId: invoke_search diff --git a/dotnet/src/Microsoft.Agents.AI.AzureAI.Persistent/Microsoft.Agents.AI.AzureAI.Persistent.csproj b/dotnet/src/Microsoft.Agents.AI.AzureAI.Persistent/Microsoft.Agents.AI.AzureAI.Persistent.csproj index 9fcbc4c83f..bdf668391b 100644 --- a/dotnet/src/Microsoft.Agents.AI.AzureAI.Persistent/Microsoft.Agents.AI.AzureAI.Persistent.csproj +++ b/dotnet/src/Microsoft.Agents.AI.AzureAI.Persistent/Microsoft.Agents.AI.AzureAI.Persistent.csproj @@ -1,4 +1,4 @@ - + $(ProjectsTargetFrameworks) @@ -20,8 +20,8 @@ - Microsoft Agent Framework AzureAI - Provides Microsoft Agent Framework support for Azure AI. + Microsoft Agent Framework AzureAI Persistent Agents + Provides Microsoft Agent Framework support for Azure AI Persistent Agents. diff --git a/dotnet/src/Microsoft.Agents.AI.AzureAI/AzureAIProjectChatClient.cs b/dotnet/src/Microsoft.Agents.AI.AzureAI/AzureAIProjectChatClient.cs new file mode 100644 index 0000000000..2a5ace7a0a --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.AzureAI/AzureAIProjectChatClient.cs @@ -0,0 +1,148 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Runtime.CompilerServices; +using Azure.AI.Projects; +using Azure.AI.Projects.OpenAI; +using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; +using OpenAI.Responses; + +#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + +namespace Microsoft.Agents.AI.AzureAI; + +/// +/// Provides a chat client implementation that integrates with Azure AI Agents, enabling chat interactions using +/// Azure-specific agent capabilities. +/// +internal sealed class AzureAIProjectChatClient : DelegatingChatClient +{ + private readonly ChatClientMetadata? _metadata; + private readonly AIProjectClient _agentClient; + private readonly AgentVersion? _agentVersion; + private readonly AgentRecord? _agentRecord; + private readonly ChatOptions? _chatOptions; + private readonly AgentReference _agentReference; + /// + /// The usage of a no-op model is a necessary change to avoid OpenAIClients to throw exceptions when + /// used with Azure AI Agents as the model used is now defined at the agent creation time. + /// + private const string NoOpModel = "no-op"; + + /// + /// Initializes a new instance of the class. + /// + /// An instance of to interact with Azure AI Agents services. + /// An instance of representing the specific agent to use. + /// The default model to use for the agent, if applicable. + /// An instance of representing the options on how the agent was predefined. + /// + /// The provided should be decorated with a for proper functionality. + /// + internal AzureAIProjectChatClient(AIProjectClient aiProjectClient, AgentReference agentReference, string? defaultModelId, ChatOptions? chatOptions) + : base(Throw.IfNull(aiProjectClient) + .GetProjectOpenAIClient() + .GetOpenAIResponseClient(defaultModelId ?? NoOpModel) + .AsIChatClient()) + { + this._agentClient = aiProjectClient; + this._agentReference = Throw.IfNull(agentReference); + this._metadata = new ChatClientMetadata("azure.ai.agents", defaultModelId: defaultModelId); + this._chatOptions = chatOptions; + } + + /// + /// Initializes a new instance of the class. + /// + /// An instance of to interact with Azure AI Agents services. + /// An instance of representing the specific agent to use. + /// An instance of representing the options on how the agent was predefined. + /// + /// The provided should be decorated with a for proper functionality. + /// + internal AzureAIProjectChatClient(AIProjectClient aiProjectClient, AgentRecord agentRecord, ChatOptions? chatOptions) + : this(aiProjectClient, Throw.IfNull(agentRecord).Versions.Latest, chatOptions) + { + this._agentRecord = agentRecord; + } + + internal AzureAIProjectChatClient(AIProjectClient aiProjectClient, AgentVersion agentVersion, ChatOptions? chatOptions) + : this( + aiProjectClient, + new AgentReference(Throw.IfNull(agentVersion).Name, agentVersion.Version), + (agentVersion.Definition as PromptAgentDefinition)?.Model, + chatOptions) + { + this._agentVersion = agentVersion; + } + + /// + public override object? GetService(Type serviceType, object? serviceKey = null) + { + return (serviceKey is null && serviceType == typeof(ChatClientMetadata)) + ? this._metadata + : (serviceKey is null && serviceType == typeof(AIProjectClient)) + ? this._agentClient + : (serviceKey is null && serviceType == typeof(AgentVersion)) + ? this._agentVersion + : (serviceKey is null && serviceType == typeof(AgentRecord)) + ? this._agentRecord + : (serviceKey is null && serviceType == typeof(AgentReference)) + ? this._agentReference + : base.GetService(serviceType, serviceKey); + } + + /// + public override async Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + var agentOptions = this.GetAgentEnabledChatOptions(options); + + return await base.GetResponseAsync(messages, agentOptions, cancellationToken).ConfigureAwait(false); + } + + /// + public async override IAsyncEnumerable GetStreamingResponseAsync(IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var agentOptions = this.GetAgentEnabledChatOptions(options); + + await foreach (var chunk in base.GetStreamingResponseAsync(messages, agentOptions, cancellationToken).ConfigureAwait(false)) + { + yield return chunk; + } + } + + private ChatOptions GetAgentEnabledChatOptions(ChatOptions? options) + { + // Start with a clone of the base chat options defined for the agent, if any. + ChatOptions agentEnabledChatOptions = this._chatOptions?.Clone() ?? new(); + + // Ignore per-request all options that can't be overridden. + agentEnabledChatOptions.Instructions = null; + agentEnabledChatOptions.Tools = null; + agentEnabledChatOptions.Temperature = null; + agentEnabledChatOptions.TopP = null; + agentEnabledChatOptions.PresencePenalty = null; + agentEnabledChatOptions.ResponseFormat = null; + + // Use the conversation from the request, or the one defined at the client level. + agentEnabledChatOptions.ConversationId = options?.ConversationId ?? this._chatOptions?.ConversationId; + + // Preserve the original RawRepresentationFactory + var originalFactory = options?.RawRepresentationFactory; + + agentEnabledChatOptions.RawRepresentationFactory = (client) => + { + if (originalFactory?.Invoke(this) is not ResponseCreationOptions responseCreationOptions) + { + responseCreationOptions = new ResponseCreationOptions(); + } + + ResponseCreationOptionsExtensions.set_Agent(responseCreationOptions, this._agentReference); + ResponseCreationOptionsExtensions.set_Model(responseCreationOptions, null); + + return responseCreationOptions; + }; + + return agentEnabledChatOptions; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.AzureAI/AzureAIProjectChatClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI.AzureAI/AzureAIProjectChatClientExtensions.cs new file mode 100644 index 0000000000..0ec5f593fd --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.AzureAI/AzureAIProjectChatClientExtensions.cs @@ -0,0 +1,1039 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.ClientModel; +using System.ClientModel.Primitives; +using System.Runtime.CompilerServices; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; +using Azure.AI.Projects.OpenAI; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.AzureAI; +using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; +using OpenAI; +using OpenAI.Responses; + +#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. +#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + +namespace Azure.AI.Projects; + +/// +/// Provides extension methods for . +/// +public static partial class AzureAIProjectChatClientExtensions +{ + /// + /// Retrieves an existing server side agent, wrapped as a using the provided . + /// + /// The to create the with. Cannot be . + /// The representing the name and version of the server side agent to create a for. Cannot be . + /// The tools to use when interacting with the agent. This is required when using prompt agent definitions with tools. + /// Provides a way to customize the creation of the underlying used by the agent. + /// An optional to use for resolving services required by the instances being invoked. + /// A instance that can be used to perform operations based on the latest version of the named Azure AI Agent. + /// Thrown when or is . + /// The agent with the specified name was not found. + /// + /// When retrieving an agent by using an , minimal information will be available about the agent in the instance level, and any logic that relies + /// on to retrieve information about the agent like will receive as the result. + /// + public static ChatClientAgent GetAIAgent( + this AIProjectClient aiProjectClient, + AgentReference agentReference, + IList? tools = null, + Func? clientFactory = null, + IServiceProvider? services = null) + { + Throw.IfNull(aiProjectClient); + Throw.IfNull(agentReference); + ThrowIfInvalidAgentName(agentReference.Name); + + return CreateChatClientAgent( + aiProjectClient, + agentReference, + new ChatClientAgentOptions() + { + Id = $"{agentReference.Name}:{agentReference.Version}", + Name = agentReference.Name, + ChatOptions = new() { Tools = tools }, + }, + clientFactory, + services); + } + + /// + /// Retrieves an existing server side agent, wrapped as a using the provided . + /// + /// The to create the with. Cannot be . + /// The name of the server side agent to create a for. Cannot be or whitespace. + /// The tools to use when interacting with the agent. This is required when using prompt agent definitions with tools. + /// Provides a way to customize the creation of the underlying used by the agent. + /// An optional to use for resolving services required by the instances being invoked. + /// The to monitor for cancellation requests. The default is . + /// A instance that can be used to perform operations based on the latest version of the named Azure AI Agent. + /// Thrown when or is . + /// Thrown when is empty or whitespace, or when the agent with the specified name was not found. + /// The agent with the specified name was not found. + public static ChatClientAgent GetAIAgent( + this AIProjectClient aiProjectClient, + string name, + IList? tools = null, + Func? clientFactory = null, + IServiceProvider? services = null, + CancellationToken cancellationToken = default) + { + Throw.IfNull(aiProjectClient); + ThrowIfInvalidAgentName(name); + + AgentRecord agentRecord = GetAgentRecordByName(aiProjectClient, name, cancellationToken); + + return GetAIAgent( + aiProjectClient, + agentRecord, + tools, + clientFactory, + services); + } + + /// + /// Asynchronously retrieves an existing server side agent, wrapped as a using the provided . + /// + /// The to create the with. Cannot be . + /// The name of the server side agent to create a for. Cannot be or whitespace. + /// The tools to use when interacting with the agent. This is required when using prompt agent definitions with tools. + /// Provides a way to customize the creation of the underlying used by the agent. + /// An optional to use for resolving services required by the instances being invoked. + /// The to monitor for cancellation requests. The default is . + /// A instance that can be used to perform operations based on the latest version of the named Azure AI Agent. + /// Thrown when or is . + /// Thrown when is empty or whitespace, or when the agent with the specified name was not found. + /// The agent with the specified name was not found. + public static async Task GetAIAgentAsync( + this AIProjectClient aiProjectClient, + string name, + IList? tools = null, + Func? clientFactory = null, + IServiceProvider? services = null, + CancellationToken cancellationToken = default) + { + Throw.IfNull(aiProjectClient); + ThrowIfInvalidAgentName(name); + + AgentRecord agentRecord = await GetAgentRecordByNameAsync(aiProjectClient, name, cancellationToken).ConfigureAwait(false); + + return GetAIAgent( + aiProjectClient, + agentRecord, + tools, + clientFactory, + services); + } + + /// + /// Gets a runnable agent instance from the provided agent record. + /// + /// The client used to interact with Azure AI Agents. Cannot be . + /// The agent record to be converted. The latest version will be used. Cannot be . + /// The tools to use when interacting with the agent. This is required when using prompt agent definitions with tools. + /// Provides a way to customize the creation of the underlying used by the agent. + /// An optional to use for resolving services required by the instances being invoked. + /// A instance that can be used to perform operations based on the latest version of the Azure AI Agent. + /// Thrown when or is . + public static ChatClientAgent GetAIAgent( + this AIProjectClient aiProjectClient, + AgentRecord agentRecord, + IList? tools = null, + Func? clientFactory = null, + IServiceProvider? services = null) + { + Throw.IfNull(aiProjectClient); + Throw.IfNull(agentRecord); + + var allowDeclarativeMode = tools is not { Count: > 0 }; + + return CreateChatClientAgent( + aiProjectClient, + agentRecord, + tools, + clientFactory, + !allowDeclarativeMode, + services); + } + + /// + /// Gets a runnable agent instance from a containing metadata about an Azure AI Agent. + /// + /// The client used to interact with Azure AI Agents. Cannot be . + /// The agent version to be converted. Cannot be . + /// In-process invocable tools to be provided. If no tools are provided manual handling will be necessary to invoke in-process tools. + /// Provides a way to customize the creation of the underlying used by the agent. + /// An optional to use for resolving services required by the instances being invoked. + /// A instance that can be used to perform operations based on the provided version of the Azure AI Agent. + /// Thrown when or is . + public static ChatClientAgent GetAIAgent( + this AIProjectClient aiProjectClient, + AgentVersion agentVersion, + IList? tools = null, + Func? clientFactory = null, + IServiceProvider? services = null) + { + Throw.IfNull(aiProjectClient); + Throw.IfNull(agentVersion); + + var allowDeclarativeMode = tools is not { Count: > 0 }; + + return CreateChatClientAgent( + aiProjectClient, + agentVersion, + tools, + clientFactory, + !allowDeclarativeMode, + services); + } + + /// + /// Creates a new Prompt AI Agent using the provided and options. + /// + /// The client used to manage and interact with AI agents. Cannot be . + /// The options for creating the agent. Cannot be . + /// A factory function to customize the creation of the chat client used by the agent. + /// An optional to use for resolving services required by the instances being invoked. + /// A to cancel the operation if needed. + /// A instance that can be used to perform operations on the newly created agent. + /// Thrown when or is . + public static ChatClientAgent GetAIAgent( + this AIProjectClient aiProjectClient, + ChatClientAgentOptions options, + Func? clientFactory = null, + IServiceProvider? services = null, + CancellationToken cancellationToken = default) + { + Throw.IfNull(aiProjectClient); + Throw.IfNull(options); + + if (string.IsNullOrWhiteSpace(options.Name)) + { + throw new ArgumentException("Agent name must be provided in the options.Name property", nameof(options)); + } + + ThrowIfInvalidAgentName(options.Name); + + AgentRecord agentRecord = GetAgentRecordByName(aiProjectClient, options.Name, cancellationToken); + var agentVersion = agentRecord.Versions.Latest; + + var agentOptions = CreateChatClientAgentOptions(agentVersion, options, requireInvocableTools: true); + + return CreateChatClientAgent( + aiProjectClient, + agentVersion, + agentOptions, + clientFactory, + services); + } + + /// + /// Creates a new Prompt AI Agent using the provided and options. + /// + /// The client used to manage and interact with AI agents. Cannot be . + /// The options for creating the agent. Cannot be . + /// A factory function to customize the creation of the chat client used by the agent. + /// An optional to use for resolving services required by the instances being invoked. + /// A to cancel the operation if needed. + /// A instance that can be used to perform operations on the newly created agent. + /// Thrown when or is . + public static async Task GetAIAgentAsync( + this AIProjectClient aiProjectClient, + ChatClientAgentOptions options, + Func? clientFactory = null, + IServiceProvider? services = null, + CancellationToken cancellationToken = default) + { + Throw.IfNull(aiProjectClient); + Throw.IfNull(options); + + if (string.IsNullOrWhiteSpace(options.Name)) + { + throw new ArgumentException("Agent name must be provided in the options.Name property", nameof(options)); + } + + ThrowIfInvalidAgentName(options.Name); + + AgentRecord agentRecord = await GetAgentRecordByNameAsync(aiProjectClient, options.Name, cancellationToken).ConfigureAwait(false); + var agentVersion = agentRecord.Versions.Latest; + + var agentOptions = CreateChatClientAgentOptions(agentVersion, options, requireInvocableTools: true); + + return CreateChatClientAgent( + aiProjectClient, + agentVersion, + agentOptions, + clientFactory, + services); + } + + /// + /// Creates a new Prompt AI agent using the specified configuration parameters. + /// + /// The client used to manage and interact with AI agents. Cannot be . + /// The name for the agent. + /// The name of the model to use for the agent. Cannot be or whitespace. + /// The instructions that guide the agent's behavior. Cannot be or whitespace. + /// The description for the agent. + /// The tools to use when interacting with the agent, this is required when using prompt agent definitions with tools. + /// A factory function to customize the creation of the chat client used by the agent. + /// An optional to use for resolving services required by the instances being invoked. + /// A token to monitor for cancellation requests. + /// A instance that can be used to perform operations on the newly created agent. + /// Thrown when , , or is . + /// Thrown when or is empty or whitespace. + /// When using prompt agent definitions with tools the parameter needs to be provided. + public static ChatClientAgent CreateAIAgent( + this AIProjectClient aiProjectClient, + string name, + string model, + string instructions, + string? description = null, + IList? tools = null, + Func? clientFactory = null, + IServiceProvider? services = null, + CancellationToken cancellationToken = default) + { + Throw.IfNull(aiProjectClient); + ThrowIfInvalidAgentName(name); + Throw.IfNullOrWhitespace(model); + Throw.IfNullOrWhitespace(instructions); + + return CreateAIAgent( + aiProjectClient, + name, + tools, + new AgentVersionCreationOptions(new PromptAgentDefinition(model) { Instructions = instructions }) { Description = description }, + clientFactory, + services, + cancellationToken); + } + + /// + /// Creates a new Prompt AI agent using the specified configuration parameters. + /// + /// The client used to manage and interact with AI agents. Cannot be . + /// The name for the agent. + /// The name of the model to use for the agent. Cannot be or whitespace. + /// The instructions that guide the agent's behavior. Cannot be or whitespace. + /// The description for the agent. + /// The tools to use when interacting with the agent, this is required when using prompt agent definitions with tools. + /// A factory function to customize the creation of the chat client used by the agent. + /// An optional to use for resolving services required by the instances being invoked. + /// A token to monitor for cancellation requests. + /// A instance that can be used to perform operations on the newly created agent. + /// Thrown when , , or is . + /// Thrown when or is empty or whitespace. + /// When using prompt agent definitions with tools the parameter needs to be provided. + public static Task CreateAIAgentAsync( + this AIProjectClient aiProjectClient, + string name, + string model, + string instructions, + string? description = null, + IList? tools = null, + Func? clientFactory = null, + IServiceProvider? services = null, + CancellationToken cancellationToken = default) + { + Throw.IfNull(aiProjectClient); + ThrowIfInvalidAgentName(name); + Throw.IfNullOrWhitespace(model); + Throw.IfNullOrWhitespace(instructions); + + return CreateAIAgentAsync( + aiProjectClient, + name, + tools, + new AgentVersionCreationOptions(new PromptAgentDefinition(model) { Instructions = instructions }) { Description = description }, + clientFactory, + services, + cancellationToken); + } + + /// + /// Creates a new Prompt AI Agent using the provided and options. + /// + /// The client used to manage and interact with AI agents. Cannot be . + /// The name of the model to use for the agent. Cannot be or whitespace. + /// The options for creating the agent. Cannot be . + /// A factory function to customize the creation of the chat client used by the agent. + /// An optional to use for resolving services required by the instances being invoked. + /// A to cancel the operation if needed. + /// A instance that can be used to perform operations on the newly created agent. + /// Thrown when or is . + /// Thrown when is empty or whitespace, or when the agent name is not provided in the options. + public static ChatClientAgent CreateAIAgent( + this AIProjectClient aiProjectClient, + string model, + ChatClientAgentOptions options, + Func? clientFactory = null, + IServiceProvider? services = null, + CancellationToken cancellationToken = default) + { + Throw.IfNull(aiProjectClient); + Throw.IfNull(options); + Throw.IfNullOrWhitespace(model); + const bool RequireInvocableTools = true; + + if (string.IsNullOrWhiteSpace(options.Name)) + { + throw new ArgumentException("Agent name must be provided in the options.Name property", nameof(options)); + } + + ThrowIfInvalidAgentName(options.Name); + + PromptAgentDefinition agentDefinition = new(model) + { + Instructions = options.Instructions, + Temperature = options.ChatOptions?.Temperature, + TopP = options.ChatOptions?.TopP, + TextOptions = new() { TextFormat = ToOpenAIResponseTextFormat(options.ChatOptions?.ResponseFormat, options.ChatOptions) } + }; + + // Attempt to capture breaking glass options from the raw representation factory that match the agent definition. + if (options.ChatOptions?.RawRepresentationFactory?.Invoke(new NoOpChatClient()) is ResponseCreationOptions respCreationOptions) + { + agentDefinition.ReasoningOptions = respCreationOptions.ReasoningOptions; + } + + ApplyToolsToAgentDefinition(agentDefinition, options.ChatOptions?.Tools); + + AgentVersionCreationOptions? creationOptions = new(agentDefinition); + if (!string.IsNullOrWhiteSpace(options.Description)) + { + creationOptions.Description = options.Description; + } + + AgentVersion agentVersion = CreateAgentVersionWithProtocol(aiProjectClient, options.Name, creationOptions, cancellationToken); + + var agentOptions = CreateChatClientAgentOptions(agentVersion, options, RequireInvocableTools); + + return CreateChatClientAgent( + aiProjectClient, + agentVersion, + agentOptions, + clientFactory, + services); + } + + /// + /// Creates a new Prompt AI Agent using the provided and options. + /// + /// The client used to manage and interact with AI agents. Cannot be . + /// The name of the model to use for the agent. Cannot be or whitespace. + /// The options for creating the agent. Cannot be . + /// A factory function to customize the creation of the chat client used by the agent. + /// An optional to use for resolving services required by the instances being invoked. + /// A to cancel the operation if needed. + /// A instance that can be used to perform operations on the newly created agent. + /// Thrown when or is . + /// Thrown when is empty or whitespace, or when the agent name is not provided in the options. + public static async Task CreateAIAgentAsync( + this AIProjectClient aiProjectClient, + string model, + ChatClientAgentOptions options, + Func? clientFactory = null, + IServiceProvider? services = null, + CancellationToken cancellationToken = default) + { + Throw.IfNull(aiProjectClient); + Throw.IfNull(options); + Throw.IfNullOrWhitespace(model); + const bool RequireInvocableTools = true; + + if (string.IsNullOrWhiteSpace(options.Name)) + { + throw new ArgumentException("Agent name must be provided in the options.Name property", nameof(options)); + } + + ThrowIfInvalidAgentName(options.Name); + + PromptAgentDefinition agentDefinition = new(model) + { + Instructions = options.Instructions, + Temperature = options.ChatOptions?.Temperature, + TopP = options.ChatOptions?.TopP, + TextOptions = new() { TextFormat = ToOpenAIResponseTextFormat(options.ChatOptions?.ResponseFormat, options.ChatOptions) } + }; + + // Attempt to capture breaking glass options from the raw representation factory that match the agent definition. + if (options.ChatOptions?.RawRepresentationFactory?.Invoke(new NoOpChatClient()) is ResponseCreationOptions respCreationOptions) + { + agentDefinition.ReasoningOptions = respCreationOptions.ReasoningOptions; + } + + ApplyToolsToAgentDefinition(agentDefinition, options.ChatOptions?.Tools); + + AgentVersionCreationOptions? creationOptions = new(agentDefinition); + if (!string.IsNullOrWhiteSpace(options.Description)) + { + creationOptions.Description = options.Description; + } + + AgentVersion agentVersion = await CreateAgentVersionWithProtocolAsync(aiProjectClient, options.Name, creationOptions, cancellationToken).ConfigureAwait(false); + + var agentOptions = CreateChatClientAgentOptions(agentVersion, options, RequireInvocableTools); + + return CreateChatClientAgent( + aiProjectClient, + agentVersion, + agentOptions, + clientFactory, + services); + } + + /// + /// Creates a new AI agent using the specified agent definition and optional configuration parameters. + /// + /// The client used to manage and interact with AI agents. Cannot be . + /// The name for the agent. + /// Settings that control the creation of the agent. + /// A factory function to customize the creation of the chat client used by the agent. + /// A token to monitor for cancellation requests. + /// A instance that can be used to perform operations on the newly created agent. + /// Thrown when or is . + /// + /// When using this extension method with a the tools are only declarative and not invocable. + /// Invocation of any in-process tools will need to be handled manually. + /// + public static ChatClientAgent CreateAIAgent( + this AIProjectClient aiProjectClient, + string name, + AgentVersionCreationOptions creationOptions, + Func? clientFactory = null, + CancellationToken cancellationToken = default) + { + Throw.IfNull(aiProjectClient); + ThrowIfInvalidAgentName(name); + Throw.IfNull(creationOptions); + + return CreateAIAgent( + aiProjectClient, + name, + tools: null, + creationOptions, + clientFactory, + services: null, + cancellationToken); + } + + /// + /// Asynchronously creates a new AI agent using the specified agent definition and optional configuration + /// parameters. + /// + /// The client used to manage and interact with AI agents. Cannot be . + /// The name for the agent. + /// Settings that control the creation of the agent. + /// A factory function to customize the creation of the chat client used by the agent. + /// A token to monitor for cancellation requests. + /// A instance that can be used to perform operations on the newly created agent. + /// Thrown when or is . + /// + /// When using this extension method with a the tools are only declarative and not invocable. + /// Invocation of any in-process tools will need to be handled manually. + /// + public static Task CreateAIAgentAsync( + this AIProjectClient aiProjectClient, + string name, + AgentVersionCreationOptions creationOptions, + Func? clientFactory = null, + CancellationToken cancellationToken = default) + { + Throw.IfNull(aiProjectClient); + ThrowIfInvalidAgentName(name); + Throw.IfNull(creationOptions); + + return CreateAIAgentAsync( + aiProjectClient, + name, + tools: null, + creationOptions, + clientFactory, + services: null, + cancellationToken); + } + + #region Private + + private static readonly ModelReaderWriterOptions s_modelWriterOptionsWire = new("W"); + + /// + /// Retrieves an agent record by name using the Protocol method with user-agent header. + /// + private static AgentRecord GetAgentRecordByName(AIProjectClient aiProjectClient, string agentName, CancellationToken cancellationToken) + { + ClientResult protocolResponse = aiProjectClient.Agents.GetAgent(agentName, cancellationToken.ToRequestOptions(false)); + var rawResponse = protocolResponse.GetRawResponse(); + AgentRecord? result = ModelReaderWriter.Read(rawResponse.Content, s_modelWriterOptionsWire, AzureAIProjectsOpenAIContext.Default); + return ClientResult.FromOptionalValue(result, rawResponse).Value! + ?? throw new InvalidOperationException($"Agent with name '{agentName}' not found."); + } + + /// + /// Asynchronously retrieves an agent record by name using the Protocol method with user-agent header. + /// + private static async Task GetAgentRecordByNameAsync(AIProjectClient aiProjectClient, string agentName, CancellationToken cancellationToken) + { + ClientResult protocolResponse = await aiProjectClient.Agents.GetAgentAsync(agentName, cancellationToken.ToRequestOptions(false)).ConfigureAwait(false); + var rawResponse = protocolResponse.GetRawResponse(); + AgentRecord? result = ModelReaderWriter.Read(rawResponse.Content, s_modelWriterOptionsWire, AzureAIProjectsOpenAIContext.Default); + return ClientResult.FromOptionalValue(result, rawResponse).Value! + ?? throw new InvalidOperationException($"Agent with name '{agentName}' not found."); + } + + /// + /// Creates an agent version using the Protocol method with user-agent header. + /// + private static AgentVersion CreateAgentVersionWithProtocol(AIProjectClient aiProjectClient, string agentName, AgentVersionCreationOptions creationOptions, CancellationToken cancellationToken) + { + using BinaryContent protocolRequest = BinaryContent.Create(ModelReaderWriter.Write(creationOptions, ModelReaderWriterOptions.Json, AzureAIProjectsContext.Default)); + ClientResult protocolResponse = aiProjectClient.Agents.CreateAgentVersion(agentName, protocolRequest, cancellationToken.ToRequestOptions(false)); + + var rawResponse = protocolResponse.GetRawResponse(); + AgentVersion? result = ModelReaderWriter.Read(rawResponse.Content, s_modelWriterOptionsWire, AzureAIProjectsOpenAIContext.Default); + return ClientResult.FromValue(result, rawResponse).Value!; + } + + /// + /// Asynchronously creates an agent version using the Protocol method with user-agent header. + /// + private static async Task CreateAgentVersionWithProtocolAsync(AIProjectClient aiProjectClient, string agentName, AgentVersionCreationOptions creationOptions, CancellationToken cancellationToken) + { + using BinaryContent protocolRequest = BinaryContent.Create(ModelReaderWriter.Write(creationOptions, ModelReaderWriterOptions.Json, AzureAIProjectsContext.Default)); + ClientResult protocolResponse = await aiProjectClient.Agents.CreateAgentVersionAsync(agentName, protocolRequest, cancellationToken.ToRequestOptions(false)).ConfigureAwait(false); + + var rawResponse = protocolResponse.GetRawResponse(); + AgentVersion? result = ModelReaderWriter.Read(rawResponse.Content, s_modelWriterOptionsWire, AzureAIProjectsOpenAIContext.Default); + return ClientResult.FromValue(result, rawResponse).Value!; + } + + private static ChatClientAgent CreateAIAgent( + this AIProjectClient aiProjectClient, + string name, + IList? tools, + AgentVersionCreationOptions creationOptions, + Func? clientFactory, + IServiceProvider? services, + CancellationToken cancellationToken) + { + var allowDeclarativeMode = tools is not { Count: > 0 }; + + if (!allowDeclarativeMode) + { + ApplyToolsToAgentDefinition(creationOptions.Definition, tools); + } + + AgentVersion agentVersion = CreateAgentVersionWithProtocol(aiProjectClient, name, creationOptions, cancellationToken); + + return CreateChatClientAgent( + aiProjectClient, + agentVersion, + tools, + clientFactory, + !allowDeclarativeMode, + services); + } + + private static async Task CreateAIAgentAsync( + this AIProjectClient aiProjectClient, + string name, + IList? tools, + AgentVersionCreationOptions creationOptions, + Func? clientFactory, + IServiceProvider? services, + CancellationToken cancellationToken) + { + var allowDeclarativeMode = tools is not { Count: > 0 }; + + if (!allowDeclarativeMode) + { + ApplyToolsToAgentDefinition(creationOptions.Definition, tools); + } + + AgentVersion agentVersion = await CreateAgentVersionWithProtocolAsync(aiProjectClient, name, creationOptions, cancellationToken).ConfigureAwait(false); + + return CreateChatClientAgent( + aiProjectClient, + agentVersion, + tools, + clientFactory, + !allowDeclarativeMode, + services); + } + + /// This method creates an with the specified ChatClientAgentOptions. + private static ChatClientAgent CreateChatClientAgent( + AIProjectClient aiProjectClient, + AgentVersion agentVersion, + ChatClientAgentOptions agentOptions, + Func? clientFactory, + IServiceProvider? services) + { + IChatClient chatClient = new AzureAIProjectChatClient(aiProjectClient, agentVersion, agentOptions.ChatOptions); + + if (clientFactory is not null) + { + chatClient = clientFactory(chatClient); + } + + return new ChatClientAgent(chatClient, agentOptions, services: services); + } + + /// This method creates an with the specified ChatClientAgentOptions. + private static ChatClientAgent CreateChatClientAgent( + AIProjectClient aiProjectClient, + AgentRecord agentRecord, + ChatClientAgentOptions agentOptions, + Func? clientFactory, + IServiceProvider? services) + { + IChatClient chatClient = new AzureAIProjectChatClient(aiProjectClient, agentRecord, agentOptions.ChatOptions); + + if (clientFactory is not null) + { + chatClient = clientFactory(chatClient); + } + + return new ChatClientAgent(chatClient, agentOptions, services: services); + } + + /// This method creates an with the specified ChatClientAgentOptions. + private static ChatClientAgent CreateChatClientAgent( + AIProjectClient aiProjectClient, + AgentReference agentReference, + ChatClientAgentOptions agentOptions, + Func? clientFactory, + IServiceProvider? services) + { + IChatClient chatClient = new AzureAIProjectChatClient(aiProjectClient, agentReference, defaultModelId: null, agentOptions.ChatOptions); + + if (clientFactory is not null) + { + chatClient = clientFactory(chatClient); + } + + return new ChatClientAgent(chatClient, agentOptions, services: services); + } + + /// This method creates an with a auto-generated ChatClientAgentOptions from the specified configuration parameters. + private static ChatClientAgent CreateChatClientAgent( + AIProjectClient AIProjectClient, + AgentVersion agentVersion, + IList? tools, + Func? clientFactory, + bool requireInvocableTools, + IServiceProvider? services) + => CreateChatClientAgent( + AIProjectClient, + agentVersion, + CreateChatClientAgentOptions(agentVersion, new ChatOptions() { Tools = tools }, requireInvocableTools), + clientFactory, + services); + + /// This method creates an with a auto-generated ChatClientAgentOptions from the specified configuration parameters. + private static ChatClientAgent CreateChatClientAgent( + AIProjectClient AIProjectClient, + AgentRecord agentRecord, + IList? tools, + Func? clientFactory, + bool requireInvocableTools, + IServiceProvider? services) + => CreateChatClientAgent( + AIProjectClient, + agentRecord, + CreateChatClientAgentOptions(agentRecord.Versions.Latest, new ChatOptions() { Tools = tools }, requireInvocableTools), + clientFactory, + services); + + /// + /// This method creates for the specified and the provided tools. + /// + /// The agent version. + /// The to use when interacting with the agent. + /// Indicates whether to enforce the presence of invocable tools when the AIAgent is created with an agent definition that uses them. + /// The created . + /// Thrown when the agent definition requires in-process tools but none were provided. + /// Thrown when the agent definition required tools were not provided. + /// + /// This method rebuilds the agent options from the agent definition returned by the version and combine with the in-proc tools when provided + /// this ensures that all required tools are provided and the definition of the agent options are consistent with the agent definition coming from the server. + /// + private static ChatClientAgentOptions CreateChatClientAgentOptions(AgentVersion agentVersion, ChatOptions? chatOptions, bool requireInvocableTools) + { + var agentDefinition = agentVersion.Definition; + + List? agentTools = null; + if (agentDefinition is PromptAgentDefinition { Tools: { Count: > 0 } definitionTools }) + { + // Check if no tools were provided while the agent definition requires in-proc tools. + if (requireInvocableTools && chatOptions?.Tools is not { Count: > 0 } && definitionTools.Any(t => t is FunctionTool)) + { + throw new ArgumentException("The agent definition in-process tools must be provided in the extension method tools parameter."); + } + + // Agregate all missing tools for a single error message. + List? missingTools = null; + + // Check function tools + foreach (ResponseTool responseTool in definitionTools) + { + if (requireInvocableTools && responseTool is FunctionTool functionTool) + { + // Check if a tool with the same type and name exists in the provided tools. + // When invocable tools are required, match only AIFunction. + var matchingTool = chatOptions?.Tools?.FirstOrDefault(t => t is AIFunction tf && functionTool.FunctionName == tf.Name); + + if (matchingTool is null) + { + (missingTools ??= []).Add($"Function tool: {functionTool.FunctionName}"); + } + else + { + (agentTools ??= []).Add(matchingTool!); + } + continue; + } + + (agentTools ??= []).Add(responseTool.AsAITool()); + } + + if (requireInvocableTools && missingTools is { Count: > 0 }) + { + throw new InvalidOperationException($"The following prompt agent definition required tools were not provided: {string.Join(", ", missingTools)}"); + } + } + + var agentOptions = new ChatClientAgentOptions() + { + Id = agentVersion.Id, + Name = agentVersion.Name, + Description = agentVersion.Description, + }; + + if (agentDefinition is PromptAgentDefinition promptAgentDefinition) + { + agentOptions.ChatOptions ??= chatOptions?.Clone() ?? new(); + agentOptions.Instructions = promptAgentDefinition.Instructions; + agentOptions.ChatOptions.Temperature = promptAgentDefinition.Temperature; + agentOptions.ChatOptions.TopP = promptAgentDefinition.TopP; + agentOptions.ChatOptions.Instructions = promptAgentDefinition.Instructions; + } + + if (agentTools is { Count: > 0 }) + { + agentOptions.ChatOptions ??= chatOptions?.Clone() ?? new(); + agentOptions.ChatOptions.Tools = agentTools; + } + + return agentOptions; + } + + /// + /// Creates a new instance of configured for the specified agent version and + /// optional base options. + /// + /// The agent version to use when configuring the chat client agent options. + /// An optional instance whose relevant properties will be copied to the + /// returned options. If , only default values are used. + /// Specifies whether the returned options must include invocable tools. Set to to require + /// invocable tools; otherwise, . + /// A instance configured according to the specified parameters. + private static ChatClientAgentOptions CreateChatClientAgentOptions(AgentVersion agentVersion, ChatClientAgentOptions? options, bool requireInvocableTools) + { + var agentOptions = CreateChatClientAgentOptions(agentVersion, options?.ChatOptions, requireInvocableTools); + if (options is not null) + { + agentOptions.AIContextProviderFactory = options.AIContextProviderFactory; + agentOptions.ChatMessageStoreFactory = options.ChatMessageStoreFactory; + agentOptions.UseProvidedChatClientAsIs = options.UseProvidedChatClientAsIs; + } + + return agentOptions; + } + + /// + /// Adds the specified AI tools to a prompt agent definition, while also ensuring that all invocable tools are provided. + /// + /// The agent definition to which the tools will be applied. Must be a PromptAgentDefinition to support tools. + /// A list of AI tools to add to the agent definition. If null or empty, no tools are added. + /// Thrown if tools were provided but is not a . + /// When providing functions, they need to be invokable AIFunctions. + private static void ApplyToolsToAgentDefinition(AgentDefinition agentDefinition, IList? tools) + { + if (tools is { Count: > 0 }) + { + if (agentDefinition is not PromptAgentDefinition promptAgentDefinition) + { + throw new ArgumentException("Only prompt agent definitions support tools.", nameof(agentDefinition)); + } + + // When tools are provided, those should represent the complete set of tools for the agent definition. + // This is particularly important for existing agents so no duplication happens for what was already defined. + promptAgentDefinition.Tools.Clear(); + + foreach (var tool in tools) + { + // Ensure that any AIFunctions provided are In-Proc, not just the declarations. + if (tool is not AIFunction && ( + tool.GetService() is not null // Declarative FunctionTool converted as AsAITool() + || tool is AIFunctionDeclaration)) // AIFunctionDeclaration type + { + throw new InvalidOperationException("When providing functions, they need to be invokable AIFunctions. AIFunctions can be created correctly using AIFunctionFactory.Create"); + } + + promptAgentDefinition.Tools.Add( + // If this is a converted ResponseTool as AITool, we can directly retrieve the ResponseTool instance from GetService. + tool.GetService() + // Otherwise we should be able to convert existing MEAI Tool abstractions into OpenAI ResponseTools + ?? tool.AsOpenAIResponseTool() + ?? throw new InvalidOperationException("The provided AITool could not be converted to a ResponseTool, ensure that the AITool was created using responseTool.AsAITool() extension.")); + } + } + } + + private static ResponseTextFormat? ToOpenAIResponseTextFormat(ChatResponseFormat? format, ChatOptions? options = null) => + format switch + { + ChatResponseFormatText => ResponseTextFormat.CreateTextFormat(), + + ChatResponseFormatJson jsonFormat when StrictSchemaTransformCache.GetOrCreateTransformedSchema(jsonFormat) is { } jsonSchema => + ResponseTextFormat.CreateJsonSchemaFormat( + jsonFormat.SchemaName ?? "json_schema", + BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(jsonSchema, AgentClientJsonContext.Default.JsonElement)), + jsonFormat.SchemaDescription, + HasStrict(options?.AdditionalProperties)), + + ChatResponseFormatJson => ResponseTextFormat.CreateJsonObjectFormat(), + + _ => null, + }; + + /// Key into AdditionalProperties used to store a strict option. + private const string StrictKey = "strictJsonSchema"; + + /// Gets whether the properties specify that strict schema handling is desired. + private static bool? HasStrict(IReadOnlyDictionary? additionalProperties) => + additionalProperties?.TryGetValue(StrictKey, out object? strictObj) is true && + strictObj is bool strictValue ? + strictValue : null; + + /// + /// Gets the JSON schema transformer cache conforming to OpenAI strict / structured output restrictions per + /// https://platform.openai.com/docs/guides/structured-outputs?api-mode=responses#supported-schemas. + /// + private static AIJsonSchemaTransformCache StrictSchemaTransformCache { get; } = new(new() + { + DisallowAdditionalProperties = true, + ConvertBooleanSchemas = true, + MoveDefaultKeywordToDescription = true, + RequireAllProperties = true, + TransformSchemaNode = (ctx, node) => + { + // Move content from common but unsupported properties to description. In particular, we focus on properties that + // the AIJsonUtilities schema generator might produce and/or that are explicitly mentioned in the OpenAI documentation. + + if (node is JsonObject schemaObj) + { + StringBuilder? additionalDescription = null; + + ReadOnlySpan unsupportedProperties = + [ + // Produced by AIJsonUtilities but not in allow list at https://platform.openai.com/docs/guides/structured-outputs#supported-properties: + "contentEncoding", "contentMediaType", "not", + + // Explicitly mentioned at https://platform.openai.com/docs/guides/structured-outputs?api-mode=responses#key-ordering as being unsupported with some models: + "minLength", "maxLength", "pattern", "format", + "minimum", "maximum", "multipleOf", + "patternProperties", + "minItems", "maxItems", + + // Explicitly mentioned at https://learn.microsoft.com/azure/ai-services/openai/how-to/structured-outputs?pivots=programming-language-csharp&tabs=python-secure%2Cdotnet-entra-id#unsupported-type-specific-keywords + // as being unsupported with Azure OpenAI: + "unevaluatedProperties", "propertyNames", "minProperties", "maxProperties", + "unevaluatedItems", "contains", "minContains", "maxContains", "uniqueItems", + ]; + + foreach (string propName in unsupportedProperties) + { + if (schemaObj[propName] is { } propNode) + { + _ = schemaObj.Remove(propName); + AppendLine(ref additionalDescription, propName, propNode); + } + } + + if (additionalDescription is not null) + { + schemaObj["description"] = schemaObj["description"] is { } descriptionNode && descriptionNode.GetValueKind() == JsonValueKind.String ? + $"{descriptionNode.GetValue()}{Environment.NewLine}{additionalDescription}" : + additionalDescription.ToString(); + } + + return node; + + static void AppendLine(ref StringBuilder? sb, string propName, JsonNode propNode) + { + sb ??= new(); + + if (sb.Length > 0) + { + _ = sb.AppendLine(); + } + + _ = sb.Append(propName).Append(": ").Append(propNode); + } + } + + return node; + }, + }); + + /// + /// This class is a no-op implementation of to be used to honor the argument passed + /// while triggering avoiding any unexpected exception on the caller implementation. + /// + private sealed class NoOpChatClient : IChatClient + { + public void Dispose() { } + + public Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + => Task.FromResult(new ChatResponse()); + + public object? GetService(Type serviceType, object? serviceKey = null) => null; + + public async IAsyncEnumerable GetStreamingResponseAsync(IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + yield return new ChatResponseUpdate(); + } + } + #endregion + +#if NET + [GeneratedRegex("^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$")] + private static partial Regex AgentNameValidationRegex(); +#else + private static Regex AgentNameValidationRegex() => new("^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$"); +#endif + + private static string ThrowIfInvalidAgentName(string? name) + { + Throw.IfNullOrWhitespace(name); + if (!AgentNameValidationRegex().IsMatch(name)) + { + throw new ArgumentException("Agent name must be 1-63 characters long, start and end with an alphanumeric character, and can only contain alphanumeric characters or hyphens.", nameof(name)); + } + return name; + } +} + +[JsonSerializable(typeof(JsonElement))] +internal sealed partial class AgentClientJsonContext : JsonSerializerContext; diff --git a/dotnet/src/Microsoft.Agents.AI.AzureAI/Microsoft.Agents.AI.AzureAI.csproj b/dotnet/src/Microsoft.Agents.AI.AzureAI/Microsoft.Agents.AI.AzureAI.csproj new file mode 100644 index 0000000000..ff9a1c38fa --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.AzureAI/Microsoft.Agents.AI.AzureAI.csproj @@ -0,0 +1,31 @@ + + + + $(ProjectsTargetFrameworks) + $(ProjectsDebugTargetFrameworks) + preview + enable + true + + + + + + + + + + + + + + + + + + + Microsoft Agent Framework for Foundry Agents + Provides Microsoft Agent Framework support for Foundry Agents. + + + diff --git a/dotnet/src/Microsoft.Agents.AI.AzureAI/RequestOptionsExtensions.cs b/dotnet/src/Microsoft.Agents.AI.AzureAI/RequestOptionsExtensions.cs new file mode 100644 index 0000000000..722d316330 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.AzureAI/RequestOptionsExtensions.cs @@ -0,0 +1,67 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.ClientModel.Primitives; +using System.Reflection; + +namespace Microsoft.Agents.AI; + +internal static class RequestOptionsExtensions +{ + /// Creates a configured for use with Foundry Agents. + public static RequestOptions ToRequestOptions(this CancellationToken cancellationToken, bool streaming) + { + RequestOptions requestOptions = new() + { + CancellationToken = cancellationToken, + BufferResponse = !streaming + }; + + requestOptions.AddPolicy(MeaiUserAgentPolicy.Instance, PipelinePosition.PerCall); + + return requestOptions; + } + + /// Provides a pipeline policy that adds a "MEAI/x.y.z" user-agent header. + private sealed class MeaiUserAgentPolicy : PipelinePolicy + { + public static MeaiUserAgentPolicy Instance { get; } = new MeaiUserAgentPolicy(); + + private static readonly string s_userAgentValue = CreateUserAgentValue(); + + public override void Process(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + AddUserAgentHeader(message); + ProcessNext(message, pipeline, currentIndex); + } + + public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + AddUserAgentHeader(message); + return ProcessNextAsync(message, pipeline, currentIndex); + } + + private static void AddUserAgentHeader(PipelineMessage message) => + message.Request.Headers.Add("User-Agent", s_userAgentValue); + + private static string CreateUserAgentValue() + { + const string Name = "MEAI"; + + if (typeof(MeaiUserAgentPolicy).Assembly.GetCustomAttribute()?.InformationalVersion is string version) + { + int pos = version.IndexOf('+'); + if (pos >= 0) + { + version = version.Substring(0, pos); + } + + if (version.Length > 0) + { + return $"{Name}/{version}"; + } + } + + return Name; + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/AIAgentExtensions.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/AIAgentExtensions.cs index 4f5619ac76..5eac1b84e0 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/AIAgentExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/AIAgentExtensions.cs @@ -17,10 +17,14 @@ public static class AIAgentExtensions /// The service provider. /// The durable agent proxy. /// - /// Thrown when the agent is a DurableAIAgent instance or if the agent has no name. + /// Thrown when the agent is a instance or if the agent has no name. /// /// - /// Thrown if does not contain an . + /// Thrown if does not contain an + /// or if durable agents have not been configured on the service collection. + /// + /// + /// Thrown when the agent with the specified name has not been registered. /// public static AIAgent AsDurableAgentProxy(this AIAgent agent, IServiceProvider services) { @@ -33,6 +37,10 @@ public static class AIAgentExtensions } string agentName = agent.Name ?? throw new ArgumentException("Agent must have a name.", nameof(agent)); + + // Validate that the agent is registered + ServiceCollectionExtensions.ValidateAgentIsRegistered(services, agentName); + IDurableAgentClient agentClient = services.GetRequiredService(); return new DurableAIAgentProxy(agentName, agentClient); } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/AgentNotRegisteredException.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/AgentNotRegisteredException.cs new file mode 100644 index 0000000000..fc051fa0b2 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/AgentNotRegisteredException.cs @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.DurableTask; + +/// +/// Exception thrown when an agent with the specified name has not been registered. +/// +public sealed class AgentNotRegisteredException : InvalidOperationException +{ + // Not used, but required by static analysis. + private AgentNotRegisteredException() + { + this.AgentName = string.Empty; + } + + /// + /// Initializes a new instance of the class with the agent name. + /// + /// The name of the agent that was not registered. + public AgentNotRegisteredException(string agentName) + : base(GetMessage(agentName)) + { + this.AgentName = agentName; + } + + /// + /// Initializes a new instance of the class with the agent name and an inner exception. + /// + /// The name of the agent that was not registered. + /// The exception that is the cause of the current exception. + public AgentNotRegisteredException(string agentName, Exception? innerException) + : base(GetMessage(agentName), innerException) + { + this.AgentName = agentName; + } + + /// + /// Gets the name of the agent that was not registered. + /// + public string AgentName { get; } + + private static string GetMessage(string agentName) + { + ArgumentException.ThrowIfNullOrEmpty(agentName); + return $"No agent named '{agentName}' was registered. Ensure the agent is registered using {nameof(ServiceCollectionExtensions.ConfigureDurableAgents)} before using it in an orchestration."; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAIAgent.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAIAgent.cs index 52907c5816..1a117aff14 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAIAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAIAgent.cs @@ -5,6 +5,7 @@ using System.Runtime.CompilerServices; using System.Text.Json; using System.Text.Json.Serialization.Metadata; using Microsoft.DurableTask; +using Microsoft.DurableTask.Entities; using Microsoft.Extensions.AI; namespace Microsoft.Agents.AI.DurableTask; @@ -59,6 +60,9 @@ public sealed class DurableAIAgent : AIAgent /// Optional run options. /// The cancellation token. /// The response from the agent. + /// Thrown when the agent has not been registered. + /// Thrown when the provided thread is not valid for a durable agent. + /// Thrown when cancellation is requested (cancellation is not supported for durable agents). public override async Task RunAsync( IEnumerable messages, AgentThread? thread = null, @@ -95,7 +99,17 @@ public sealed class DurableAIAgent : AIAgent } RunRequest request = new([.. messages], responseFormat, enableToolCalls, enableToolNames); - return await this._context.Entities.CallEntityAsync(durableThread.SessionId, nameof(AgentEntity.RunAgentAsync), request); + try + { + return await this._context.Entities.CallEntityAsync( + durableThread.SessionId, + nameof(AgentEntity.RunAgentAsync), + request); + } + catch (EntityOperationFailedException e) when (e.FailureDetails.ErrorType == "EntityTaskNotFound") + { + throw new AgentNotRegisteredException(this._agentName, e); + } } /// diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/ServiceCollectionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/ServiceCollectionExtensions.cs index c611206d6a..2f435e0541 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/ServiceCollectionExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/ServiceCollectionExtensions.cs @@ -80,7 +80,7 @@ public static class ServiceCollectionExtensions DurableAgentsOptions options = new(); configure(options); - var agents = options.GetAgentFactories(); + IReadOnlyDictionary> agents = options.GetAgentFactories(); // The agent dictionary contains the real agent factories, which is used by the agent entities. services.AddSingleton(agents); @@ -98,6 +98,30 @@ public static class ServiceCollectionExtensions return options; } + /// + /// Validates that an agent with the specified name has been registered. + /// + /// The service provider. + /// The name of the agent to validate. + /// + /// Thrown when the agent dictionary is not registered in the service provider. + /// + /// + /// Thrown when the agent with the specified name has not been registered. + /// + internal static void ValidateAgentIsRegistered(IServiceProvider services, string agentName) + { + IReadOnlyDictionary>? agents = + services.GetService>>() + ?? throw new InvalidOperationException( + $"Durable agents have not been configured. Ensure {nameof(ConfigureDurableAgents)} has been called on the service collection."); + + if (!agents.ContainsKey(agentName)) + { + throw new AgentNotRegisteredException(agentName); + } + } + private sealed class DefaultDataConverter : DataConverter { // Use durable agent options (web defaults + camel case by default) with case-insensitive matching. diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableTaskClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableTaskClientExtensions.cs index 7628a3f3bf..0977d756cb 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableTaskClientExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableTaskClientExtensions.cs @@ -21,6 +21,12 @@ public static class DurableTaskClientExtensions /// A durable agent proxy. /// Thrown when or is null. /// Thrown when is null or empty. + /// + /// Thrown when durable agents have not been configured on the service collection. + /// + /// + /// Thrown when the agent has not been registered. + /// public static AIAgent AsDurableAgentProxy( this DurableTaskClient durableClient, FunctionContext context, @@ -30,6 +36,9 @@ public static class DurableTaskClientExtensions ArgumentNullException.ThrowIfNull(context); ArgumentException.ThrowIfNullOrEmpty(agentName); + // Validate that the agent is registered + DurableTask.ServiceCollectionExtensions.ValidateAgentIsRegistered(context.InstanceServices, agentName); + DefaultDurableAgentClient agentClient = ActivatorUtilities.CreateInstance( context.InstanceServices, durableClient); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.AzureAI/AzureAgentProvider.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.AzureAI/AzureAgentProvider.cs new file mode 100644 index 0000000000..c4a613901c --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.AzureAI/AzureAgentProvider.cs @@ -0,0 +1,264 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Net.Http; +using System.Runtime.CompilerServices; +using System.Text.Json.Nodes; +using System.Threading; +using System.Threading.Tasks; +using Azure.AI.Projects; +using Azure.AI.Projects.OpenAI; +using Azure.Core; +using Microsoft.Extensions.AI; +using OpenAI.Responses; + +namespace Microsoft.Agents.AI.Workflows.Declarative; + +/// +/// Provides functionality to interact with Foundry agents within a specified project context. +/// +/// This class is used to retrieve and manage AI agents associated with a Foundry project. It requires a +/// project endpoint and credentials to authenticate requests. +/// A instance representing the endpoint URL of the Foundry project. This must be a valid, non-null URI pointing to the project. +/// The credentials used to authenticate with the Foundry project. This must be a valid instance of . +public sealed class AzureAgentProvider(Uri projectEndpoint, TokenCredential projectCredentials) : WorkflowAgentProvider +{ + private readonly Dictionary _versionCache = []; + private readonly Dictionary _agentCache = []; + + private AIProjectClient? _agentClient; + private ProjectConversationsClient? _conversationClient; + + /// + /// Optional options used when creating the . + /// + public AIProjectClientOptions? AIProjectClientOptions { get; init; } + + /// + /// Optional options used when invoking the . + /// + public ProjectOpenAIClientOptions? OpenAIClientOptions { get; init; } + + /// + /// An optional instance to be used for making HTTP requests. + /// If not provided, a default client will be used. + /// + public HttpClient? HttpClient { get; init; } + + /// + public override async Task CreateConversationAsync(CancellationToken cancellationToken = default) + { + ProjectConversation conversation = + await this.GetConversationClient() + .CreateProjectConversationAsync(options: null, cancellationToken).ConfigureAwait(false); + + return conversation.Id; + } + + /// + public override async Task CreateMessageAsync(string conversationId, ChatMessage conversationMessage, CancellationToken cancellationToken = default) + { + ReadOnlyCollection newItems = + await this.GetConversationClient().CreateProjectConversationItemsAsync( + conversationId, + items: GetResponseItems(), + include: null, + cancellationToken).ConfigureAwait(false); + + return newItems.AsChatMessages().Single(); + + IEnumerable GetResponseItems() + { + IEnumerable messages = [conversationMessage]; + + foreach (ResponseItem item in messages.AsOpenAIResponseItems()) + { + if (string.IsNullOrEmpty(item.Id)) + { + yield return item; + } + else + { + yield return new ReferenceResponseItem(item.Id); + } + } + } + } + + /// + public override async IAsyncEnumerable InvokeAgentAsync( + string agentId, + string? agentVersion, + string? conversationId, + IEnumerable? messages, + IDictionary? inputArguments, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + AgentVersion agentVersionResult = await this.QueryAgentAsync(agentId, agentVersion, cancellationToken).ConfigureAwait(false); + AIAgent agent = await this.GetAgentAsync(agentVersionResult, cancellationToken).ConfigureAwait(false); + + ChatOptions chatOptions = + new() + { + ConversationId = conversationId, + AllowMultipleToolCalls = this.AllowMultipleToolCalls, + }; + + if (inputArguments is not null) + { + JsonNode jsonNode = ConvertDictionaryToJson(inputArguments); + ResponseCreationOptions responseCreationOptions = new(); +#pragma warning disable SCME0001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + responseCreationOptions.Patch.Set("$.structured_inputs"u8, BinaryData.FromString(jsonNode.ToJsonString())); +#pragma warning restore SCME0001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + chatOptions.RawRepresentationFactory = (_) => responseCreationOptions; + } + + ChatClientAgentRunOptions runOptions = new(chatOptions); + + IAsyncEnumerable agentResponse = + messages is not null ? + agent.RunStreamingAsync([.. messages], null, runOptions, cancellationToken) : + agent.RunStreamingAsync([new ChatMessage(ChatRole.User, string.Empty)], null, runOptions, cancellationToken); + + await foreach (AgentRunResponseUpdate update in agentResponse.ConfigureAwait(false)) + { + update.AuthorName = agentVersionResult.Name; + yield return update; + } + } + + private async Task QueryAgentAsync(string agentName, string? agentVersion, CancellationToken cancellationToken = default) + { + string agentKey = $"{agentName}:{agentVersion}"; + if (this._versionCache.TryGetValue(agentKey, out AgentVersion? targetAgent)) + { + return targetAgent; + } + + AIProjectClient client = this.GetAgentClient(); + + if (string.IsNullOrEmpty(agentVersion)) + { + AgentRecord agentRecord = + await client.Agents.GetAgentAsync( + agentName, + cancellationToken).ConfigureAwait(false); + + targetAgent = agentRecord.Versions.Latest; + } + else + { + targetAgent = + await client.Agents.GetAgentVersionAsync( + agentName, + agentVersion, + cancellationToken).ConfigureAwait(false); + } + + this._versionCache[agentKey] = targetAgent; + + return targetAgent; + } + + private async Task GetAgentAsync(AgentVersion agentVersion, CancellationToken cancellationToken = default) + { + if (this._agentCache.TryGetValue(agentVersion.Id, out AIAgent? agent)) + { + return agent; + } + + AIProjectClient client = this.GetAgentClient(); + + agent = client.GetAIAgent(agentVersion, tools: null, clientFactory: null, services: null); + + FunctionInvokingChatClient? functionInvokingClient = agent.GetService(); + if (functionInvokingClient is not null) + { + // Allow concurrent invocations if configured + functionInvokingClient.AllowConcurrentInvocation = this.AllowConcurrentInvocation; + // Allows the caller to respond with function responses + functionInvokingClient.TerminateOnUnknownCalls = true; + // Make functions available for execution. Doesn't change what tool is available for any given agent. + if (this.Functions is not null) + { + if (functionInvokingClient.AdditionalTools is null) + { + functionInvokingClient.AdditionalTools = [.. this.Functions]; + } + else + { + functionInvokingClient.AdditionalTools = [.. functionInvokingClient.AdditionalTools, .. this.Functions]; + } + } + } + + this._agentCache[agentVersion.Id] = agent; + + return agent; + } + + /// + public override async Task GetMessageAsync(string conversationId, string messageId, CancellationToken cancellationToken = default) + { + AgentResponseItem responseItem = await this.GetConversationClient().GetProjectConversationItemAsync(conversationId, messageId, include: null, cancellationToken).ConfigureAwait(false); + ResponseItem[] items = [responseItem.AsOpenAIResponseItem()]; + return items.AsChatMessages().Single(); + } + + /// + public override async IAsyncEnumerable GetMessagesAsync( + string conversationId, + int? limit = null, + string? after = null, + string? before = null, + bool newestFirst = false, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + AgentListOrder order = newestFirst ? AgentListOrder.Ascending : AgentListOrder.Descending; + + await foreach (AgentResponseItem responseItem in this.GetConversationClient().GetProjectConversationItemsAsync(conversationId, null, limit, order.ToString(), after, before, include: null, cancellationToken).ConfigureAwait(false)) + { + ResponseItem[] items = [responseItem.AsOpenAIResponseItem()]; + foreach (ChatMessage message in items.AsChatMessages()) + { + yield return message; + } + } + } + + private AIProjectClient GetAgentClient() + { + if (this._agentClient is null) + { + AIProjectClientOptions clientOptions = this.AIProjectClientOptions ?? new(); + + if (this.HttpClient is not null) + { + clientOptions.Transport = new HttpClientPipelineTransport(this.HttpClient); + } + + AIProjectClient newClient = new(projectEndpoint, projectCredentials, clientOptions); + + Interlocked.CompareExchange(ref this._agentClient, newClient, null); + } + + return this._agentClient; + } + + private ProjectConversationsClient GetConversationClient() + { + if (this._conversationClient is null) + { + ProjectConversationsClient conversationClient = this.GetAgentClient().GetProjectOpenAIClient().GetProjectConversationsClient(); + + Interlocked.CompareExchange(ref this._conversationClient, conversationClient, null); + } + + return this._conversationClient; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.AzureAI/Microsoft.Agents.AI.Workflows.Declarative.AzureAI.csproj b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.AzureAI/Microsoft.Agents.AI.Workflows.Declarative.AzureAI.csproj new file mode 100644 index 0000000000..c43c28aaf4 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.AzureAI/Microsoft.Agents.AI.Workflows.Declarative.AzureAI.csproj @@ -0,0 +1,41 @@ + + + + $(ProjectsTargetFrameworks) + $(ProjectsDebugTargetFrameworks) + preview + $(NoWarn);MEAI001;OPENAI001 + + + + true + true + true + + + + + + + Microsoft Agent Framework Declarative Workflows Azure AI + Provides Microsoft Agent Framework support for declarative workflows for Azure AI Agents. + + + + + + + + + + + + + + + + + + + + diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/AzureAgentProvider.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/AzureAgentProvider.cs deleted file mode 100644 index ac44890c1c..0000000000 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/AzureAgentProvider.cs +++ /dev/null @@ -1,214 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Collections.Generic; -using System.Linq; -using System.Net.Http; -using System.Runtime.CompilerServices; -using System.Threading; -using System.Threading.Tasks; -using Azure.AI.Agents.Persistent; -using Azure.Core; -using Azure.Core.Pipeline; -using Microsoft.Extensions.AI; - -namespace Microsoft.Agents.AI.Workflows.Declarative; - -/// -/// Provides functionality to interact with Foundry agents within a specified project context. -/// -/// This class is used to retrieve and manage AI agents associated with a Foundry project. It requires a -/// project endpoint and credentials to authenticate requests. -/// The endpoint URL of the Foundry project. This must be a valid, non-null URI pointing to the project. -/// The credentials used to authenticate with the Foundry project. This must be a valid instance of . -/// An optional instance to be used for making HTTP requests. If not provided, a default client will be used. -public sealed class AzureAgentProvider(string projectEndpoint, TokenCredential projectCredentials, HttpClient? httpClient = null) : WorkflowAgentProvider -{ - private static readonly Dictionary s_roleMap = - new() - { - [ChatRole.User.Value.ToUpperInvariant()] = MessageRole.User, - [ChatRole.Assistant.Value.ToUpperInvariant()] = MessageRole.Agent, - [ChatRole.System.Value.ToUpperInvariant()] = new MessageRole(ChatRole.System.Value), - [ChatRole.Tool.Value.ToUpperInvariant()] = new MessageRole(ChatRole.Tool.Value), - }; - - private PersistentAgentsClient? _agentsClient; - - /// - public override async Task CreateConversationAsync(CancellationToken cancellationToken = default) - { - PersistentAgentThread conversation = - await this.GetAgentsClient().Threads.CreateThreadAsync( - messages: null, - toolResources: null, - metadata: null, - cancellationToken).ConfigureAwait(false); - - return conversation.Id; - } - - /// - public override async Task CreateMessageAsync(string conversationId, ChatMessage conversationMessage, CancellationToken cancellationToken = default) - { - PersistentThreadMessage newMessage = - await this.GetAgentsClient().Messages.CreateMessageAsync( - conversationId, - role: s_roleMap[conversationMessage.Role.Value.ToUpperInvariant()], - contentBlocks: GetContent(), - attachments: null, - metadata: GetMetadata(), - cancellationToken).ConfigureAwait(false); - - return ToChatMessage(newMessage); - - Dictionary? GetMetadata() - { - if (conversationMessage.AdditionalProperties is null) - { - return null; - } - - return conversationMessage.AdditionalProperties.ToDictionary(prop => prop.Key, prop => prop.Value?.ToString() ?? string.Empty); - } - - IEnumerable GetContent() - { - foreach (AIContent content in conversationMessage.Contents) - { - MessageInputContentBlock? contentBlock = - content switch - { - TextContent textContent => new MessageInputTextBlock(textContent.Text), - HostedFileContent fileContent => new MessageInputImageFileBlock(new MessageImageFileParam(fileContent.FileId)), - UriContent uriContent when uriContent.Uri is not null => new MessageInputImageUriBlock(new MessageImageUriParam(uriContent.Uri.ToString())), - DataContent dataContent when dataContent.Uri is not null => new MessageInputImageUriBlock(new MessageImageUriParam(dataContent.Uri)), - _ => null // Unsupported content type - }; - - if (contentBlock is not null) - { - yield return contentBlock; - } - } - } - } - - /// - public override async Task GetAgentAsync(string agentId, CancellationToken cancellationToken = default) - { - ChatClientAgent agent = - await this.GetAgentsClient().GetAIAgentAsync( - agentId, - new ChatOptions() - { - AllowMultipleToolCalls = this.AllowMultipleToolCalls, - }, - clientFactory: null, - cancellationToken).ConfigureAwait(false); - - FunctionInvokingChatClient? functionInvokingClient = agent.GetService(); - if (functionInvokingClient is not null) - { - // Allow concurrent invocations if configured - functionInvokingClient.AllowConcurrentInvocation = this.AllowConcurrentInvocation; - // Allows the caller to respond with function responses - functionInvokingClient.TerminateOnUnknownCalls = true; - // Make functions available for execution. Doesn't change what tool is available for any given agent. - if (this.Functions is not null) - { - if (functionInvokingClient.AdditionalTools is null) - { - functionInvokingClient.AdditionalTools = [.. this.Functions]; - } - else - { - functionInvokingClient.AdditionalTools = [.. functionInvokingClient.AdditionalTools, .. this.Functions]; - } - } - } - - return agent; - } - - /// - public override async Task GetMessageAsync(string conversationId, string messageId, CancellationToken cancellationToken = default) - { - PersistentThreadMessage message = await this.GetAgentsClient().Messages.GetMessageAsync(conversationId, messageId, cancellationToken).ConfigureAwait(false); - return ToChatMessage(message); - } - - /// - public override async IAsyncEnumerable GetMessagesAsync( - string conversationId, - int? limit = null, - string? after = null, - string? before = null, - bool newestFirst = false, - [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - ListSortOrder order = newestFirst ? ListSortOrder.Ascending : ListSortOrder.Descending; - await foreach (PersistentThreadMessage message in this.GetAgentsClient().Messages.GetMessagesAsync(conversationId, runId: null, limit, order, after, before, cancellationToken).ConfigureAwait(false)) - { - yield return ToChatMessage(message); - } - } - - private PersistentAgentsClient GetAgentsClient() - { - if (this._agentsClient is null) - { - PersistentAgentsAdministrationClientOptions clientOptions = new(); - - if (httpClient is not null) - { - clientOptions.Transport = new HttpClientTransport(httpClient); - } - - PersistentAgentsClient newClient = new(projectEndpoint, projectCredentials, clientOptions); - - Interlocked.CompareExchange(ref this._agentsClient, newClient, null); - } - - return this._agentsClient; - } - - private static ChatMessage ToChatMessage(PersistentThreadMessage message) - { - return - new ChatMessage(new ChatRole(message.Role.ToString()), [.. GetContent()]) - { - MessageId = message.Id, - CreatedAt = message.CreatedAt, - AdditionalProperties = GetMetadata() - }; - - IEnumerable GetContent() - { - foreach (MessageContent contentItem in message.ContentItems) - { - AIContent? content = - contentItem switch - { - MessageTextContent textContent => new TextContent(textContent.Text), - MessageImageFileContent imageContent => new HostedFileContent(imageContent.FileId), - _ => null // Unsupported content type - }; - - if (content is not null) - { - yield return content; - } - } - } - - AdditionalPropertiesDictionary? GetMetadata() - { - if (message.Metadata is null) - { - return null; - } - - return new AdditionalPropertiesDictionary(message.Metadata.Select(m => new KeyValuePair(m.Key, m.Value))); - } - } -} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/InvokeAzureAgentTemplate.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/InvokeAzureAgentTemplate.cs index 2fe387a5f6..3704d38b21 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/InvokeAzureAgentTemplate.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/InvokeAzureAgentTemplate.cs @@ -61,7 +61,6 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.CodeGen EvaluateStringExpression(this.Model.ConversationId, "conversationId", isNullable: true); EvaluateBoolExpression(this.Model.Output?.AutoSend, "autoSend", defaultValue: true); - EvaluateMessageTemplate(this.Model.Input?.AdditionalInstructions, "additionalInstructions"); EvaluateListExpression(this.Model.Input?.Messages, "inputMessages"); this.Write(@" @@ -71,7 +70,6 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.CodeGen agentName, conversationId, autoSend, - additionalInstructions, inputMessages, cancellationToken).ConfigureAwait(false); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/InvokeAzureAgentTemplate.tt b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/InvokeAzureAgentTemplate.tt index b46e88588a..48c4acb859 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/InvokeAzureAgentTemplate.tt +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/InvokeAzureAgentTemplate.tt @@ -19,7 +19,6 @@ internal sealed class <#= this.Name #>Executor(FormulaSession session, WorkflowA <# EvaluateStringExpression(this.Model.ConversationId, "conversationId", isNullable: true); EvaluateBoolExpression(this.Model.Output?.AutoSend, "autoSend", defaultValue: true); - EvaluateMessageTemplate(this.Model.Input?.AdditionalInstructions, "additionalInstructions"); EvaluateListExpression(this.Model.Input?.Messages, "inputMessages");#> AgentRunResponse agentResponse = @@ -28,7 +27,6 @@ internal sealed class <#= this.Name #>Executor(FormulaSession session, WorkflowA agentName, conversationId, autoSend, - additionalInstructions, inputMessages, cancellationToken).ConfigureAwait(false); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/AgentFunctionToolRequest.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/AgentFunctionToolRequest.cs deleted file mode 100644 index 5f9f878e79..0000000000 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/AgentFunctionToolRequest.cs +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Collections.Generic; -using System.Text.Json.Serialization; -using Microsoft.Extensions.AI; - -namespace Microsoft.Agents.AI.Workflows.Declarative.Events; - -/// -/// Represents one or more function tool requests. -/// -public sealed class AgentFunctionToolRequest -{ - /// - /// The name of the agent associated with the tool request. - /// - public string AgentName { get; } - - /// - /// A list of function tool requests. - /// - public IList FunctionCalls { get; } - - [JsonConstructor] - internal AgentFunctionToolRequest(string agentName, IList functionCalls) - { - this.AgentName = agentName; - this.FunctionCalls = functionCalls; - } -} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/AgentFunctionToolResponse.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/AgentFunctionToolResponse.cs deleted file mode 100644 index e03414bd2e..0000000000 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/AgentFunctionToolResponse.cs +++ /dev/null @@ -1,52 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Collections.Generic; -using System.Linq; -using System.Text.Json.Serialization; -using Microsoft.Extensions.AI; - -namespace Microsoft.Agents.AI.Workflows.Declarative.Events; - -/// -/// Represents one or more function tool responses. -/// -public sealed class AgentFunctionToolResponse -{ - /// - /// The name of the agent associated with the tool response. - /// - public string AgentName { get; } - - /// - /// A list of tool responses. - /// - public IList FunctionResults { get; } - - [JsonConstructor] - internal AgentFunctionToolResponse(string agentName, IList functionResults) - { - this.AgentName = agentName; - this.FunctionResults = functionResults; - } - - /// - /// Factory method to create an from an - /// Ensures that all function calls in the request have a corresponding result. - /// - /// The tool request. - /// One or more function results - /// An that can be provided to the workflow. - /// Not all have a corresponding . - public static AgentFunctionToolResponse Create(AgentFunctionToolRequest toolRequest, params IEnumerable functionResults) - { - HashSet callIds = [.. toolRequest.FunctionCalls.Select(call => call.CallId)]; - HashSet resultIds = [.. functionResults.Select(call => call.CallId)]; - - if (!callIds.SetEquals(resultIds)) - { - throw new DeclarativeActionException($"Missing results for: {string.Join(",", callIds.Except(resultIds))}"); - } - - return new AgentFunctionToolResponse(toolRequest.AgentName, [.. functionResults]); - } -} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/AnswerRequest.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/AnswerRequest.cs deleted file mode 100644 index 845696a180..0000000000 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/AnswerRequest.cs +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Text.Json.Serialization; - -namespace Microsoft.Agents.AI.Workflows.Declarative.Events; - -/// -/// Represents a request for user input in response to a `Question` action. -/// -public sealed class AnswerRequest -{ - /// - /// An optional prompt for the user. - /// - /// - /// This prompt is utilized for the "Question" action type in the Declarative Workflow, - /// but is redundant when the user is responding to an agent since the agent's message - /// is the implicit prompt. - /// - public string? Prompt { get; } - - [JsonConstructor] - internal AnswerRequest(string? prompt = null) - { - this.Prompt = prompt; - } -} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/AnswerResponse.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/AnswerResponse.cs deleted file mode 100644 index 00903f43f0..0000000000 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/AnswerResponse.cs +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Text.Json.Serialization; -using Microsoft.Extensions.AI; - -namespace Microsoft.Agents.AI.Workflows.Declarative.Events; - -/// -/// Represents a user input response. -/// -public sealed class AnswerResponse -{ - /// - /// The response value. - /// - public ChatMessage Value { get; } - - /// - /// Initializes a new instance of the class. - /// - /// The response value. - [JsonConstructor] - public AnswerResponse(ChatMessage value) - { - this.Value = value; - } - - /// - /// Initializes a new instance of the class. - /// - /// The response value. - public AnswerResponse(string value) - { - this.Value = new ChatMessage(ChatRole.User, value); - } -} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/ExternalInputRequest.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/ExternalInputRequest.cs new file mode 100644 index 0000000000..8caf374b70 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/ExternalInputRequest.cs @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Workflows.Declarative.Events; + +/// +/// Represents a request for external input. +/// +public sealed class ExternalInputRequest +{ + /// + /// The source message that triggered the request for external input. + /// + public AgentRunResponse AgentResponse { get; } + + [JsonConstructor] + internal ExternalInputRequest(AgentRunResponse agentResponse) + { + this.AgentResponse = agentResponse; + } + + internal ExternalInputRequest(ChatMessage message) + { + this.AgentResponse = new AgentRunResponse(message); + } + + internal ExternalInputRequest(string text) + { + this.AgentResponse = new AgentRunResponse(new ChatMessage(ChatRole.User, text)); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/ExternalInputResponse.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/ExternalInputResponse.cs new file mode 100644 index 0000000000..0653a12ce5 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/ExternalInputResponse.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Text.Json.Serialization; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Workflows.Declarative.Events; + +/// +/// Represents the response to a . +/// +public sealed class ExternalInputResponse +{ + /// + /// The message being provided as external input to the workflow. + /// + public IList Messages { get; } + + internal bool HasMessages => this.Messages?.Count > 0; + + /// + /// Initializes a new instance of . + /// + /// The external input message being provided to the workflow. + public ExternalInputResponse(ChatMessage message) + { + this.Messages = [message]; + } + + /// + /// Initializes a new instance of . + /// + /// The external input messages being provided to the workflow. + [JsonConstructor] + public ExternalInputResponse(IList messages) + { + this.Messages = messages; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/UserInputRequest.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/UserInputRequest.cs deleted file mode 100644 index 1426025d74..0000000000 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/UserInputRequest.cs +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Collections.Generic; -using System.Text.Json.Serialization; -using Microsoft.Extensions.AI; - -namespace Microsoft.Agents.AI.Workflows.Declarative.Events; - -/// -/// Represents one or more user-input requests. -/// -public sealed class UserInputRequest -{ - /// - /// The name of the agent associated with the tool request. - /// - public string AgentName { get; } - - /// - /// A list of user input requests. - /// - public IList InputRequests { get; } - - [JsonConstructor] - internal UserInputRequest(string agentName, IList inputRequests) - { - this.AgentName = agentName; - this.InputRequests = inputRequests; - } -} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/UserInputResponse.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/UserInputResponse.cs deleted file mode 100644 index edb9f3b7cc..0000000000 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/UserInputResponse.cs +++ /dev/null @@ -1,52 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Collections.Generic; -using System.Linq; -using System.Text.Json.Serialization; -using Microsoft.Extensions.AI; - -namespace Microsoft.Agents.AI.Workflows.Declarative.Events; - -/// -/// Represents one or more user-input responses. -/// -public sealed class UserInputResponse -{ - /// - /// The name of the agent associated with the tool request. - /// - public string AgentName { get; } - - /// - /// A list of approval responses. - /// - public IList InputResponses { get; } - - [JsonConstructor] - internal UserInputResponse(string agentName, IList inputResponses) - { - this.AgentName = agentName; - this.InputResponses = inputResponses; - } - - /// - /// Factory method to create an from a - /// Ensures that all requests have a corresponding result. - /// - /// The input request. - /// One or more responses - /// An that can be provided to the workflow. - /// Not all have a corresponding . - public static UserInputResponse Create(UserInputRequest inputRequest, params IEnumerable inputResponses) - { - HashSet callIds = [.. inputRequest.InputRequests.OfType().Select(call => call.Id)]; - HashSet resultIds = [.. inputResponses.Select(call => call.Id)]; - - if (!callIds.SetEquals(resultIds)) - { - throw new DeclarativeActionException($"Missing responses for: {string.Join(",", callIds.Except(resultIds))}"); - } - - return new UserInputResponse(inputRequest.AgentName, [.. inputResponses]); - } -} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/AgentProviderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/AgentProviderExtensions.cs index 5b6bbbc297..037665e8b8 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/AgentProviderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/AgentProviderExtensions.cs @@ -1,24 +1,14 @@ // Copyright (c) Microsoft. All rights reserved. using System.Collections.Generic; -using System.Linq; using System.Threading; using System.Threading.Tasks; -using Azure.AI.Agents.Persistent; using Microsoft.Extensions.AI; namespace Microsoft.Agents.AI.Workflows.Declarative.Extensions; internal static class AgentProviderExtensions { - private static readonly HashSet s_failureStatus = - [ - Azure.AI.Agents.Persistent.RunStatus.Failed, - Azure.AI.Agents.Persistent.RunStatus.Cancelled, - Azure.AI.Agents.Persistent.RunStatus.Cancelling, - Azure.AI.Agents.Persistent.RunStatus.Expired, - ]; - public static async ValueTask InvokeAgentAsync( this WorkflowAgentProvider agentProvider, string executorId, @@ -26,27 +16,11 @@ internal static class AgentProviderExtensions string agentName, string? conversationId, bool autoSend, - string? additionalInstructions = null, IEnumerable? inputMessages = null, + IDictionary? inputArguments = null, CancellationToken cancellationToken = default) { - // Get the specified agent. - AIAgent agent = await agentProvider.GetAgentAsync(agentName, cancellationToken).ConfigureAwait(false); - - // Prepare the run options. - ChatClientAgentRunOptions options = - new( - new ChatOptions() - { - ConversationId = conversationId, - Instructions = additionalInstructions, - }); - - // Initialize the agent thread. - IAsyncEnumerable agentUpdates = - inputMessages is not null ? - agent.RunStreamingAsync([.. inputMessages], null, options, cancellationToken) : - agent.RunStreamingAsync(null, options, cancellationToken); + IAsyncEnumerable agentUpdates = agentProvider.InvokeAgentAsync(agentName, null, conversationId, inputMessages, inputArguments, cancellationToken); // Enable "autoSend" behavior if this is the workflow conversation. bool isWorkflowConversation = context.IsWorkflowConversation(conversationId, out string? workflowConversationId); @@ -60,13 +34,6 @@ internal static class AgentProviderExtensions updates.Add(update); - if (update.RawRepresentation is ChatResponseUpdate chatUpdate && - chatUpdate.RawRepresentation is RunUpdate runUpdate && - s_failureStatus.Contains(runUpdate.Value.Status)) - { - throw new DeclarativeActionException($"Unexpected failure invoking agent, run {runUpdate.Value.Status}: {agent.Name ?? agent.Id} [{runUpdate.Value.Id}/{conversationId}]"); - } - if (autoSend) { await context.AddEventAsync(new AgentRunUpdateEvent(executorId, update), cancellationToken).ConfigureAwait(false); @@ -80,16 +47,10 @@ internal static class AgentProviderExtensions await context.AddEventAsync(new AgentRunResponseEvent(executorId, response), cancellationToken).ConfigureAwait(false); } + // If autoSend is enabled and this is not the workflow conversation, copy messages to the workflow conversation. if (autoSend && !isWorkflowConversation && workflowConversationId is not null) { - // Copy messages with content that aren't function calls or results. - IEnumerable messages = - response.Messages.Where( - message => - !string.IsNullOrEmpty(message.Text) && - !message.Contents.OfType().Any() && - !message.Contents.OfType().Any()); - foreach (ChatMessage message in messages) + foreach (ChatMessage message in response.Messages) { await agentProvider.CreateMessageAsync(workflowConversationId, message, cancellationToken).ConfigureAwait(false); } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/FormulaValueExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/FormulaValueExtensions.cs index 108dca7682..3e397f3e87 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/FormulaValueExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/FormulaValueExtensions.cs @@ -163,6 +163,24 @@ internal static class FormulaValueExtensions } } } + + public static JsonNode ToJson(this FormulaValue value) => + value switch + { + BooleanValue booleanValue => JsonValue.Create(booleanValue.Value), + DecimalValue decimalValue => JsonValue.Create(decimalValue.Value), + NumberValue numberValue => JsonValue.Create(numberValue.Value), + DateValue dateValue => JsonValue.Create(dateValue.GetConvertedValue(TimeZoneInfo.Utc)), + DateTimeValue datetimeValue => JsonValue.Create(datetimeValue.GetConvertedValue(TimeZoneInfo.Utc)), + TimeValue timeValue => JsonValue.Create($"{timeValue.Value}"), + StringValue stringValue => JsonValue.Create(stringValue.Value), + GuidValue guidValue => JsonValue.Create(guidValue.Value), + RecordValue recordValue => recordValue.ToJson(), + TableValue tableValue => tableValue.ToJson(), + BlankValue => JsonValue.Create(string.Empty), + _ => $"[{value.GetType().Name}]", + }; + public static RecordValue ToRecord(this Dictionary value) => FormulaValue.NewRecordFromFields( value.Select( @@ -256,23 +274,6 @@ internal static class FormulaValueExtensions private static KeyValuePair GetKeyValuePair(this NamedValue value) => new(value.Name, value.Value.ToDataValue()); - private static JsonNode ToJson(this FormulaValue value) => - value switch - { - BooleanValue booleanValue => JsonValue.Create(booleanValue.Value), - DecimalValue decimalValue => JsonValue.Create(decimalValue.Value), - NumberValue numberValue => JsonValue.Create(numberValue.Value), - DateValue dateValue => JsonValue.Create(dateValue.GetConvertedValue(TimeZoneInfo.Utc)), - DateTimeValue datetimeValue => JsonValue.Create(datetimeValue.GetConvertedValue(TimeZoneInfo.Utc)), - TimeValue timeValue => JsonValue.Create($"{timeValue.Value}"), - StringValue stringValue => JsonValue.Create(stringValue.Value), - GuidValue guidValue => JsonValue.Create(guidValue.Value), - RecordValue recordValue => recordValue.ToJson(), - TableValue tableValue => tableValue.ToJson(), - BlankValue => JsonValue.Create(string.Empty), - _ => $"[{value.GetType().Name}]", - }; - private static JsonArray ToJson(this TableValue value) { return new([.. GetJsonElements()]); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/JsonDocumentExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/JsonDocumentExtensions.cs index d3a4ef9cbc..9d3c5e335d 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/JsonDocumentExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/JsonDocumentExtensions.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Collections.Frozen; using System.Collections.Generic; using System.Globalization; using System.Linq; @@ -43,16 +44,28 @@ internal static class JsonDocumentExtensions private static Dictionary ParseRecord(this JsonElement currentElement, VariableType targetType) { - if (targetType.Schema is null) - { - throw new DeclarativeActionException($"Object schema not defined for. {targetType.Type.Name}."); - } + IEnumerable> keyValuePairs = + targetType.Schema is null ? + ParseValues() : + ParseSchema(targetType.Schema); - return ParseValues().ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + return keyValuePairs.ToDictionary(kvp => kvp.Key, kvp => kvp.Value); IEnumerable> ParseValues() { - foreach (KeyValuePair property in targetType.Schema) + foreach (JsonProperty objectProperty in currentElement.EnumerateObject()) + { + if (!objectProperty.Value.TryParseValue(targetType: null, out object? parsedValue)) + { + throw new DeclarativeActionException($"Unsupported data type '{objectProperty.Value.ValueKind}' for property '{objectProperty.Name}'"); + } + yield return new KeyValuePair(objectProperty.Name, parsedValue); + } + } + + IEnumerable> ParseSchema(FrozenDictionary schema) + { + foreach (KeyValuePair property in schema) { object? parsedValue = null; if (!currentElement.TryGetProperty(property.Key, out JsonElement propertyElement)) @@ -131,22 +144,22 @@ internal static class JsonDocumentExtensions return value; } - private static bool TryParseValue(this JsonElement propertyElement, VariableType targetType, out object? value) => + private static bool TryParseValue(this JsonElement propertyElement, VariableType? targetType, out object? value) => propertyElement.ValueKind switch { - JsonValueKind.String => TryParseString(propertyElement, targetType.Type, out value), - JsonValueKind.Number => TryParseNumber(propertyElement, targetType.Type, out value), + JsonValueKind.String => TryParseString(propertyElement, targetType?.Type, out value), + JsonValueKind.Number => TryParseNumber(propertyElement, targetType?.Type, out value), JsonValueKind.True or JsonValueKind.False => TryParseBoolean(propertyElement, out value), JsonValueKind.Object => TryParseObject(propertyElement, targetType, out value), JsonValueKind.Array => TryParseList(propertyElement, targetType, out value), - JsonValueKind.Null => TryParseNull(targetType.Type, out value), + JsonValueKind.Null => TryParseNull(targetType?.Type, out value), _ => throw new DeclarativeActionException($"JSON element of type {propertyElement.ValueKind} is not supported."), }; - private static bool TryParseNull(Type valueType, out object? value) + private static bool TryParseNull(Type? valueType, out object? value) { // If the target type is not nullable, we cannot assign null to it - if (!valueType.IsNullable()) + if (valueType?.IsNullable() == false) { value = null; return false; @@ -170,7 +183,7 @@ internal static class JsonDocumentExtensions } } - private static bool TryParseString(JsonElement propertyElement, Type valueType, out object? value) + private static bool TryParseString(JsonElement propertyElement, Type? valueType, out object? value) { try { @@ -178,23 +191,30 @@ internal static class JsonDocumentExtensions if (propertyValue is null) { value = null; - return valueType.IsNullable(); // Parse fails if value is null and requested type is not. + return valueType?.IsNullable() ?? false; // Parse fails if value is null and requested type is not. } - switch (valueType) + if (valueType is null) { - case Type targetType when targetType == typeof(string): - value = propertyValue; - break; - case Type targetType when targetType == typeof(DateTime): - value = DateTime.Parse(propertyValue, provider: null, styles: DateTimeStyles.RoundtripKind); - break; - case Type targetType when targetType == typeof(TimeSpan): - value = TimeSpan.Parse(propertyValue); - break; - default: - value = null; - return false; + value = propertyValue; + } + else + { + switch (valueType) + { + case Type targetType when targetType == typeof(string): + value = propertyValue; + break; + case Type targetType when targetType == typeof(DateTime): + value = DateTime.Parse(propertyValue, provider: null, styles: DateTimeStyles.RoundtripKind); + break; + case Type targetType when targetType == typeof(TimeSpan): + value = TimeSpan.Parse(propertyValue); + break; + default: + value = null; + return false; + } } return true; @@ -206,7 +226,7 @@ internal static class JsonDocumentExtensions } } - private static bool TryParseNumber(JsonElement element, Type valueType, out object? value) + private static bool TryParseNumber(JsonElement element, Type? valueType, out object? value) { // Try parsing as integer types first (most precise representation) if (element.TryGetInt32(out int intValue)) @@ -234,8 +254,14 @@ internal static class JsonDocumentExtensions value = null; return false; - static bool ConvertToExpectedType(Type valueType, object sourceValue, out object? value) + static bool ConvertToExpectedType(Type? valueType, object sourceValue, out object? value) { + if (valueType is null) + { + value = sourceValue; + return true; + } + try { value = Convert.ChangeType(sourceValue, valueType); @@ -249,23 +275,17 @@ internal static class JsonDocumentExtensions } } - private static bool TryParseObject(JsonElement propertyElement, VariableType targetType, out object? value) + private static bool TryParseObject(JsonElement propertyElement, VariableType? targetType, out object? value) { - if (!targetType.HasSchema) - { - value = null; - return false; - } - - value = propertyElement.ParseRecord(targetType); + value = propertyElement.ParseRecord(targetType ?? VariableType.RecordType); return true; } - private static bool TryParseList(JsonElement propertyElement, VariableType targetType, out object? value) + private static bool TryParseList(JsonElement propertyElement, VariableType? targetType, out object? value) { try { - value = ParseTable(propertyElement, targetType); + value = ParseTable(propertyElement, targetType ?? VariableType.ListType); return true; } catch diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/PortableValueExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/PortableValueExtensions.cs index 17e7579d9f..7ef09d2b85 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/PortableValueExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/PortableValueExtensions.cs @@ -40,6 +40,12 @@ internal static class PortableValueExtensions private static TableValue ToTable(this PortableValue[] values) { FormulaValue[] formulaValues = values.Select(value => value.ToFormula()).ToArray(); + + if (formulaValues.Length == 0) + { + return FormulaValue.NewTable(RecordType.Empty()); + } + if (formulaValues[0] is RecordValue recordValue) { return FormulaValue.NewTable(ParseRecordType(recordValue), formulaValues.OfType()); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeWorkflowContext.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeWorkflowContext.cs index 60e50e6abe..9a8d9da707 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeWorkflowContext.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeWorkflowContext.cs @@ -77,7 +77,7 @@ internal sealed class DeclarativeWorkflowContext : IWorkflowContext this.State.Bind(); } - private bool IsManagedScope(string? scopeName) => scopeName is not null && VariableScopeNames.IsValidName(scopeName); + private static bool IsManagedScope(string? scopeName) => scopeName is not null && VariableScopeNames.IsValidName(scopeName); /// public async ValueTask ReadStateAsync(string key, string? scopeName = null, CancellationToken cancellationToken = default) @@ -86,7 +86,7 @@ internal sealed class DeclarativeWorkflowContext : IWorkflowContext { // Not a managed scope, just pass through. This is valid when a declarative // workflow has been ejected to code (where DeclarativeWorkflowContext is also utilized). - _ when !this.IsManagedScope(scopeName) => await this.Source.ReadStateAsync(key, scopeName, cancellationToken).ConfigureAwait(false), + _ when !IsManagedScope(scopeName) => await this.Source.ReadStateAsync(key, scopeName, cancellationToken).ConfigureAwait(false), // Retrieve formula values directly from the managed state to avoid conversion. _ when typeof(TValue) == typeof(FormulaValue) => (TValue?)(object?)this.State.Get(key, scopeName), // Retrieve native types from the source context to avoid conversion. @@ -100,7 +100,7 @@ internal sealed class DeclarativeWorkflowContext : IWorkflowContext { // Not a managed scope, just pass through. This is valid when a declarative // workflow has been ejected to code (where DeclarativeWorkflowContext is also utilized). - _ when !this.IsManagedScope(scopeName) => await this.Source.ReadOrInitStateAsync(key, initialStateFactory, scopeName, cancellationToken).ConfigureAwait(false), + _ when !IsManagedScope(scopeName) => await this.Source.ReadOrInitStateAsync(key, initialStateFactory, scopeName, cancellationToken).ConfigureAwait(false), // Retrieve formula values directly from the managed state to avoid conversion. _ when typeof(TValue) == typeof(FormulaValue) => await EnsureFormulaValueAsync().ConfigureAwait(false), // Retrieve native types from the source context to avoid conversion. diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs index de90787cfd..b6bcd458d8 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs @@ -137,18 +137,23 @@ internal sealed class WorkflowActionVisitor : DialogActionVisitor conditionItem.Accept(this); } + if (lastConditionItemId is not null) + { + // Create clean start for else action from prior conditions + this.RestartAfter(lastConditionItemId, action.Id); + } + if (item.ElseActions?.Actions.Length > 0) { - if (lastConditionItemId is not null) - { - // Create clean start for else action from prior conditions - this.RestartAfter(lastConditionItemId, action.Id); - } - // Create conditional link for else action string stepId = ConditionGroupExecutor.Steps.Else(item); this._workflowModel.AddLink(action.Id, stepId, action.IsElse); } + else + { + string stepId = Steps.Post(action.Id); + this._workflowModel.AddLink(action.Id, stepId, action.IsElse); + } } protected override void Visit(GotoAction item) @@ -239,6 +244,7 @@ internal sealed class WorkflowActionVisitor : DialogActionVisitor // Entry point for question QuestionExecutor action = new(item, this._workflowOptions.AgentProvider, this._workflowState); this.ContinueWith(action); + // Transition to post action if complete string postId = Steps.Post(action.Id); this._workflowModel.AddLink(action.Id, postId, QuestionExecutor.IsComplete); @@ -249,13 +255,13 @@ internal sealed class WorkflowActionVisitor : DialogActionVisitor // Define input action string inputId = QuestionExecutor.Steps.Input(action.Id); - RequestPortAction inputPort = new(RequestPort.Create(inputId)); + RequestPortAction inputPort = new(RequestPort.Create(inputId)); this._workflowModel.AddNode(inputPort, action.ParentId); this._workflowModel.AddLinkFromPeer(action.ParentId, inputId); // Capture input response string captureId = QuestionExecutor.Steps.Capture(action.Id); - this.ContinueWith(new DelegateActionExecutor(captureId, this._workflowState, action.CaptureResponseAsync, emitResult: false), action.ParentId); + this.ContinueWith(new DelegateActionExecutor(captureId, this._workflowState, action.CaptureResponseAsync, emitResult: false), action.ParentId); // Transition to post action if complete this.ContinueWith(new DelegateActionExecutor(postId, this._workflowState, action.CompleteAsync), action.ParentId, QuestionExecutor.IsComplete); @@ -263,6 +269,24 @@ internal sealed class WorkflowActionVisitor : DialogActionVisitor this._workflowModel.AddLink(captureId, prepareId, message => !QuestionExecutor.IsComplete(message)); } + protected override void Visit(RequestExternalInput item) + { + this.Trace(item); + + RequestExternalInputExecutor action = new(item, this._workflowOptions.AgentProvider, this._workflowState); + this.ContinueWith(action); + + // Define input action + string inputId = RequestExternalInputExecutor.Steps.Input(action.Id); + RequestPortAction inputPort = new(RequestPort.Create(inputId)); + this._workflowModel.AddNode(inputPort, action.ParentId); + this._workflowModel.AddLinkFromPeer(action.ParentId, inputId); + + // Capture input response + string captureId = RequestExternalInputExecutor.Steps.Capture(action.Id); + this.ContinueWith(new DelegateActionExecutor(captureId, this._workflowState, action.CaptureResponseAsync), action.ParentId); + } + protected override void Visit(EndDialog item) { this.Trace(item); @@ -285,6 +309,28 @@ internal sealed class WorkflowActionVisitor : DialogActionVisitor this.RestartAfter(action.Id, action.ParentId); } + protected override void Visit(CancelAllDialogs item) + { + this.Trace(item); + + // Represent action with default executor + DefaultActionExecutor action = new(item, this._workflowState); + this.ContinueWith(action); + // Define a clean-start to ensure "end" is not a source for any edge + this.RestartAfter(item.Id.Value, action.ParentId); + } + + protected override void Visit(CancelDialog item) + { + this.Trace(item); + + // Represent action with default executor + DefaultActionExecutor action = new(item, this._workflowState); + this.ContinueWith(action); + // Define a clean-start to ensure "end" is not a source for any edge + this.RestartAfter(action.Id, action.ParentId); + } + protected override void Visit(CreateConversation item) { this.Trace(item); @@ -318,33 +364,29 @@ internal sealed class WorkflowActionVisitor : DialogActionVisitor this._workflowModel.AddLink(action.Id, postId, InvokeAzureAgentExecutor.RequiresNothing); // Define request-port for function calling action - string functionCallingPortId = InvokeAzureAgentExecutor.Steps.FunctionTool(action.Id); - RequestPortAction functionCallingPort = new(RequestPort.Create(functionCallingPortId)); - this._workflowModel.AddNode(functionCallingPort, action.ParentId); - this._workflowModel.AddLink(action.Id, functionCallingPort.Id, InvokeAzureAgentExecutor.RequiresFunctionCall); - - // Define request-port for user input, such as: mcp tool & function tool approval - string userInputPortId = InvokeAzureAgentExecutor.Steps.UserInput(action.Id); - RequestPortAction userInputPort = new(RequestPort.Create(userInputPortId)); - this._workflowModel.AddNode(userInputPort, action.ParentId); - this._workflowModel.AddLink(action.Id, userInputPortId, InvokeAzureAgentExecutor.RequiresUserInput); + string externalInputPortId = InvokeAzureAgentExecutor.Steps.ExternalInput(action.Id); + RequestPortAction externalInputPort = new(RequestPort.Create(externalInputPortId)); + this._workflowModel.AddNode(externalInputPort, action.ParentId); + this._workflowModel.AddLink(action.Id, externalInputPortId, InvokeAzureAgentExecutor.RequiresInput); // Request ports always transitions to resume string resumeId = InvokeAzureAgentExecutor.Steps.Resume(action.Id); - this._workflowModel.AddNode(new DelegateActionExecutor(resumeId, this._workflowState, action.ResumeAsync), action.ParentId); - this._workflowModel.AddLink(functionCallingPortId, resumeId); - this._workflowModel.AddLink(userInputPortId, resumeId); - // Transition to appropriate request port if more function calling is requested - this._workflowModel.AddLink(resumeId, functionCallingPortId, InvokeAzureAgentExecutor.RequiresFunctionCall); - // Transition to appropriate request port if more user input is requested - this._workflowModel.AddLink(resumeId, userInputPortId, InvokeAzureAgentExecutor.RequiresUserInput); + this._workflowModel.AddNode(new DelegateActionExecutor(resumeId, this._workflowState, action.ResumeAsync, emitResult: false), action.ParentId); + this._workflowModel.AddLink(externalInputPortId, resumeId); // Transition to post action if complete this._workflowModel.AddLink(resumeId, postId, InvokeAzureAgentExecutor.RequiresNothing); + // Transition to request port if more input is required + this._workflowModel.AddLink(resumeId, externalInputPortId, InvokeAzureAgentExecutor.RequiresInput); // Define post action this._workflowModel.AddNode(new DelegateActionExecutor(postId, this._workflowState, action.CompleteAsync), action.ParentId); } + protected override void Visit(InvokeAzureResponse item) + { + this.NotSupported(item); + } + protected override void Visit(RetrieveConversationMessage item) { this.Trace(item); @@ -462,10 +504,6 @@ internal sealed class WorkflowActionVisitor : DialogActionVisitor protected override void Visit(ReplaceDialog item) => this.NotSupported(item); - protected override void Visit(CancelAllDialogs item) => this.NotSupported(item); - - protected override void Visit(CancelDialog item) => this.NotSupported(item); - protected override void Visit(EmitEvent item) => this.NotSupported(item); protected override void Visit(GetConversationMembers item) => this.NotSupported(item); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowTemplateVisitor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowTemplateVisitor.cs index 4822a70024..5d6a7b3384 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowTemplateVisitor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowTemplateVisitor.cs @@ -210,9 +210,11 @@ internal sealed class WorkflowTemplateVisitor : DialogActionVisitor protected override void Visit(Question item) { this.NotSupported(item); - //this.Trace(item); + } - //this.ContinueWith(new QuestionTemplate(item)); + protected override void Visit(RequestExternalInput item) + { + this.NotSupported(item); } protected override void Visit(EndDialog item) @@ -237,6 +239,24 @@ internal sealed class WorkflowTemplateVisitor : DialogActionVisitor this.RestartAfter(action.Id, action.ParentId); } + protected override void Visit(CancelAllDialogs item) + { + // Represent action with default executor + DefaultTemplate action = new(item, this._rootId); + this.ContinueWith(action); + // Define a clean-start to ensure "end" is not a source for any edge + this.RestartAfter(action.Id, action.ParentId); + } + + protected override void Visit(CancelDialog item) + { + // Represent action with default executor + DefaultTemplate action = new(item, this._rootId); + this.ContinueWith(action); + // Define a clean-start to ensure "end" is not a source for any edge + this.RestartAfter(action.Id, action.ParentId); + } + protected override void Visit(CreateConversation item) { this.Trace(item); @@ -265,6 +285,11 @@ internal sealed class WorkflowTemplateVisitor : DialogActionVisitor this.ContinueWith(new InvokeAzureAgentTemplate(item)); } + protected override void Visit(InvokeAzureResponse item) + { + this.NotSupported(item); + } + protected override void Visit(RetrieveConversationMessage item) { this.Trace(item); @@ -317,17 +342,11 @@ internal sealed class WorkflowTemplateVisitor : DialogActionVisitor protected override void Visit(EditTable item) { this.NotSupported(item); - //this.Trace(item); - - //this.ContinueWith(new EditTableTemplate(item)); } protected override void Visit(EditTableV2 item) { this.NotSupported(item); - //this.Trace(item); - - //this.ContinueWith(new EditTableV2Template(item)); } protected override void Visit(ParseValue item) @@ -384,10 +403,6 @@ internal sealed class WorkflowTemplateVisitor : DialogActionVisitor protected override void Visit(ReplaceDialog item) => this.NotSupported(item); - protected override void Visit(CancelAllDialogs item) => this.NotSupported(item); - - protected override void Visit(CancelDialog item) => this.NotSupported(item); - protected override void Visit(EmitEvent item) => this.NotSupported(item); protected override void Visit(GetConversationMembers item) => this.NotSupported(item); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/AgentExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/AgentExecutor.cs index 8189e9b8aa..45a5b47bd8 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/AgentExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/AgentExecutor.cs @@ -23,7 +23,6 @@ public abstract class AgentExecutor(string id, FormulaSession session, WorkflowA /// The name or identifier of the agent. /// The identifier of the conversation. /// Send the agent's response as workflow output. (default: true). - /// Optional additional instructions to the agent. /// Optional messages to add to the conversation prior to invocation. /// A token that can be used to observe cancellation. /// @@ -32,8 +31,7 @@ public abstract class AgentExecutor(string id, FormulaSession session, WorkflowA string agentName, string? conversationId, bool autoSend, - string? additionalInstructions = null, IEnumerable? inputMessages = null, CancellationToken cancellationToken = default) - => agentProvider.InvokeAgentAsync(this.Id, context, agentName, conversationId, autoSend, additionalInstructions, inputMessages, cancellationToken); + => agentProvider.InvokeAgentAsync(this.Id, context, agentName, conversationId, autoSend, inputMessages, inputArguments: null, cancellationToken); } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Microsoft.Agents.AI.Workflows.Declarative.csproj b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Microsoft.Agents.AI.Workflows.Declarative.csproj index 7db0b0d941..1f466aac4e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Microsoft.Agents.AI.Workflows.Declarative.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Microsoft.Agents.AI.Workflows.Declarative.csproj @@ -4,7 +4,7 @@ $(ProjectsTargetFrameworks) $(ProjectsDebugTargetFrameworks) preview - $(NoWarn);MEAI001 + $(NoWarn);MEAI001;OPENAI001 @@ -22,7 +22,6 @@ - @@ -35,7 +34,6 @@ - diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/AddConversationMessageExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/AddConversationMessageExecutor.cs index ef64625f6e..632f462758 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/AddConversationMessageExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/AddConversationMessageExecutor.cs @@ -19,6 +19,7 @@ internal sealed class AddConversationMessageExecutor(AddConversationMessage mode { Throw.IfNull(this.Model.ConversationId, $"{nameof(this.Model)}.{nameof(this.Model.ConversationId)}"); string conversationId = this.Evaluator.GetValue(this.Model.ConversationId).Value; + bool isWorkflowConversation = context.IsWorkflowConversation(conversationId, out string? _); ChatMessage newMessage = new(this.Model.Role.Value.ToChatRole(), [.. this.GetContent()]) { AdditionalProperties = this.GetMetadata() }; @@ -27,6 +28,11 @@ internal sealed class AddConversationMessageExecutor(AddConversationMessage mode await this.AssignAsync(this.Model.Message?.Path, newMessage.ToRecord(), context).ConfigureAwait(false); + if (isWorkflowConversation) + { + await context.AddEventAsync(new AgentRunResponseEvent(this.Id, new AgentRunResponse(newMessage)), cancellationToken).ConfigureAwait(false); + } + return default; } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/CopyConversationMessagesExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/CopyConversationMessagesExecutor.cs index e54e294730..01b1bab496 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/CopyConversationMessagesExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/CopyConversationMessagesExecutor.cs @@ -20,6 +20,7 @@ internal sealed class CopyConversationMessagesExecutor(CopyConversationMessages { Throw.IfNull(this.Model.ConversationId, $"{nameof(this.Model)}.{nameof(this.Model.ConversationId)}"); string conversationId = this.Evaluator.GetValue(this.Model.ConversationId).Value; + bool isWorkflowConversation = context.IsWorkflowConversation(conversationId, out string? _); IEnumerable? inputMessages = this.GetInputMessages(); @@ -29,6 +30,11 @@ internal sealed class CopyConversationMessagesExecutor(CopyConversationMessages { await agentProvider.CreateMessageAsync(conversationId, message, cancellationToken).ConfigureAwait(false); } + + if (isWorkflowConversation) + { + await context.AddEventAsync(new AgentRunResponseEvent(this.Id, new AgentRunResponse([.. inputMessages])), cancellationToken).ConfigureAwait(false); + } } return default; diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs index 8c4c9eee61..ed069a9b78 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.Agents.AI.Workflows.Declarative.Events; @@ -21,14 +22,11 @@ internal sealed class InvokeAzureAgentExecutor(InvokeAzureAgent model, WorkflowA { public static class Steps { - public static string UserInput(string id) => $"{id}_{nameof(UserInput)}"; - public static string FunctionTool(string id) => $"{id}_{nameof(FunctionTool)}"; + public static string ExternalInput(string id) => $"{id}_{nameof(ExternalInput)}"; public static string Resume(string id) => $"{id}_{nameof(Resume)}"; } - public static bool RequiresFunctionCall(object? message) => message is AgentFunctionToolRequest; - - public static bool RequiresUserInput(object? message) => message is UserInputRequest; + public static bool RequiresInput(object? message) => message is ExternalInputRequest; public static bool RequiresNothing(object? message) => message is ActionExecutorResult; @@ -46,8 +44,11 @@ internal sealed class InvokeAzureAgentExecutor(InvokeAzureAgent model, WorkflowA return default; } - public ValueTask ResumeAsync(IWorkflowContext context, AgentFunctionToolResponse message, CancellationToken cancellationToken) => - this.InvokeAgentAsync(context, [message.FunctionResults.ToChatMessage()], cancellationToken); + public async ValueTask ResumeAsync(IWorkflowContext context, ExternalInputResponse response, CancellationToken cancellationToken) + { + await context.SetLastMessageAsync(response.Messages.Last()).ConfigureAwait(false); + await this.InvokeAgentAsync(context, response.Messages, cancellationToken).ConfigureAwait(false); + } public async ValueTask CompleteAsync(IWorkflowContext context, ActionExecutorResult message, CancellationToken cancellationToken) { @@ -58,40 +59,69 @@ internal sealed class InvokeAzureAgentExecutor(InvokeAzureAgent model, WorkflowA { string? conversationId = this.GetConversationId(); string agentName = this.GetAgentName(); - string? additionalInstructions = this.GetAdditionalInstructions(); bool autoSend = this.GetAutoSendValue(); + Dictionary? inputParameters = this.GetStructuredInputs(); + AgentRunResponse agentResponse = await agentProvider.InvokeAgentAsync(this.Id, context, agentName, conversationId, autoSend, messages, inputParameters, cancellationToken).ConfigureAwait(false); - bool isComplete = true; - - AgentRunResponse agentResponse = await agentProvider.InvokeAgentAsync(this.Id, context, agentName, conversationId, autoSend, additionalInstructions, messages, cancellationToken).ConfigureAwait(false); - - if (string.IsNullOrEmpty(agentResponse.Text)) + ChatMessage[] actionableMessages = FilterActionableContent(agentResponse).ToArray(); + if (actionableMessages.Length > 0) { - // Identify function calls that have no associated result. - List inputRequests = GetUserInputRequests(agentResponse); - if (inputRequests.Count > 0) - { - isComplete = false; - UserInputRequest approvalRequest = new(agentName, inputRequests.OfType().ToArray()); - await context.SendMessageAsync(approvalRequest, cancellationToken).ConfigureAwait(false); - } - - // Identify function calls that have no associated result. - List functionCalls = GetOrphanedFunctionCalls(agentResponse); - if (functionCalls.Count > 0) - { - isComplete = false; - AgentFunctionToolRequest toolRequest = new(agentName, functionCalls); - await context.SendMessageAsync(toolRequest, cancellationToken).ConfigureAwait(false); - } - } - - if (isComplete) - { - await context.SendResultMessageAsync(this.Id, result: null, cancellationToken).ConfigureAwait(false); + AgentRunResponse filteredResponse = + new(actionableMessages) + { + AdditionalProperties = agentResponse.AdditionalProperties, + AgentId = agentResponse.AgentId, + CreatedAt = agentResponse.CreatedAt, + ResponseId = agentResponse.ResponseId, + Usage = agentResponse.Usage, + }; + await context.SendMessageAsync(new ExternalInputRequest(filteredResponse), cancellationToken).ConfigureAwait(false); + return; } await this.AssignAsync(this.AgentOutput?.Messages?.Path, agentResponse.Messages.ToTable(), context).ConfigureAwait(false); + + // Attempt to parse the last message as JSON and assign to the response object variable. + try + { + JsonDocument jsonDocument = JsonDocument.Parse(agentResponse.Messages.Last().Text); + Dictionary objectProperties = jsonDocument.ParseRecord(VariableType.RecordType); + await this.AssignAsync(this.AgentOutput?.ResponseObject?.Path, objectProperties.ToFormula(), context).ConfigureAwait(false); + } + catch + { + // Not valid json, skip assignment. + } + + if (this.Model.Input?.ExternalLoop?.When is not null) + { + bool requestInput = this.Evaluator.GetValue(this.Model.Input.ExternalLoop.When).Value; + if (requestInput) + { + ExternalInputRequest inputRequest = new(agentResponse); + await context.SendMessageAsync(inputRequest, cancellationToken).ConfigureAwait(false); + return; + } + } + + await context.SendResultMessageAsync(this.Id, result: null, cancellationToken).ConfigureAwait(false); + } + + private Dictionary? GetStructuredInputs() + { + Dictionary? inputs = null; + + if (this.AgentInput?.Arguments is not null) + { + inputs = []; + + foreach (KeyValuePair argument in this.AgentInput.Arguments) + { + inputs[argument.Key] = this.Evaluator.GetValue(argument.Value).Value.ToObject(); + } + } + + return inputs; } private IEnumerable? GetInputMessages() @@ -107,7 +137,7 @@ internal sealed class InvokeAzureAgentExecutor(InvokeAzureAgent model, WorkflowA return userInput?.ToChatMessages(); } - private static List GetOrphanedFunctionCalls(AgentRunResponse agentResponse) + private static IEnumerable FilterActionableContent(AgentRunResponse agentResponse) { HashSet functionResultIds = [.. agentResponse.Messages @@ -117,21 +147,21 @@ internal sealed class InvokeAzureAgentExecutor(InvokeAzureAgent model, WorkflowA .OfType() .Select(functionCall => functionCall.CallId))]; - List functionCalls = []; - foreach (FunctionCallContent functionCall in agentResponse.Messages.SelectMany(m => m.Contents.OfType())) + foreach (ChatMessage responseMessage in agentResponse.Messages) { - if (!functionResultIds.Contains(functionCall.CallId)) + if (responseMessage.Contents.Any(content => content is UserInputRequestContent)) { - functionCalls.Add(functionCall); + yield return responseMessage; + continue; + } + + if (responseMessage.Contents.OfType().Any(functionCall => !functionResultIds.Contains(functionCall.CallId))) + { + yield return responseMessage; } } - - return functionCalls; } - private static List GetUserInputRequests(AgentRunResponse agentResponse) => - agentResponse.Messages.SelectMany(m => m.Contents.OfType()).ToList(); - private string? GetConversationId() { if (this.Model.ConversationId is null) @@ -149,18 +179,6 @@ internal sealed class InvokeAzureAgentExecutor(InvokeAzureAgent model, WorkflowA this.AgentUsage.Name, $"{nameof(this.Model)}.{nameof(this.Model.Agent)}.{nameof(this.Model.Agent.Name)}")).Value; - private string? GetAdditionalInstructions() - { - string? additionalInstructions = null; - - if (this.AgentInput?.AdditionalInstructions is not null) - { - additionalInstructions = this.Engine.Format(this.AgentInput.AdditionalInstructions); - } - - return additionalInstructions; - } - private bool GetAutoSendValue() { if (this.AgentOutput?.AutoSend is null) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/QuestionExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/QuestionExecutor.cs index 145567f2ce..40dc5ce31e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/QuestionExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/QuestionExecutor.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.Agents.AI.Workflows.Declarative.Entities; @@ -75,22 +76,22 @@ internal sealed class QuestionExecutor(Question model, WorkflowAgentProvider age public async ValueTask PrepareResponseAsync(IWorkflowContext context, ActionExecutorResult message, CancellationToken cancellationToken) { int count = await this._promptCount.ReadAsync(context).ConfigureAwait(false); - AnswerRequest inputRequest = new(this.FormatPrompt(this.Model.Prompt)); + ExternalInputRequest inputRequest = new(this.FormatPrompt(this.Model.Prompt)); await context.SendMessageAsync(inputRequest, cancellationToken).ConfigureAwait(false); await this._promptCount.WriteAsync(context, count + 1).ConfigureAwait(false); } - public async ValueTask CaptureResponseAsync(IWorkflowContext context, AnswerResponse message, CancellationToken cancellationToken) + public async ValueTask CaptureResponseAsync(IWorkflowContext context, ExternalInputResponse response, CancellationToken cancellationToken) { FormulaValue? extractedValue = null; - if (message.Value is null) + if (!response.HasMessages) { string unrecognizedResponse = this.FormatPrompt(this.Model.UnrecognizedPrompt); await context.AddEventAsync(new MessageActivityEvent(unrecognizedResponse.Trim()), cancellationToken).ConfigureAwait(false); } else { - EntityExtractionResult entityResult = EntityExtractor.Parse(this.Model.Entity, message.Value.Text); + EntityExtractionResult entityResult = EntityExtractor.Parse(this.Model.Entity, string.Concat(response.Messages.Select(message => message.Text))); if (entityResult.IsValid) { extractedValue = entityResult.Value; @@ -121,7 +122,7 @@ internal sealed class QuestionExecutor(Question model, WorkflowAgentProvider age if (workflowConversationId is not null) { // Input message always defined if values has been extracted. - ChatMessage input = message.Value!; + ChatMessage input = response.Messages.Last(); await agentProvider.CreateMessageAsync(workflowConversationId, input, cancellationToken).ConfigureAwait(false); await context.SetLastMessageAsync(input).ConfigureAwait(false); } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/RequestExternalInputExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/RequestExternalInputExecutor.cs new file mode 100644 index 0000000000..2c35dc18e9 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/RequestExternalInputExecutor.cs @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Workflows.Declarative.Events; +using Microsoft.Agents.AI.Workflows.Declarative.Extensions; +using Microsoft.Agents.AI.Workflows.Declarative.Interpreter; +using Microsoft.Agents.AI.Workflows.Declarative.PowerFx; +using Microsoft.Bot.ObjectModel; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Workflows.Declarative.ObjectModel; + +internal sealed class RequestExternalInputExecutor(RequestExternalInput model, WorkflowAgentProvider agentProvider, WorkflowFormulaState state) + : DeclarativeActionExecutor(model, state) +{ + public static class Steps + { + public static string Input(string id) => $"{id}_{nameof(Input)}"; + public static string Capture(string id) => $"{id}_{nameof(Capture)}"; + } + + protected override bool IsDiscreteAction => false; + protected override bool EmitResultEvent => false; + + protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken = default) + { + ExternalInputRequest inputRequest = new(new AgentRunResponse()); + + await context.SendMessageAsync(inputRequest, cancellationToken).ConfigureAwait(false); + + return default; + } + + public async ValueTask CaptureResponseAsync(IWorkflowContext context, ExternalInputResponse response, CancellationToken cancellationToken) + { + string? workflowConversationId = context.GetWorkflowConversation(); + if (workflowConversationId is not null) + { + foreach (ChatMessage inputMessage in response.Messages) + { + await agentProvider.CreateMessageAsync(workflowConversationId, inputMessage, cancellationToken).ConfigureAwait(false); + } + } + await context.SetLastMessageAsync(response.Messages.Last()).ConfigureAwait(false); + await this.AssignAsync(this.Model.Variable?.Path, response.Messages.ToFormula(), context).ConfigureAwait(false); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/SetVariableExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/SetVariableExecutor.cs index 449e982982..81ed6e30d3 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/SetVariableExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/SetVariableExecutor.cs @@ -8,7 +8,6 @@ using Microsoft.Agents.AI.Workflows.Declarative.PowerFx; using Microsoft.Bot.ObjectModel; using Microsoft.Bot.ObjectModel.Abstractions; using Microsoft.PowerFx.Types; -using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI.Workflows.Declarative.ObjectModel; @@ -17,17 +16,15 @@ internal sealed class SetVariableExecutor(SetVariable model, WorkflowFormulaStat { protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken = default) { - PropertyPath variablePath = Throw.IfNull(this.Model.Variable?.Path, $"{nameof(this.Model)}.{nameof(model.Variable)}"); - if (this.Model.Value is null) { - await this.AssignAsync(variablePath, FormulaValue.NewBlank(), context).ConfigureAwait(false); + await this.AssignAsync(this.Model.Variable?.Path, FormulaValue.NewBlank(), context).ConfigureAwait(false); } else { EvaluationResult expressionResult = this.Evaluator.GetValue(this.Model.Value); - await this.AssignAsync(variablePath, expressionResult.Value.ToFormula(), context).ConfigureAwait(false); + await this.AssignAsync(this.Model.Variable?.Path, expressionResult.Value.ToFormula(), context).ConfigureAwait(false); } return default; diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/Functions/AgentMessage.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/Functions/AgentMessage.cs new file mode 100644 index 0000000000..927a842478 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/Functions/AgentMessage.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Extensions.AI; +using Microsoft.PowerFx.Types; + +namespace Microsoft.Agents.AI.Workflows.Declarative.PowerFx.Functions; + +internal sealed class AgentMessage : MessageFunction +{ + public const string FunctionName = nameof(AgentMessage); + + public AgentMessage() : base(FunctionName) { } + + public static FormulaValue Execute(StringValue input) => Create(ChatRole.Assistant, input); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/Functions/MessageFunction.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/Functions/MessageFunction.cs new file mode 100644 index 0000000000..e9d52c2e15 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/Functions/MessageFunction.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.Workflows.Declarative.Extensions; +using Microsoft.Extensions.AI; +using Microsoft.PowerFx; +using Microsoft.PowerFx.Types; + +namespace Microsoft.Agents.AI.Workflows.Declarative.PowerFx.Functions; + +internal abstract class MessageFunction : ReflectionFunction +{ + protected MessageFunction(string functionName) + : base(functionName, FormulaType.String, FormulaType.String) + { } + + protected static FormulaValue Create(ChatRole role, StringValue input) => + string.IsNullOrEmpty(input.Value) ? + FormulaValue.NewBlank(RecordType.Empty()) : + FormulaValue.NewRecordFromFields( + new NamedValue(TypeSchema.Discriminator, nameof(ChatMessage).ToFormula()), + new NamedValue(TypeSchema.Message.Fields.Role, FormulaValue.New(role.Value)), + new NamedValue( + TypeSchema.Message.Fields.Content, + FormulaValue.NewTable( + RecordType.Empty() + .Add(TypeSchema.Message.Fields.ContentType, FormulaType.String) + .Add(TypeSchema.Message.Fields.ContentValue, FormulaType.String), + [ + FormulaValue.NewRecordFromFields( + new NamedValue(TypeSchema.Message.Fields.ContentType, FormulaValue.New(TypeSchema.Message.ContentTypes.Text)), + new NamedValue(TypeSchema.Message.Fields.ContentValue, input)) + ] + ) + ) + ); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/Functions/MessageText.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/Functions/MessageText.cs new file mode 100644 index 0000000000..ff9f7d499e --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/Functions/MessageText.cs @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using Microsoft.PowerFx; +using Microsoft.PowerFx.Types; + +namespace Microsoft.Agents.AI.Workflows.Declarative.PowerFx.Functions; + +internal static class MessageText +{ + public const string FunctionName = nameof(MessageText); + + public sealed class StringInput() + : ReflectionFunction(FunctionName, FormulaType.String, FormulaType.String) + { + public static FormulaValue Execute(StringValue input) => input; + } + + public sealed class RecordInput() : ReflectionFunction(FunctionName, FormulaType.String, RecordType.Empty()) + { + public static FormulaValue Execute(RecordValue input) => FormulaValue.New(GetTextFromRecord(input)); + } + + public sealed class TableInput() : ReflectionFunction(FunctionName, FormulaType.String, TableType.Empty()) + { + public static FormulaValue Execute(TableValue tableValue) + { + return FormulaValue.New(string.Join("\n", GetText())); + + IEnumerable GetText() + { + foreach (DValue row in tableValue.Rows) + { + string text = GetTextFromRecord(row.Value); + if (!string.IsNullOrWhiteSpace(text)) + { + yield return text; + } + } + } + } + } + + private static string GetTextFromRecord(RecordValue recordValue) + { + FormulaValue textValue = recordValue.GetField(TypeSchema.Message.Fields.Text); + + return textValue switch + { + StringValue stringValue => stringValue.Value.Trim(), + _ => string.Empty, + }; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/Functions/UserMessage.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/Functions/UserMessage.cs index 1d4510b11d..968431623a 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/Functions/UserMessage.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/Functions/UserMessage.cs @@ -1,38 +1,15 @@ // Copyright (c) Microsoft. All rights reserved. -using Microsoft.Agents.AI.Workflows.Declarative.Extensions; using Microsoft.Extensions.AI; -using Microsoft.PowerFx; using Microsoft.PowerFx.Types; namespace Microsoft.Agents.AI.Workflows.Declarative.PowerFx.Functions; -internal sealed class UserMessage : ReflectionFunction +internal sealed class UserMessage : MessageFunction { public const string FunctionName = nameof(UserMessage); - public UserMessage() - : base(FunctionName, FormulaType.String, FormulaType.String) - { } + public UserMessage() : base(FunctionName) { } - public static FormulaValue Execute(StringValue input) => - string.IsNullOrEmpty(input.Value) ? - FormulaValue.NewBlank(RecordType.Empty()) : - FormulaValue.NewRecordFromFields( - new NamedValue(TypeSchema.Discriminator, nameof(ChatMessage).ToFormula()), - new NamedValue(TypeSchema.Message.Fields.Role, FormulaValue.New(ChatRole.User.Value)), - new NamedValue( - TypeSchema.Message.Fields.Content, - FormulaValue.NewTable( - RecordType.Empty() - .Add(TypeSchema.Message.Fields.ContentType, FormulaType.String) - .Add(TypeSchema.Message.Fields.ContentValue, FormulaType.String), - [ - FormulaValue.NewRecordFromFields( - new NamedValue(TypeSchema.Message.Fields.ContentType, FormulaValue.New(TypeSchema.Message.ContentTypes.Text)), - new NamedValue(TypeSchema.Message.Fields.ContentValue, input)) - ] - ) - ) - ); + public static FormulaValue Execute(StringValue input) => Create(ChatRole.User, input); } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/RecalcEngineFactory.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/RecalcEngineFactory.cs index 6d0364603c..2087307504 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/RecalcEngineFactory.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/RecalcEngineFactory.cs @@ -38,7 +38,11 @@ internal static class RecalcEngineFactory } config.EnableSetFunction(); + config.AddFunction(new AgentMessage()); config.AddFunction(new UserMessage()); + config.AddFunction(new MessageText.StringInput()); + config.AddFunction(new MessageText.RecordInput()); + config.AddFunction(new MessageText.TableInput()); return config; } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/README.md b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/README.md index 3a8fa6031d..4202b89b82 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/README.md +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/README.md @@ -55,7 +55,7 @@ Please refer to the [README](../../samples/GettingStarted/Workflows/Declarative/ |**ConditionItem**|Represents a single conditional statement within a group. It evaluates a specific logical condition and determines the next step in the flow. |**ContinueLoop**|Skips the remaining steps in the current iteration and continues with the next loop cycle. Commonly used to bypass specific cases without exiting the loop entirely. |**EndConversation**|Terminates the current conversation session. It ensures any necessary cleanup or final actions are performed before closing. -|**EndDialog**|Ends the current dialog or sub-dialog within a broader conversation flow. This helps modularize complex interactions. +|**EndWorkflow**|Ends the current workflow or sub-workflow within a broader conversation flow. This helps modularize complex interactions. |**Foreach**|Iterates through a collection of items, executing a set of actions for each. Ideal for processing lists or batch operations. |**GotoAction**|Jumps directly to a specified action within the workflow. Enables non-linear navigation in the logic flow. diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/WorkflowAgentProvider.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/WorkflowAgentProvider.cs index 1cb79db9f7..e0967ab376 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/WorkflowAgentProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/WorkflowAgentProvider.cs @@ -1,9 +1,11 @@ // Copyright (c) Microsoft. All rights reserved. using System.Collections.Generic; +using System.Text.Json.Nodes; using System.Threading; using System.Threading.Tasks; using Microsoft.Agents.AI.Workflows.Declarative.Events; +using Microsoft.Agents.AI.Workflows.Declarative.Extensions; using Microsoft.Extensions.AI; namespace Microsoft.Agents.AI.Workflows.Declarative; @@ -16,8 +18,8 @@ public abstract class WorkflowAgentProvider /// /// Gets or sets a collection of additional tools an agent is able to automatically invoke. /// If an agent is configured with a function tool that is not available, a is executed - /// that provides an that describes the function calls requested. The caller may - /// then respond with a corrsponding that includes the results of the function calls. + /// that provides an that describes the function calls requested. The caller may + /// then respond with a corrsponding that includes the results of the function calls. /// /// /// These will not impact the requests sent to the model by the . @@ -56,14 +58,6 @@ public abstract class WorkflowAgentProvider /// public bool AllowMultipleToolCalls { get; init; } - /// - /// Asynchronously retrieves an AI agent by its unique identifier. - /// - /// The unique identifier of the AI agent to retrieve. Cannot be null or empty. - /// A token that propagates notification when operation should be canceled. - /// The task result contains the associated. - public abstract Task GetAgentAsync(string agentId, CancellationToken cancellationToken = default); - /// /// Asynchronously creates a new conversation and returns its unique identifier. /// @@ -88,6 +82,24 @@ public abstract class WorkflowAgentProvider /// The requested message public abstract Task GetMessageAsync(string conversationId, string messageId, CancellationToken cancellationToken = default); + /// + /// Asynchronously retrieves an AI agent by its unique identifier. + /// + /// The unique identifier of the AI agent to retrieve. Cannot be null or empty. + /// An optional agent version. + /// Optional identifier of the target conversation. + /// The messages to include in the invocation. + /// Optional input arguments for agents that provide support. + /// A token that propagates notification when operation should be canceled. + /// Asynchronous set of . + public abstract IAsyncEnumerable InvokeAgentAsync( + string agentId, + string? agentVersion, + string? conversationId, + IEnumerable? messages, + IDictionary? inputArguments, + CancellationToken cancellationToken = default); + /// /// Retrieves a set of messages from a conversation. /// @@ -105,4 +117,14 @@ public abstract class WorkflowAgentProvider string? before = null, bool newestFirst = false, CancellationToken cancellationToken = default); + + /// + /// Utility method to convert a dictionary of input arguments to a JsonNode. + /// + /// The dictionary of input arguments. + /// A JsonNode representing the input arguments. + protected static JsonNode ConvertDictionaryToJson(IDictionary inputArguments) + { + return inputArguments.ToFormula().ToJson(); + } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/StatefulExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/StatefulExecutor.cs index 344134369d..12079289a4 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/StatefulExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/StatefulExecutor.cs @@ -110,7 +110,7 @@ public abstract class StatefulExecutor : Executor { if (!skipCache && !context.ConcurrentRunsEnabled) { - TState newState = await invocation(this._stateCache ?? (this._initialStateFactory()), + TState newState = await invocation(this._stateCache ?? this._initialStateFactory(), context, cancellationToken).ConfigureAwait(false) ?? this._initialStateFactory(); diff --git a/dotnet/src/Shared/CodeTests/README.md b/dotnet/src/Shared/CodeTests/README.md new file mode 100644 index 0000000000..e1282f1778 --- /dev/null +++ b/dotnet/src/Shared/CodeTests/README.md @@ -0,0 +1,11 @@ +# Build Code + +Re-usable utility for building C# code in tests. + +To use this in your project, add the following to your `.csproj` file: + +```xml + + true + +``` diff --git a/dotnet/src/Shared/Foundry/Agents/AgentFactory.cs b/dotnet/src/Shared/Foundry/Agents/AgentFactory.cs new file mode 100644 index 0000000000..e179058e69 --- /dev/null +++ b/dotnet/src/Shared/Foundry/Agents/AgentFactory.cs @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft. All rights reserved. + +#pragma warning disable IDE0005 + +using System; +using System.Threading.Tasks; +using Azure.AI.Projects; +using Azure.AI.Projects.OpenAI; + +namespace Shared.Foundry; + +internal static class AgentFactory +{ + public static async ValueTask CreateAgentAsync( + this AIProjectClient aiProjectClient, + string agentName, + AgentDefinition agentDefinition, + string agentDescription) + { + AgentVersionCreationOptions options = + new(agentDefinition) + { + Description = agentDescription, + Metadata = + { + { "deleteme", bool.TrueString }, + { "test", bool.TrueString }, + }, + }; + + AgentVersion agentVersion = await aiProjectClient.Agents.CreateAgentVersionAsync(agentName, options).ConfigureAwait(false); + + Console.ForegroundColor = ConsoleColor.Cyan; + try + { + Console.WriteLine($"PROMPT AGENT: {agentVersion.Name}:{agentVersion.Version}"); + } + finally + { + Console.ResetColor(); + } + + return agentVersion; + } +} diff --git a/dotnet/src/Shared/Foundry/Agents/README.md b/dotnet/src/Shared/Foundry/Agents/README.md new file mode 100644 index 0000000000..370068c555 --- /dev/null +++ b/dotnet/src/Shared/Foundry/Agents/README.md @@ -0,0 +1,11 @@ +# Foundry Agents + +Shared patterns for creating and utilizing Foundry agents. + +To use this in your project, add the following to your `.csproj` file: + +```xml + + true + +``` diff --git a/dotnet/src/Shared/Workflows/Execution/README.md b/dotnet/src/Shared/Workflows/Execution/README.md new file mode 100644 index 0000000000..4a885ae651 --- /dev/null +++ b/dotnet/src/Shared/Workflows/Execution/README.md @@ -0,0 +1,11 @@ +# Workflow Execution + +Common support for workflow execution. + +To use this in your project, add the following to your `.csproj` file: + +```xml + + true + +``` diff --git a/dotnet/src/Shared/Workflows/Execution/WorkflowFactory.cs b/dotnet/src/Shared/Workflows/Execution/WorkflowFactory.cs new file mode 100644 index 0000000000..08a39afe5e --- /dev/null +++ b/dotnet/src/Shared/Workflows/Execution/WorkflowFactory.cs @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Azure.Identity; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Agents.AI.Workflows.Declarative; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Shared.Workflows; + +internal sealed class WorkflowFactory(string workflowFile, Uri foundryEndpoint) +{ + public IList Functions { get; init; } = []; + + public IConfiguration? Configuration { get; init; } + + // Assign to continue an existing conversation + public string? ConversationId { get; init; } + + // Assign to enable logging + public ILoggerFactory LoggerFactory { get; init; } = NullLoggerFactory.Instance; + + /// + /// Create the workflow from the declarative YAML. Includes definition of the + /// and the associated . + /// + public Workflow CreateWorkflow() + { + // Create the agent provider that will service agent requests within the workflow. + AzureAgentProvider agentProvider = new(foundryEndpoint, new AzureCliCredential()) + { + // Functions included here will be auto-executed by the framework. + Functions = this.Functions + }; + + // Define the workflow options. + DeclarativeWorkflowOptions options = + new(agentProvider) + { + Configuration = this.Configuration, + ConversationId = this.ConversationId, + LoggerFactory = this.LoggerFactory, + }; + + string workflowPath = Path.Combine(AppContext.BaseDirectory, workflowFile); + + // Use DeclarativeWorkflowBuilder to build a workflow based on a YAML file. + return DeclarativeWorkflowBuilder.Build(workflowPath, options); + } +} diff --git a/dotnet/src/Shared/Workflows/Execution/WorkflowRunner.cs b/dotnet/src/Shared/Workflows/Execution/WorkflowRunner.cs new file mode 100644 index 0000000000..75f451bf8e --- /dev/null +++ b/dotnet/src/Shared/Workflows/Execution/WorkflowRunner.cs @@ -0,0 +1,378 @@ +// Copyright (c) Microsoft. All rights reserved. + +// Uncomment to output unknown content types for debugging. +//#define DEBUG_OUTPUT + +using System.Diagnostics; +using System.Text.Json; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Agents.AI.Workflows.Checkpointing; +using Microsoft.Agents.AI.Workflows.Declarative; +using Microsoft.Agents.AI.Workflows.Declarative.Events; +using Microsoft.Agents.AI.Workflows.Declarative.Kit; +using Microsoft.Extensions.AI; +using OpenAI.Responses; + +namespace Shared.Workflows; + +// Types are for evaluation purposes only and is subject to change or removal in future updates. +#pragma warning disable OPENAI001 +#pragma warning disable OPENAICUA001 +#pragma warning disable MEAI001 + +internal sealed class WorkflowRunner +{ + private Dictionary FunctionMap { get; } + private CheckpointInfo? LastCheckpoint { get; set; } + + public static void Notify(string message, ConsoleColor? color = null) + { + Console.ForegroundColor = color ?? ConsoleColor.Cyan; + try + { + Console.WriteLine(message); + } + finally + { + Console.ResetColor(); + } + } + + /// + /// When enabled, checkpoints will be persisted to disk as JSON files. + /// Otherwise an in-memory checkpoint store that will not persist checkpoints + /// beyond the lifetime of the process. + /// + public bool UseJsonCheckpoints { get; init; } + + public WorkflowRunner(params IEnumerable functions) + { + this.FunctionMap = functions.ToDictionary(f => f.Name); + } + + public async Task ExecuteAsync(Func workflowProvider, string input) + { + Workflow workflow = workflowProvider.Invoke(); + + CheckpointManager checkpointManager; + + if (this.UseJsonCheckpoints) + { + // Use a file-system based JSON checkpoint store to persist checkpoints to disk. + DirectoryInfo checkpointFolder = Directory.CreateDirectory(Path.Combine(".", $"chk-{DateTime.Now:yyMMdd-hhmmss-ff}")); + checkpointManager = CheckpointManager.CreateJson(new FileSystemJsonCheckpointStore(checkpointFolder)); + } + else + { + // Use an in-memory checkpoint store that will not persist checkpoints beyond the lifetime of the process. + checkpointManager = CheckpointManager.CreateInMemory(); + } + + Checkpointed run = await InProcessExecution.StreamAsync(workflow, input, checkpointManager).ConfigureAwait(false); + + bool isComplete = false; + ExternalResponse? requestResponse = null; + do + { + ExternalRequest? externalRequest = await this.MonitorAndDisposeWorkflowRunAsync(run, requestResponse).ConfigureAwait(false); + if (externalRequest is not null) + { + Notify("\nWORKFLOW: Yield\n", ConsoleColor.DarkYellow); + + if (this.LastCheckpoint is null) + { + throw new InvalidOperationException("Checkpoint information missing after external request."); + } + + // Process the external request. + object response = await this.HandleExternalRequestAsync(externalRequest).ConfigureAwait(false); + requestResponse = externalRequest.CreateResponse(response); + + // Let's resume on an entirely new workflow instance to demonstrate checkpoint portability. + workflow = workflowProvider.Invoke(); + + // Restore the latest checkpoint. + Debug.WriteLine($"RESTORE #{this.LastCheckpoint.CheckpointId}"); + Notify("WORKFLOW: Restore", ConsoleColor.DarkYellow); + + run = await InProcessExecution.ResumeStreamAsync(workflow, this.LastCheckpoint, checkpointManager, run.Run.RunId).ConfigureAwait(false); + } + else + { + isComplete = true; + } + } + while (!isComplete); + + Notify("\nWORKFLOW: Done!\n"); + } + + public async Task MonitorAndDisposeWorkflowRunAsync(Checkpointed run, ExternalResponse? response = null) + { +#pragma warning disable CA2007 // Consider calling ConfigureAwait on the awaited task + await using IAsyncDisposable disposeRun = run; +#pragma warning restore CA2007 // Consider calling ConfigureAwait on the awaited task + + bool hasStreamed = false; + string? messageId = null; + + bool shouldExit = false; + ExternalRequest? externalResponse = null; + + if (response is not null) + { + await run.Run.SendResponseAsync(response).ConfigureAwait(false); + } + + await foreach (WorkflowEvent workflowEvent in run.Run.WatchStreamAsync().ConfigureAwait(false)) + { + switch (workflowEvent) + { + case ExecutorInvokedEvent executorInvoked: + Debug.WriteLine($"EXECUTOR ENTER #{executorInvoked.ExecutorId}"); + break; + + case ExecutorCompletedEvent executorCompleted: + Debug.WriteLine($"EXECUTOR EXIT #{executorCompleted.ExecutorId}"); + break; + + case DeclarativeActionInvokedEvent actionInvoked: + Debug.WriteLine($"ACTION ENTER #{actionInvoked.ActionId} [{actionInvoked.ActionType}]"); + break; + + case DeclarativeActionCompletedEvent actionComplete: + Debug.WriteLine($"ACTION EXIT #{actionComplete.ActionId} [{actionComplete.ActionType}]"); + break; + + case ExecutorFailedEvent executorFailure: + Debug.WriteLine($"STEP ERROR #{executorFailure.ExecutorId}: {executorFailure.Data?.Message ?? "Unknown"}"); + break; + + case WorkflowErrorEvent workflowError: + throw workflowError.Data as Exception ?? new InvalidOperationException("Unexpected failure..."); + + case SuperStepCompletedEvent checkpointCompleted: + this.LastCheckpoint = checkpointCompleted.CompletionInfo?.Checkpoint; + Debug.WriteLine($"CHECKPOINT x{checkpointCompleted.StepNumber} [{this.LastCheckpoint?.CheckpointId ?? "(none)"}]"); + if (externalResponse is not null) + { + shouldExit = true; + } + break; + + case RequestInfoEvent requestInfo: + Debug.WriteLine($"REQUEST #{requestInfo.Request.RequestId}"); + externalResponse = requestInfo.Request; + break; + + case ConversationUpdateEvent invokeEvent: + Debug.WriteLine($"CONVERSATION: {invokeEvent.Data}"); + break; + + case MessageActivityEvent activityEvent: + Console.ForegroundColor = ConsoleColor.Cyan; + Console.WriteLine("\nACTIVITY:"); + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine(activityEvent.Message.Trim()); + break; + + case AgentRunUpdateEvent streamEvent: + if (!string.Equals(messageId, streamEvent.Update.MessageId, StringComparison.Ordinal)) + { + hasStreamed = false; + messageId = streamEvent.Update.MessageId; + + if (messageId is not null) + { + string? agentName = streamEvent.Update.AuthorName ?? streamEvent.Update.AgentId ?? nameof(ChatRole.Assistant); + Console.ForegroundColor = ConsoleColor.Cyan; + Console.Write($"\n{agentName.ToUpperInvariant()}:"); + Console.ForegroundColor = ConsoleColor.DarkGray; + Console.WriteLine($" [{messageId}]"); + } + } + + ChatResponseUpdate? chatUpdate = streamEvent.Update.RawRepresentation as ChatResponseUpdate; + switch (chatUpdate?.RawRepresentation) + { + case ImageGenerationCallResponseItem messageUpdate: + await DownloadFileContentAsync(Path.GetFileName("response.png"), messageUpdate.ImageResultBytes).ConfigureAwait(false); + break; + + case FunctionCallResponseItem actionUpdate: + Console.ForegroundColor = ConsoleColor.White; + Console.Write($"Calling tool: {actionUpdate.FunctionName}"); + Console.ForegroundColor = ConsoleColor.DarkGray; + Console.WriteLine($" [{actionUpdate.CallId}]"); + break; + + case McpToolCallItem actionUpdate: + Console.ForegroundColor = ConsoleColor.White; + Console.Write($"Calling tool: {actionUpdate.ToolName}"); + Console.ForegroundColor = ConsoleColor.DarkGray; + Console.WriteLine($" [{actionUpdate.Id}]"); + break; + } + + try + { + Console.ResetColor(); + Console.Write(streamEvent.Update.Text); + hasStreamed |= !string.IsNullOrEmpty(streamEvent.Update.Text); + } + finally + { + Console.ResetColor(); + } + break; + + case AgentRunResponseEvent messageEvent: + try + { + if (hasStreamed) + { + Console.WriteLine(); + } + + if (messageEvent.Response.Usage is not null) + { + Console.ForegroundColor = ConsoleColor.DarkGray; + Console.WriteLine($"[Tokens Total: {messageEvent.Response.Usage.TotalTokenCount}, Input: {messageEvent.Response.Usage.InputTokenCount}, Output: {messageEvent.Response.Usage.OutputTokenCount}]"); + } + } + finally + { + Console.ResetColor(); + } + break; + + default: +#if DEBUG_OUTPUT + Debug.WriteLine($"UNHANDLED: {workflowEvent.GetType().Name}"); +#endif + break; + } + + if (shouldExit) + { + break; + } + } + + return externalResponse; + } + + /// + /// Handle request for external input. + /// + private async ValueTask HandleExternalRequestAsync(ExternalRequest request) + { + ExternalInputRequest inputRequest = + request.DataAs() ?? + throw new InvalidOperationException($"Expected external request type: {request.GetType().Name}."); + + List responseMessages = []; + + foreach (ChatMessage message in inputRequest.AgentResponse.Messages) + { + await foreach (ChatMessage responseMessage in this.ProcessInputMessageAsync(message).ConfigureAwait(false)) + { + responseMessages.Add(responseMessage); + } + } + + if (responseMessages.Count == 0) + { + // Must be request for user input. + responseMessages.Add(HandleUserInputRequest(inputRequest)); + } + + Console.WriteLine(); + + return new ExternalInputResponse(responseMessages); + } + + private async IAsyncEnumerable ProcessInputMessageAsync(ChatMessage message) + { + foreach (AIContent requestItem in message.Contents) + { + ChatMessage? responseMessage = + requestItem switch + { + FunctionCallContent functionCall => await InvokeFunctionAsync(functionCall).ConfigureAwait(false), + FunctionApprovalRequestContent functionApprovalRequest => ApproveFunction(functionApprovalRequest), + McpServerToolApprovalRequestContent mcpApprovalRequest => ApproveMCP(mcpApprovalRequest), + _ => HandleUnknown(requestItem), + }; + + if (responseMessage is not null) + { + yield return responseMessage; + } + } + + ChatMessage? HandleUnknown(AIContent request) + { +#if DEBUG_OUTPUT + Notify($"INPUT - Unknown: {request.GetType().Name} [{request.RawRepresentation?.GetType().Name ?? "*"}]"); +#endif + return null; + } + + ChatMessage ApproveFunction(FunctionApprovalRequestContent functionApprovalRequest) + { + Notify($"INPUT - Approving Function: {functionApprovalRequest.FunctionCall.Name}"); + return new ChatMessage(ChatRole.User, [functionApprovalRequest.CreateResponse(approved: true)]); + } + + ChatMessage ApproveMCP(McpServerToolApprovalRequestContent mcpApprovalRequest) + { + Notify($"INPUT - Approving MCP: {mcpApprovalRequest.ToolCall.ToolName}"); + return new ChatMessage(ChatRole.User, [mcpApprovalRequest.CreateResponse(approved: true)]); + } + + async Task InvokeFunctionAsync(FunctionCallContent functionCall) + { + Notify($"INPUT - Executing Function: {functionCall.Name}"); + AIFunction functionTool = this.FunctionMap[functionCall.Name]; + AIFunctionArguments? functionArguments = functionCall.Arguments is null ? null : new(functionCall.Arguments.NormalizePortableValues()); + object? result = await functionTool.InvokeAsync(functionArguments).ConfigureAwait(false); + return new ChatMessage(ChatRole.Tool, [new FunctionResultContent(functionCall.CallId, JsonSerializer.Serialize(result))]); + } + } + + private static ChatMessage HandleUserInputRequest(ExternalInputRequest request) + { + string prompt = + string.IsNullOrWhiteSpace(request.AgentResponse.Text) || request.AgentResponse.ResponseId is not null ? + "INPUT:" : + request.AgentResponse.Text; + + string? userInput; + do + { + Console.ForegroundColor = ConsoleColor.DarkGreen; + Console.Write($"{prompt} "); + Console.ForegroundColor = ConsoleColor.White; + userInput = Console.ReadLine(); + } + while (string.IsNullOrWhiteSpace(userInput)); + + return new ChatMessage(ChatRole.User, userInput); + } + + private static async ValueTask DownloadFileContentAsync(string filename, BinaryData content) + { + string filePath = Path.Combine(Path.GetTempPath(), Path.GetFileName(filename)); + filePath = Path.ChangeExtension(filePath, ".png"); + + await File.WriteAllBytesAsync(filePath, content.ToArray()).ConfigureAwait(false); + + Process.Start( + new ProcessStartInfo + { + FileName = "cmd.exe", + Arguments = $"/C start {filePath}" + }); + } +} diff --git a/dotnet/src/Shared/Workflows/Settings/Application.cs b/dotnet/src/Shared/Workflows/Settings/Application.cs new file mode 100644 index 0000000000..de8eb51534 --- /dev/null +++ b/dotnet/src/Shared/Workflows/Settings/Application.cs @@ -0,0 +1,80 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Reflection; +using Microsoft.Extensions.Configuration; + +namespace Shared.Workflows; + +internal static class Application +{ + /// + /// Configuration key used to identify the Foundry project endpoint. + /// + public static class Settings + { + public const string FoundryEndpoint = "FOUNDRY_PROJECT_ENDPOINT"; + public const string FoundryModelMini = "FOUNDRY_MODEL_DEPLOYMENT_NAME"; + public const string FoundryModelFull = "FOUNDRY_MEDIA_DEPLOYMENT_NAME"; + public const string FoundryGroundingTool = "FOUNDRY_CONNECTION_GROUNDING_TOOL"; + } + + public static string GetInput(string[] args) + { + string? input = args.FirstOrDefault(); + + try + { + Console.ForegroundColor = ConsoleColor.DarkGreen; + + Console.Write("\nINPUT: "); + + Console.ForegroundColor = ConsoleColor.White; + + if (!string.IsNullOrWhiteSpace(input)) + { + Console.WriteLine(input); + return input; + } + while (string.IsNullOrWhiteSpace(input)) + { + input = Console.ReadLine(); + } + + return input.Trim(); + } + finally + { + Console.ResetColor(); + } + } + + public static string? GetRepoFolder() + { + DirectoryInfo? current = new(Directory.GetCurrentDirectory()); + + while (current is not null) + { + if (Directory.Exists(Path.Combine(current.FullName, ".git"))) + { + return current.FullName; + } + + current = current.Parent; + } + + return null; + } + + public static string GetValue(this IConfiguration configuration, string settingName) => + configuration[settingName] ?? + throw new InvalidOperationException($"Undefined configuration setting: {settingName}"); + + /// + /// Initialize configuration and environment + /// + public static IConfigurationRoot InitializeConfig() => + new ConfigurationBuilder() + .AddUserSecrets(Assembly.GetExecutingAssembly()) + .AddEnvironmentVariables() + .Build(); +} diff --git a/dotnet/src/Shared/Workflows/Settings/README.md b/dotnet/src/Shared/Workflows/Settings/README.md new file mode 100644 index 0000000000..80b176131b --- /dev/null +++ b/dotnet/src/Shared/Workflows/Settings/README.md @@ -0,0 +1,11 @@ +# Workflow Settings + +Common support configuration and environment used in workflow samples. + +To use this in your project, add the following to your `.csproj` file: + +```xml + + true + +``` diff --git a/dotnet/tests/.gitignore b/dotnet/tests/.gitignore new file mode 100644 index 0000000000..8392c905c6 --- /dev/null +++ b/dotnet/tests/.gitignore @@ -0,0 +1 @@ +launchSettings.json \ No newline at end of file diff --git a/dotnet/tests/AgentConformance.IntegrationTests/RunStreamingTests.cs b/dotnet/tests/AgentConformance.IntegrationTests/RunStreamingTests.cs index 984356affb..a2da3e0d6e 100644 --- a/dotnet/tests/AgentConformance.IntegrationTests/RunStreamingTests.cs +++ b/dotnet/tests/AgentConformance.IntegrationTests/RunStreamingTests.cs @@ -4,6 +4,7 @@ using System; using System.Linq; using System.Threading.Tasks; using AgentConformance.IntegrationTests.Support; +using Microsoft.Agents.AI; using Microsoft.Extensions.AI; namespace AgentConformance.IntegrationTests; @@ -16,6 +17,8 @@ namespace AgentConformance.IntegrationTests; public abstract class RunStreamingTests(Func createAgentFixture) : AgentTests(createAgentFixture) where TAgentFixture : IAgentFixture { + public virtual Func> AgentRunOptionsFactory { get; set; } = () => Task.FromResult(default(AgentRunOptions)); + [RetryFact(Constants.RetryCount, Constants.RetryDelay)] public virtual async Task RunWithNoMessageDoesNotFailAsync() { @@ -25,7 +28,7 @@ public abstract class RunStreamingTests(Func creat await using var cleanup = new ThreadCleanup(thread, this.Fixture); // Act - var chatResponses = await agent.RunStreamingAsync(thread).ToListAsync(); + var chatResponses = await agent.RunStreamingAsync(thread, await this.AgentRunOptionsFactory.Invoke()).ToListAsync(); } [RetryFact(Constants.RetryCount, Constants.RetryDelay)] @@ -37,7 +40,7 @@ public abstract class RunStreamingTests(Func creat await using var cleanup = new ThreadCleanup(thread, this.Fixture); // Act - var responseUpdates = await agent.RunStreamingAsync("What is the capital of France.", thread).ToListAsync(); + var responseUpdates = await agent.RunStreamingAsync("What is the capital of France.", thread, await this.AgentRunOptionsFactory.Invoke()).ToListAsync(); // Assert var chatResponseText = string.Concat(responseUpdates.Select(x => x.Text)); @@ -53,7 +56,7 @@ public abstract class RunStreamingTests(Func creat await using var cleanup = new ThreadCleanup(thread, this.Fixture); // Act - var responseUpdates = await agent.RunStreamingAsync(new ChatMessage(ChatRole.User, "What is the capital of France."), thread).ToListAsync(); + var responseUpdates = await agent.RunStreamingAsync(new ChatMessage(ChatRole.User, "What is the capital of France."), thread, await this.AgentRunOptionsFactory.Invoke()).ToListAsync(); // Assert var chatResponseText = string.Concat(responseUpdates.Select(x => x.Text)); @@ -74,7 +77,8 @@ public abstract class RunStreamingTests(Func creat new ChatMessage(ChatRole.User, "Hello."), new ChatMessage(ChatRole.User, "What is the capital of France.") ], - thread).ToListAsync(); + thread, + await this.AgentRunOptionsFactory.Invoke()).ToListAsync(); // Assert var chatResponseText = string.Concat(responseUpdates.Select(x => x.Text)); @@ -92,8 +96,9 @@ public abstract class RunStreamingTests(Func creat await using var cleanup = new ThreadCleanup(thread, this.Fixture); // Act - var responseUpdates1 = await agent.RunStreamingAsync(Q1, thread).ToListAsync(); - var responseUpdates2 = await agent.RunStreamingAsync(Q2, thread).ToListAsync(); + var options = await this.AgentRunOptionsFactory.Invoke(); + var responseUpdates1 = await agent.RunStreamingAsync(Q1, thread, options).ToListAsync(); + var responseUpdates2 = await agent.RunStreamingAsync(Q2, thread, options).ToListAsync(); // Assert var response1Text = string.Concat(responseUpdates1.Select(x => x.Text)); diff --git a/dotnet/tests/AgentConformance.IntegrationTests/RunTests.cs b/dotnet/tests/AgentConformance.IntegrationTests/RunTests.cs index f89c821455..58f8b67d1d 100644 --- a/dotnet/tests/AgentConformance.IntegrationTests/RunTests.cs +++ b/dotnet/tests/AgentConformance.IntegrationTests/RunTests.cs @@ -4,6 +4,7 @@ using System; using System.Linq; using System.Threading.Tasks; using AgentConformance.IntegrationTests.Support; +using Microsoft.Agents.AI; using Microsoft.Extensions.AI; namespace AgentConformance.IntegrationTests; @@ -16,6 +17,8 @@ namespace AgentConformance.IntegrationTests; public abstract class RunTests(Func createAgentFixture) : AgentTests(createAgentFixture) where TAgentFixture : IAgentFixture { + public virtual Func> AgentRunOptionsFactory { get; set; } = () => Task.FromResult(default(AgentRunOptions)); + [RetryFact(Constants.RetryCount, Constants.RetryDelay)] public virtual async Task RunWithNoMessageDoesNotFailAsync() { @@ -40,7 +43,7 @@ public abstract class RunTests(Func createAgentFix await using var cleanup = new ThreadCleanup(thread, this.Fixture); // Act - var response = await agent.RunAsync("What is the capital of France.", thread); + var response = await agent.RunAsync("What is the capital of France.", thread, await this.AgentRunOptionsFactory.Invoke()); // Assert Assert.NotNull(response); @@ -58,7 +61,7 @@ public abstract class RunTests(Func createAgentFix await using var cleanup = new ThreadCleanup(thread, this.Fixture); // Act - var response = await agent.RunAsync(new ChatMessage(ChatRole.User, "What is the capital of France."), thread); + var response = await agent.RunAsync(new ChatMessage(ChatRole.User, "What is the capital of France."), thread, await this.AgentRunOptionsFactory.Invoke()); // Assert Assert.NotNull(response); @@ -80,7 +83,8 @@ public abstract class RunTests(Func createAgentFix new ChatMessage(ChatRole.User, "Hello."), new ChatMessage(ChatRole.User, "What is the capital of France.") ], - thread); + thread, + await this.AgentRunOptionsFactory.Invoke()); // Assert Assert.NotNull(response); @@ -99,8 +103,9 @@ public abstract class RunTests(Func createAgentFix await using var cleanup = new ThreadCleanup(thread, this.Fixture); // Act - var result1 = await agent.RunAsync(Q1, thread); - var result2 = await agent.RunAsync(Q2, thread); + var options = await this.AgentRunOptionsFactory.Invoke(); + var result1 = await agent.RunAsync(Q1, thread, options); + var result2 = await agent.RunAsync(Q2, thread, options); // Assert Assert.Contains("Paris", result1.Text); @@ -111,8 +116,8 @@ public abstract class RunTests(Func createAgentFix Assert.Equal(2, chatHistory.Count(x => x.Role == ChatRole.User)); Assert.Equal(2, chatHistory.Count(x => x.Role == ChatRole.Assistant)); Assert.Equal(Q1, chatHistory[0].Text); - Assert.Equal(Q2, chatHistory[2].Text); Assert.Contains("Paris", chatHistory[1].Text); + Assert.Equal(Q2, chatHistory[2].Text); Assert.Contains("Vienna", chatHistory[3].Text); } } diff --git a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientAgentRunStreamingTests.cs b/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientAgentRunStreamingTests.cs new file mode 100644 index 0000000000..50ced1e64d --- /dev/null +++ b/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientAgentRunStreamingTests.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading.Tasks; +using AgentConformance.IntegrationTests; +using Microsoft.Agents.AI; + +namespace AzureAI.IntegrationTests; + +public class AIProjectClientAgentRunStreamingPreviousResponseTests() : RunStreamingTests(() => new()) +{ + [Fact(Skip = "No messages is not supported")] + public override Task RunWithNoMessageDoesNotFailAsync() + { + return Task.CompletedTask; + } +} + +public class AIProjectClientAgentRunStreamingConversationTests() : RunTests(() => new()) +{ + public override Func> AgentRunOptionsFactory => async () => + { + var conversationId = await this.Fixture.CreateConversationAsync(); + return new ChatClientAgentRunOptions(new() { ConversationId = conversationId }); + }; + + [Fact(Skip = "No messages is not supported")] + public override Task RunWithNoMessageDoesNotFailAsync() + { + return Task.CompletedTask; + } +} diff --git a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientAgentRunTests.cs b/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientAgentRunTests.cs new file mode 100644 index 0000000000..0092090401 --- /dev/null +++ b/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientAgentRunTests.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading.Tasks; +using AgentConformance.IntegrationTests; +using Microsoft.Agents.AI; + +namespace AzureAI.IntegrationTests; + +public class AIProjectClientAgentRunPreviousResponseTests() : RunTests(() => new()) +{ + [Fact(Skip = "No messages is not supported")] + public override Task RunWithNoMessageDoesNotFailAsync() + { + return Task.CompletedTask; + } +} + +public class AIProjectClientAgentRunConversationTests() : RunTests(() => new()) +{ + public override Func> AgentRunOptionsFactory => async () => + { + var conversationId = await this.Fixture.CreateConversationAsync(); + return new ChatClientAgentRunOptions(new() { ConversationId = conversationId }); + }; + + [Fact(Skip = "No messages is not supported")] + public override Task RunWithNoMessageDoesNotFailAsync() + { + return Task.CompletedTask; + } +} diff --git a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientChatClientAgentRunStreamingTests.cs b/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientChatClientAgentRunStreamingTests.cs new file mode 100644 index 0000000000..befa409d80 --- /dev/null +++ b/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientChatClientAgentRunStreamingTests.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; +using AgentConformance.IntegrationTests; + +namespace AzureAI.IntegrationTests; + +public class AIProjectClientChatClientAgentRunStreamingTests() : ChatClientAgentRunStreamingTests(() => new()) +{ + [Fact(Skip = "No messages is not supported")] + public override Task RunWithInstructionsAndNoMessageReturnsExpectedResultAsync() + { + return Task.CompletedTask; + } +} diff --git a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientChatClientAgentRunTests.cs b/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientChatClientAgentRunTests.cs new file mode 100644 index 0000000000..1af12606cb --- /dev/null +++ b/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientChatClientAgentRunTests.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; +using AgentConformance.IntegrationTests; + +namespace AzureAI.IntegrationTests; + +public class AIProjectClientChatClientAgentRunTests() : ChatClientAgentRunTests(() => new()) +{ + [Fact(Skip = "No messages is not supported")] + public override Task RunWithInstructionsAndNoMessageReturnsExpectedResultAsync() + { + return Task.CompletedTask; + } +} diff --git a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientCreateTests.cs b/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientCreateTests.cs new file mode 100644 index 0000000000..bb74c18ba6 --- /dev/null +++ b/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientCreateTests.cs @@ -0,0 +1,271 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.IO; +using System.Threading.Tasks; +using AgentConformance.IntegrationTests.Support; +using Azure.AI.Projects; +using Azure.AI.Projects.OpenAI; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using OpenAI.Files; +using OpenAI.Responses; +using Shared.IntegrationTests; + +namespace AzureAI.IntegrationTests; + +public class AIProjectClientCreateTests +{ + private static readonly AzureAIConfiguration s_config = TestConfiguration.LoadSection(); + private readonly AIProjectClient _client = new(new Uri(s_config.Endpoint), new AzureCliCredential()); + + [Theory] + [InlineData("CreateWithChatClientAgentOptionsAsync")] + [InlineData("CreateWithChatClientAgentOptionsSync")] + [InlineData("CreateWithFoundryOptionsAsync")] + [InlineData("CreateWithFoundryOptionsSync")] + public async Task CreateAgent_CreatesAgentWithCorrectMetadataAsync(string createMechanism) + { + // Arrange. + const string AgentName = "IntegrationTestAgent"; + const string AgentDescription = "An agent created during integration tests"; + const string AgentInstructions = "You are an integration test agent"; + + // Act. + var agent = createMechanism switch + { + "CreateWithChatClientAgentOptionsAsync" => await this._client.CreateAIAgentAsync( + model: s_config.DeploymentName, + options: new ChatClientAgentOptions( + instructions: AgentInstructions, + name: AgentName, + description: AgentDescription)), + "CreateWithChatClientAgentOptionsSync" => this._client.CreateAIAgent( + model: s_config.DeploymentName, + options: new ChatClientAgentOptions( + instructions: AgentInstructions, + name: AgentName, + description: AgentDescription)), + "CreateWithFoundryOptionsAsync" => await this._client.CreateAIAgentAsync( + name: AgentName, + creationOptions: new AgentVersionCreationOptions(new PromptAgentDefinition(s_config.DeploymentName) { Instructions = AgentInstructions }) { Description = AgentDescription }), + "CreateWithFoundryOptionsSync" => this._client.CreateAIAgent( + name: AgentName, + creationOptions: new AgentVersionCreationOptions(new PromptAgentDefinition(s_config.DeploymentName) { Instructions = AgentInstructions }) { Description = AgentDescription }), + _ => throw new InvalidOperationException($"Unknown create mechanism: {createMechanism}") + }; + + try + { + // Assert. + Assert.NotNull(agent); + Assert.Equal(AgentName, agent.Name); + Assert.Equal(AgentDescription, agent.Description); + Assert.Equal(AgentInstructions, agent.Instructions); + + var agentRecord = await this._client.Agents.GetAgentAsync(agent.Name); + Assert.NotNull(agentRecord); + Assert.Equal(AgentName, agentRecord.Value.Name); + var definition = Assert.IsType(agentRecord.Value.Versions.Latest.Definition); + Assert.Equal(AgentDescription, agentRecord.Value.Versions.Latest.Description); + Assert.Equal(AgentInstructions, definition.Instructions); + } + finally + { + // Cleanup. + await this._client.Agents.DeleteAgentAsync(agent.Name); + } + } + + [Theory(Skip = "For manual testing only")] + [InlineData("CreateWithChatClientAgentOptionsAsync")] + [InlineData("CreateWithChatClientAgentOptionsSync")] + [InlineData("CreateWithFoundryOptionsAsync")] + [InlineData("CreateWithFoundryOptionsSync")] + public async Task CreateAgent_CreatesAgentWithVectorStoresAsync(string createMechanism) + { + // Arrange. + const string AgentName = "VectorStoreAgent"; + const string AgentInstructions = """ + You are a helpful agent that can help fetch data from files you know about. + Use the File Search Tool to look up codes for words. + Do not answer a question unless you can find the answer using the File Search Tool. + """; + + // Get the project OpenAI client. + var projectOpenAIClient = this._client.GetProjectOpenAIClient(); + + // Create a vector store. + var searchFilePath = Path.GetTempFileName() + "wordcodelookup.txt"; + File.WriteAllText( + path: searchFilePath, + contents: "The word 'apple' uses the code 442345, while the word 'banana' uses the code 673457." + ); + OpenAIFile uploadedAgentFile = projectOpenAIClient.GetProjectFilesClient().UploadFile( + filePath: searchFilePath, + purpose: FileUploadPurpose.Assistants + ); + var vectorStoreMetadata = await projectOpenAIClient.GetProjectVectorStoresClient().CreateVectorStoreAsync(options: new() { FileIds = { uploadedAgentFile.Id }, Name = "WordCodeLookup_VectorStore" }); + + // Act. + var agent = createMechanism switch + { + "CreateWithChatClientAgentOptionsAsync" => await this._client.CreateAIAgentAsync( + model: s_config.DeploymentName, + name: AgentName, + instructions: AgentInstructions, + tools: [new HostedFileSearchTool() { Inputs = [new HostedVectorStoreContent(vectorStoreMetadata.Value.Id)] }]), + "CreateWithChatClientAgentOptionsSync" => this._client.CreateAIAgent( + model: s_config.DeploymentName, + name: AgentName, + instructions: AgentInstructions, + tools: [new HostedFileSearchTool() { Inputs = [new HostedVectorStoreContent(vectorStoreMetadata.Value.Id)] }]), + "CreateWithFoundryOptionsAsync" => await this._client.CreateAIAgentAsync( + model: s_config.DeploymentName, + name: AgentName, + instructions: AgentInstructions, + tools: [ResponseTool.CreateFileSearchTool(vectorStoreIds: [vectorStoreMetadata.Value.Id]).AsAITool()]), + "CreateWithFoundryOptionsSync" => this._client.CreateAIAgent( + model: s_config.DeploymentName, + name: AgentName, + instructions: AgentInstructions, + tools: [ResponseTool.CreateFileSearchTool(vectorStoreIds: [vectorStoreMetadata.Value.Id]).AsAITool()]), + _ => throw new InvalidOperationException($"Unknown create mechanism: {createMechanism}") + }; + + try + { + // Assert. + // Verify that the agent can use the vector store to answer a question. + var result = await agent.RunAsync("Can you give me the documented code for 'banana'?"); + Assert.Contains("673457", result.ToString()); + } + finally + { + // Cleanup. + await this._client.Agents.DeleteAgentAsync(agent.Name); + await projectOpenAIClient.GetProjectVectorStoresClient().DeleteVectorStoreAsync(vectorStoreMetadata.Value.Id); + await projectOpenAIClient.GetProjectFilesClient().DeleteFileAsync(uploadedAgentFile.Id); + File.Delete(searchFilePath); + } + } + + [Theory] + [InlineData("CreateWithChatClientAgentOptionsAsync")] + [InlineData("CreateWithChatClientAgentOptionsSync")] + [InlineData("CreateWithFoundryOptionsAsync")] + [InlineData("CreateWithFoundryOptionsSync")] + public async Task CreateAgent_CreatesAgentWithCodeInterpreterAsync(string createMechanism) + { + // Arrange. + const string AgentName = "CodeInterpreterAgent"; + const string AgentInstructions = """ + You are a helpful coding agent. A Python file is provided. Use the Code Interpreter Tool to run the file + and report the SECRET_NUMBER value it prints. Respond only with the number. + """; + + // Get the project OpenAI client. + var projectOpenAIClient = this._client.GetProjectOpenAIClient(); + + // Create a python file that prints a known value. + var codeFilePath = Path.GetTempFileName() + "secret_number.py"; + File.WriteAllText( + path: codeFilePath, + contents: "print(\"SECRET_NUMBER=24601\")" // Deterministic output we will look for. + ); + OpenAIFile uploadedCodeFile = projectOpenAIClient.GetProjectFilesClient().UploadFile( + filePath: codeFilePath, + purpose: FileUploadPurpose.Assistants + ); + + // Act. + var agent = createMechanism switch + { + // Hosted tool path (tools supplied via ChatClientAgentOptions) + "CreateWithChatClientAgentOptionsAsync" => await this._client.CreateAIAgentAsync( + model: s_config.DeploymentName, + name: AgentName, + instructions: AgentInstructions, + tools: [new HostedCodeInterpreterTool() { Inputs = [new HostedFileContent(uploadedCodeFile.Id)] }]), + "CreateWithChatClientAgentOptionsSync" => this._client.CreateAIAgent( + model: s_config.DeploymentName, + name: AgentName, + instructions: AgentInstructions, + tools: [new HostedCodeInterpreterTool() { Inputs = [new HostedFileContent(uploadedCodeFile.Id)] }]), + // Foundry (definitions + resources provided directly) + "CreateWithFoundryOptionsAsync" => await this._client.CreateAIAgentAsync( + model: s_config.DeploymentName, + name: AgentName, + instructions: AgentInstructions, + tools: [ResponseTool.CreateCodeInterpreterTool(new CodeInterpreterToolContainer(CodeInterpreterToolContainerConfiguration.CreateAutomaticContainerConfiguration([uploadedCodeFile.Id]))).AsAITool()]), + "CreateWithFoundryOptionsSync" => this._client.CreateAIAgent( + model: s_config.DeploymentName, + name: AgentName, + instructions: AgentInstructions, + tools: [ResponseTool.CreateCodeInterpreterTool(new CodeInterpreterToolContainer(CodeInterpreterToolContainerConfiguration.CreateAutomaticContainerConfiguration([uploadedCodeFile.Id]))).AsAITool()]), + _ => throw new InvalidOperationException($"Unknown create mechanism: {createMechanism}") + }; + + try + { + // Assert. + var result = await agent.RunAsync("What is the SECRET_NUMBER?"); + // We expect the model to run the code and surface the number. + Assert.Contains("24601", result.ToString()); + } + finally + { + // Cleanup. + await this._client.Agents.DeleteAgentAsync(agent.Name); + await projectOpenAIClient.GetProjectFilesClient().DeleteFileAsync(uploadedCodeFile.Id); + File.Delete(codeFilePath); + } + } + + [Theory] + [InlineData("CreateWithChatClientAgentOptionsAsync")] + [InlineData("CreateWithChatClientAgentOptionsSync")] + public async Task CreateAgent_CreatesAgentWithAIFunctionToolsAsync(string createMechanism) + { + // Arrange. + const string AgentName = "WeatherAgent"; + const string AgentInstructions = "You are a helpful weather assistant. Always call the GetWeather function to answer questions about weather."; + + static string GetWeather(string location) => $"The weather in {location} is sunny with a high of 23C."; + var weatherFunction = AIFunctionFactory.Create(GetWeather); + + ChatClientAgent agent = createMechanism switch + { + "CreateWithChatClientAgentOptionsAsync" => await this._client.CreateAIAgentAsync( + model: s_config.DeploymentName, + options: new ChatClientAgentOptions( + name: AgentName, + instructions: AgentInstructions, + tools: [weatherFunction])), + "CreateWithChatClientAgentOptionsSync" => this._client.CreateAIAgent( + s_config.DeploymentName, + options: new ChatClientAgentOptions( + name: AgentName, + instructions: AgentInstructions, + tools: [weatherFunction])), + _ => throw new InvalidOperationException($"Unknown create mechanism: {createMechanism}") + }; + + try + { + // Act. + var response = await agent.RunAsync("What is the weather like in Amsterdam?"); + + // Assert - ensure function was invoked and its output surfaced. + var text = response.Text; + Assert.Contains("Amsterdam", text, StringComparison.OrdinalIgnoreCase); + Assert.Contains("sunny", text, StringComparison.OrdinalIgnoreCase); + Assert.Contains("23", text, StringComparison.OrdinalIgnoreCase); + } + finally + { + await this._client.Agents.DeleteAgentAsync(agent.Name); + } + } +} diff --git a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientFixture.cs b/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientFixture.cs new file mode 100644 index 0000000000..f8d6d14b91 --- /dev/null +++ b/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientFixture.cs @@ -0,0 +1,162 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using AgentConformance.IntegrationTests; +using AgentConformance.IntegrationTests.Support; +using Azure.AI.Projects; +using Azure.AI.Projects.OpenAI; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using OpenAI.Responses; +using Shared.IntegrationTests; + +namespace AzureAI.IntegrationTests; + +public class AIProjectClientFixture : IChatClientAgentFixture +{ + private static readonly AzureAIConfiguration s_config = TestConfiguration.LoadSection(); + + private ChatClientAgent _agent = null!; + private AIProjectClient _client = null!; + + public IChatClient ChatClient => this._agent.ChatClient; + + public AIAgent Agent => this._agent; + + public async Task CreateConversationAsync() + { + var response = await this._client.GetProjectOpenAIClient().GetProjectConversationsClient().CreateProjectConversationAsync(); + return response.Value.Id; + } + + public async Task> GetChatHistoryAsync(AgentThread thread) + { + var chatClientThread = (ChatClientAgentThread)thread; + + if (chatClientThread.ConversationId?.StartsWith("conv_", StringComparison.OrdinalIgnoreCase) == true) + { + // Conversation threads do not persist message history. + return await this.GetChatHistoryFromConversationAsync(chatClientThread.ConversationId); + } + + if (chatClientThread.ConversationId?.StartsWith("resp_", StringComparison.OrdinalIgnoreCase) == true) + { + return await this.GetChatHistoryFromResponsesChainAsync(chatClientThread.ConversationId); + } + + return chatClientThread.MessageStore is null ? [] : (await chatClientThread.MessageStore.GetMessagesAsync()).ToList(); + } + + private async Task> GetChatHistoryFromResponsesChainAsync(string conversationId) + { + var openAIResponseClient = this._client.GetProjectOpenAIClient().GetProjectResponsesClient(); + var inputItems = await openAIResponseClient.GetResponseInputItemsAsync(conversationId).ToListAsync(); + var response = await openAIResponseClient.GetResponseAsync(conversationId); + var responseItem = response.Value.OutputItems.FirstOrDefault()!; + + // Take the messages that were the chat history leading up to the current response + // remove the instruction messages, and reverse the order so that the most recent message is last. + var previousMessages = inputItems + .Select(ConvertToChatMessage) + .Where(x => x.Text != "You are a helpful assistant.") + .Reverse(); + + // Convert the response item to a chat message. + var responseMessage = ConvertToChatMessage(responseItem); + + // Concatenate the previous messages with the response message to get a full chat history + // that includes the current response. + return [.. previousMessages, responseMessage]; + } + + private static ChatMessage ConvertToChatMessage(ResponseItem item) + { + if (item is MessageResponseItem messageResponseItem) + { + var role = messageResponseItem.Role == MessageRole.User ? ChatRole.User : ChatRole.Assistant; + return new ChatMessage(role, messageResponseItem.Content.FirstOrDefault()?.Text); + } + + throw new NotSupportedException("This test currently only supports text messages"); + } + + private async Task> GetChatHistoryFromConversationAsync(string conversationId) + { + List messages = []; + await foreach (AgentResponseItem item in this._client.GetProjectOpenAIClient().GetProjectConversationsClient().GetProjectConversationItemsAsync(conversationId, order: "asc")) + { + var openAIItem = item.AsOpenAIResponseItem(); + if (openAIItem is MessageResponseItem messageItem) + { + messages.Add(new ChatMessage + { + Role = new ChatRole(messageItem.Role.ToString()), + Contents = messageItem.Content + .Where(c => c.Kind is ResponseContentPartKind.OutputText or ResponseContentPartKind.InputText) + .Select(c => new TextContent(c.Text)) + .ToList() + }); + } + } + + return messages; + } + + public async Task CreateChatClientAgentAsync( + string name = "HelpfulAssistant", + string instructions = "You are a helpful assistant.", + IList? aiTools = null) + { + return await this._client.CreateAIAgentAsync(GenerateUniqueAgentName(name), model: s_config.DeploymentName, instructions: instructions, tools: aiTools); + } + + private static string GenerateUniqueAgentName(string baseName) => + $"{baseName}-{Guid.NewGuid().ToString("N").Substring(0, 8)}"; + + public Task DeleteAgentAsync(ChatClientAgent agent) => + this._client.Agents.DeleteAgentAsync(agent.Name); + + public async Task DeleteThreadAsync(AgentThread thread) + { + var typedThread = (ChatClientAgentThread)thread; + if (typedThread.ConversationId?.StartsWith("conv_", StringComparison.OrdinalIgnoreCase) == true) + { + await this._client.GetProjectOpenAIClient().GetProjectConversationsClient().DeleteConversationAsync(typedThread.ConversationId); + } + else if (typedThread.ConversationId?.StartsWith("resp_", StringComparison.OrdinalIgnoreCase) == true) + { + await this.DeleteResponseChainAsync(typedThread.ConversationId!); + } + } + + private async Task DeleteResponseChainAsync(string lastResponseId) + { + var response = await this._client.GetProjectOpenAIClient().GetProjectResponsesClient().GetResponseAsync(lastResponseId); + await this._client.GetProjectOpenAIClient().GetProjectResponsesClient().DeleteResponseAsync(lastResponseId); + + if (response.Value.PreviousResponseId is not null) + { + await this.DeleteResponseChainAsync(response.Value.PreviousResponseId); + } + } + + public Task DisposeAsync() + { + if (this._client is not null && this._agent is not null) + { + return this._client.Agents.DeleteAgentAsync(this._agent.Name); + } + + return Task.CompletedTask; + } + + public async Task InitializeAsync() + { + this._client = new(new Uri(s_config.Endpoint), new AzureCliCredential()); + this._agent = await this.CreateChatClientAgentAsync(); + } +} diff --git a/dotnet/tests/AzureAI.IntegrationTests/AzureAI.IntegrationTests.csproj b/dotnet/tests/AzureAI.IntegrationTests/AzureAI.IntegrationTests.csproj new file mode 100644 index 0000000000..8da1981f51 --- /dev/null +++ b/dotnet/tests/AzureAI.IntegrationTests/AzureAI.IntegrationTests.csproj @@ -0,0 +1,18 @@ + + + + $(ProjectsTargetFrameworks) + $(ProjectsDebugTargetFrameworks) + True + + + + + + + + + + + + diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/AzureAIProjectChatClientExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/AzureAIProjectChatClientExtensionsTests.cs new file mode 100644 index 0000000000..ede9b37919 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/AzureAIProjectChatClientExtensionsTests.cs @@ -0,0 +1,2821 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.ClientModel; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Azure.AI.Projects; +using Azure.AI.Projects.OpenAI; +using Microsoft.Extensions.AI; +using Moq; +using OpenAI.Responses; + +namespace Microsoft.Agents.AI.AzureAI.UnitTests; + +/// +/// Unit tests for the class. +/// +public sealed class AzureAIProjectChatClientExtensionsTests +{ + #region GetAIAgent(AIProjectClient, AgentRecord) Tests + + /// + /// Verify that GetAIAgent throws ArgumentNullException when AIProjectClient is null. + /// + [Fact] + public void GetAIAgent_WithAgentRecord_WithNullClient_ThrowsArgumentNullException() + { + // Arrange + AIProjectClient? client = null; + AgentRecord agentRecord = this.CreateTestAgentRecord(); + + // Act & Assert + var exception = Assert.Throws(() => + client!.GetAIAgent(agentRecord)); + + Assert.Equal("aiProjectClient", exception.ParamName); + } + + /// + /// Verify that GetAIAgent throws ArgumentNullException when agentRecord is null. + /// + [Fact] + public void GetAIAgent_WithAgentRecord_WithNullAgentRecord_ThrowsArgumentNullException() + { + // Arrange + var mockClient = new Mock(); + + // Act & Assert + var exception = Assert.Throws(() => + mockClient.Object.GetAIAgent((AgentRecord)null!)); + + Assert.Equal("agentRecord", exception.ParamName); + } + + /// + /// Verify that GetAIAgent with AgentRecord creates a valid agent. + /// + [Fact] + public void GetAIAgent_WithAgentRecord_CreatesValidAgent() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + AgentRecord agentRecord = this.CreateTestAgentRecord(); + + // Act + var agent = client.GetAIAgent(agentRecord); + + // Assert + Assert.NotNull(agent); + Assert.Equal("agent_abc123", agent.Name); + } + + /// + /// Verify that GetAIAgent with AgentRecord and clientFactory applies the factory. + /// + [Fact] + public void GetAIAgent_WithAgentRecord_WithClientFactory_AppliesFactoryCorrectly() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + AgentRecord agentRecord = this.CreateTestAgentRecord(); + TestChatClient? testChatClient = null; + + // Act + var agent = client.GetAIAgent( + agentRecord, + clientFactory: (innerClient) => testChatClient = new TestChatClient(innerClient)); + + // Assert + Assert.NotNull(agent); + var retrievedTestClient = agent.GetService(); + Assert.NotNull(retrievedTestClient); + Assert.Same(testChatClient, retrievedTestClient); + } + + #endregion + + #region GetAIAgent(AIProjectClient, AgentVersion) Tests + + /// + /// Verify that GetAIAgent throws ArgumentNullException when AIProjectClient is null. + /// + [Fact] + public void GetAIAgent_WithAgentVersion_WithNullClient_ThrowsArgumentNullException() + { + // Arrange + AIProjectClient? client = null; + AgentVersion agentVersion = this.CreateTestAgentVersion(); + + // Act & Assert + var exception = Assert.Throws(() => + client!.GetAIAgent(agentVersion)); + + Assert.Equal("aiProjectClient", exception.ParamName); + } + + /// + /// Verify that GetAIAgent throws ArgumentNullException when agentVersion is null. + /// + [Fact] + public void GetAIAgent_WithAgentVersion_WithNullAgentVersion_ThrowsArgumentNullException() + { + // Arrange + var mockClient = new Mock(); + + // Act & Assert + var exception = Assert.Throws(() => + mockClient.Object.GetAIAgent((AgentVersion)null!)); + + Assert.Equal("agentVersion", exception.ParamName); + } + + /// + /// Verify that GetAIAgent with AgentVersion creates a valid agent. + /// + [Fact] + public void GetAIAgent_WithAgentVersion_CreatesValidAgent() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + AgentVersion agentVersion = this.CreateTestAgentVersion(); + + // Act + var agent = client.GetAIAgent(agentVersion); + + // Assert + Assert.NotNull(agent); + Assert.Equal("agent_abc123", agent.Name); + } + + /// + /// Verify that GetAIAgent with AgentVersion and clientFactory applies the factory. + /// + [Fact] + public void GetAIAgent_WithAgentVersion_WithClientFactory_AppliesFactoryCorrectly() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + AgentVersion agentVersion = this.CreateTestAgentVersion(); + TestChatClient? testChatClient = null; + + // Act + var agent = client.GetAIAgent( + agentVersion, + clientFactory: (innerClient) => testChatClient = new TestChatClient(innerClient)); + + // Assert + Assert.NotNull(agent); + var retrievedTestClient = agent.GetService(); + Assert.NotNull(retrievedTestClient); + Assert.Same(testChatClient, retrievedTestClient); + } + + /// + /// Verify that GetAIAgent with requireInvocableTools=true enforces invocable tools. + /// + [Fact] + public void GetAIAgent_WithAgentVersion_WithRequireInvocableToolsTrue_EnforcesInvocableTools() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + AgentVersion agentVersion = this.CreateTestAgentVersion(); + var tools = new List + { + AIFunctionFactory.Create(() => "test", "test_function", "A test function") + }; + + // Act + var agent = client.GetAIAgent(agentVersion, tools: tools); + + // Assert + Assert.NotNull(agent); + Assert.IsType(agent); + } + + /// + /// Verify that GetAIAgent with requireInvocableTools=false allows declarative functions. + /// + [Fact] + public void GetAIAgent_WithAgentVersion_WithRequireInvocableToolsFalse_AllowsDeclarativeFunctions() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + AgentVersion agentVersion = this.CreateTestAgentVersion(); + + // Act - should not throw even without tools when requireInvocableTools is false + var agent = client.GetAIAgent(agentVersion); + + // Assert + Assert.NotNull(agent); + Assert.IsType(agent); + } + + #endregion + + #region GetAIAgent(AIProjectClient, ChatClientAgentOptions) Tests + + /// + /// Verify that GetAIAgent with ChatClientAgentOptions throws ArgumentNullException when client is null. + /// + [Fact] + public void GetAIAgent_WithOptions_WithNullClient_ThrowsArgumentNullException() + { + // Arrange + AIProjectClient? client = null; + var options = new ChatClientAgentOptions { Name = "test-agent" }; + + // Act & Assert + var exception = Assert.Throws(() => + client!.GetAIAgent(options)); + + Assert.Equal("aiProjectClient", exception.ParamName); + } + + /// + /// Verify that GetAIAgent with ChatClientAgentOptions throws ArgumentNullException when options is null. + /// + [Fact] + public void GetAIAgent_WithOptions_WithNullOptions_ThrowsArgumentNullException() + { + // Arrange + var mockClient = new Mock(); + + // Act & Assert + var exception = Assert.Throws(() => + mockClient.Object.GetAIAgent((ChatClientAgentOptions)null!)); + + Assert.Equal("options", exception.ParamName); + } + + /// + /// Verify that GetAIAgent with ChatClientAgentOptions throws ArgumentException when options.Name is null. + /// + [Fact] + public void GetAIAgent_WithOptions_WithoutName_ThrowsArgumentException() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + var options = new ChatClientAgentOptions(); + + // Act & Assert + var exception = Assert.Throws(() => + client.GetAIAgent(options)); + + Assert.Contains("Agent name must be provided", exception.Message); + } + + /// + /// Verify that GetAIAgent with ChatClientAgentOptions creates a valid agent. + /// + [Fact] + public void GetAIAgent_WithOptions_CreatesValidAgent() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(agentName: "test-agent"); + var options = new ChatClientAgentOptions { Name = "test-agent" }; + + // Act + var agent = client.GetAIAgent(options); + + // Assert + Assert.NotNull(agent); + Assert.Equal("test-agent", agent.Name); + } + + /// + /// Verify that GetAIAgent with ChatClientAgentOptions and clientFactory applies the factory. + /// + [Fact] + public void GetAIAgent_WithOptions_WithClientFactory_AppliesFactoryCorrectly() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(agentName: "test-agent"); + var options = new ChatClientAgentOptions { Name = "test-agent" }; + TestChatClient? testChatClient = null; + + // Act + var agent = client.GetAIAgent( + options, + clientFactory: (innerClient) => testChatClient = new TestChatClient(innerClient)); + + // Assert + Assert.NotNull(agent); + var retrievedTestClient = agent.GetService(); + Assert.NotNull(retrievedTestClient); + Assert.Same(testChatClient, retrievedTestClient); + } + + #endregion + + #region GetAIAgentAsync(AIProjectClient, ChatClientAgentOptions) Tests + + /// + /// Verify that GetAIAgentAsync with ChatClientAgentOptions throws ArgumentNullException when client is null. + /// + [Fact] + public async Task GetAIAgentAsync_WithOptions_WithNullClient_ThrowsArgumentNullExceptionAsync() + { + // Arrange + AIProjectClient? client = null; + var options = new ChatClientAgentOptions { Name = "test-agent" }; + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + client!.GetAIAgentAsync(options)); + + Assert.Equal("aiProjectClient", exception.ParamName); + } + + /// + /// Verify that GetAIAgentAsync with ChatClientAgentOptions throws ArgumentNullException when options is null. + /// + [Fact] + public async Task GetAIAgentAsync_WithOptions_WithNullOptions_ThrowsArgumentNullExceptionAsync() + { + // Arrange + var mockClient = new Mock(); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + mockClient.Object.GetAIAgentAsync((ChatClientAgentOptions)null!)); + + Assert.Equal("options", exception.ParamName); + } + + /// + /// Verify that GetAIAgentAsync with ChatClientAgentOptions creates a valid agent. + /// + [Fact] + public async Task GetAIAgentAsync_WithOptions_CreatesValidAgentAsync() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(agentName: "test-agent"); + var options = new ChatClientAgentOptions { Name = "test-agent" }; + + // Act + var agent = await client.GetAIAgentAsync(options); + + // Assert + Assert.NotNull(agent); + Assert.Equal("test-agent", agent.Name); + } + + #endregion + + #region GetAIAgent(AIProjectClient, string) Tests + + /// + /// Verify that GetAIAgent throws ArgumentNullException when AIProjectClient is null. + /// + [Fact] + public void GetAIAgent_ByName_WithNullClient_ThrowsArgumentNullException() + { + // Arrange + AIProjectClient? client = null; + + // Act & Assert + var exception = Assert.Throws(() => + client!.GetAIAgent("test-agent")); + + Assert.Equal("aiProjectClient", exception.ParamName); + } + + /// + /// Verify that GetAIAgent throws ArgumentNullException when name is null. + /// + [Fact] + public void GetAIAgent_ByName_WithNullName_ThrowsArgumentNullException() + { + // Arrange + var mockClient = new Mock(); + + // Act & Assert + var exception = Assert.Throws(() => + mockClient.Object.GetAIAgent((string)null!)); + + Assert.Equal("name", exception.ParamName); + } + + /// + /// Verify that GetAIAgent throws ArgumentException when name is empty. + /// + [Fact] + public void GetAIAgent_ByName_WithEmptyName_ThrowsArgumentException() + { + // Arrange + var mockClient = new Mock(); + + // Act & Assert + var exception = Assert.Throws(() => + mockClient.Object.GetAIAgent(string.Empty)); + + Assert.Equal("name", exception.ParamName); + } + + /// + /// Verify that GetAIAgent throws InvalidOperationException when agent is not found. + /// + [Fact] + public void GetAIAgent_ByName_WithNonExistentAgent_ThrowsInvalidOperationException() + { + // Arrange + var mockAgentOperations = new Mock(); + mockAgentOperations + .Setup(c => c.GetAgent(It.IsAny(), It.IsAny())) + .Returns(ClientResult.FromOptionalValue((AgentRecord)null!, new MockPipelineResponse(200, BinaryData.FromString("null")))); + + var mockClient = new Mock(); + mockClient.SetupGet(x => x.Agents).Returns(mockAgentOperations.Object); + mockClient.Setup(x => x.GetConnection(It.IsAny())).Returns(new ClientConnection("fake-connection-id", "http://localhost", ClientPipeline.Create(), CredentialKind.None)); + + // Act & Assert + var exception = Assert.Throws(() => + mockClient.Object.GetAIAgent("non-existent-agent")); + + Assert.Contains("not found", exception.Message); + } + + #endregion + + #region GetAIAgentAsync(AIProjectClient, string) Tests + + /// + /// Verify that GetAIAgentAsync throws ArgumentNullException when AIProjectClient is null. + /// + [Fact] + public async Task GetAIAgentAsync_ByName_WithNullClient_ThrowsArgumentNullExceptionAsync() + { + // Arrange + AIProjectClient? client = null; + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + client!.GetAIAgentAsync("test-agent")); + + Assert.Equal("aiProjectClient", exception.ParamName); + } + + /// + /// Verify that GetAIAgentAsync throws ArgumentNullException when name is null. + /// + [Fact] + public async Task GetAIAgentAsync_ByName_WithNullName_ThrowsArgumentNullExceptionAsync() + { + // Arrange + var mockClient = new Mock(); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + mockClient.Object.GetAIAgentAsync(name: null!)); + + Assert.Equal("name", exception.ParamName); + } + + /// + /// Verify that GetAIAgentAsync throws InvalidOperationException when agent is not found. + /// + [Fact] + public async Task GetAIAgentAsync_ByName_WithNonExistentAgent_ThrowsInvalidOperationExceptionAsync() + { + // Arrange + var mockAgentOperations = new Mock(); + mockAgentOperations + .Setup(c => c.GetAgentAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(ClientResult.FromOptionalValue((AgentRecord)null!, new MockPipelineResponse(200, BinaryData.FromString("null")))); + + var mockClient = new Mock(); + mockClient.SetupGet(c => c.Agents).Returns(mockAgentOperations.Object); + mockClient.Setup(x => x.GetConnection(It.IsAny())).Returns(new ClientConnection("fake-connection-id", "http://localhost", ClientPipeline.Create(), CredentialKind.None)); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + mockClient.Object.GetAIAgentAsync("non-existent-agent")); + + Assert.Contains("not found", exception.Message); + } + + #endregion + + #region GetAIAgent(AIProjectClient, AgentRecord) with tools Tests + + /// + /// Verify that GetAIAgent with additional tools when the definition has no tools does not throw and results in an agent with no tools. + /// + [Fact] + public void GetAIAgent_WithAgentRecordAndAdditionalTools_WhenDefinitionHasNoTools_ShouldNotThrow() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + AgentRecord agentRecord = this.CreateTestAgentRecord(); + var tools = new List + { + AIFunctionFactory.Create(() => "test", "test_function", "A test function") + }; + + // Act + var agent = client.GetAIAgent(agentRecord, tools: tools); + + // Assert + Assert.NotNull(agent); + Assert.IsType(agent); + var chatClient = agent.GetService(); + Assert.NotNull(chatClient); + var agentVersion = chatClient.GetService(); + Assert.NotNull(agentVersion); + var definition = Assert.IsType(agentVersion.Definition); + Assert.Empty(definition.Tools); + } + + /// + /// Verify that GetAIAgent with null tools works correctly. + /// + [Fact] + public void GetAIAgent_WithAgentRecordAndNullTools_WorksCorrectly() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + AgentRecord agentRecord = this.CreateTestAgentRecord(); + + // Act + var agent = client.GetAIAgent(agentRecord, tools: null); + + // Assert + Assert.NotNull(agent); + Assert.Equal("agent_abc123", agent.Name); + } + + #endregion + + #region GetAIAgentAsync(AIProjectClient, string) with tools Tests + + /// + /// Verify that GetAIAgentAsync with tools parameter creates an agent. + /// + [Fact] + public async Task GetAIAgentAsync_WithNameAndTools_CreatesAgentAsync() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + var tools = new List + { + AIFunctionFactory.Create(() => "test", "test_function", "A test function") + }; + + // Act + var agent = await client.GetAIAgentAsync("test-agent", tools: tools); + + // Assert + Assert.NotNull(agent); + Assert.IsType(agent); + } + + #endregion + + #region CreateAIAgent(AIProjectClient, string, string) Tests + + /// + /// Verify that CreateAIAgent throws ArgumentNullException when AIProjectClient is null. + /// + [Fact] + public void CreateAIAgent_WithBasicParams_WithNullClient_ThrowsArgumentNullException() + { + // Arrange + AIProjectClient? client = null; + + // Act & Assert + var exception = Assert.Throws(() => + client!.CreateAIAgent("test-agent", "model", "instructions")); + + Assert.Equal("aiProjectClient", exception.ParamName); + } + + /// + /// Verify that CreateAIAgent throws ArgumentNullException when name is null. + /// + [Fact] + public void CreateAIAgent_WithBasicParams_WithNullName_ThrowsArgumentNullException() + { + // Arrange + var mockClient = new Mock(); + + // Act & Assert + var exception = Assert.Throws(() => + mockClient.Object.CreateAIAgent(null!, "model", "instructions")); + + Assert.Equal("name", exception.ParamName); + } + + #endregion + + #region CreateAIAgent(AIProjectClient, string, AgentDefinition) Tests + + /// + /// Verify that CreateAIAgent throws ArgumentNullException when AIProjectClient is null. + /// + [Fact] + public void CreateAIAgent_WithAgentDefinition_WithNullClient_ThrowsArgumentNullException() + { + // Arrange + AIProjectClient? client = null; + var definition = new PromptAgentDefinition("test-model"); + var options = new AgentVersionCreationOptions(definition); + + // Act & Assert + var exception = Assert.Throws(() => + client!.CreateAIAgent("test-agent", options)); + + Assert.Equal("aiProjectClient", exception.ParamName); + } + + /// + /// Verify that CreateAIAgent throws ArgumentNullException when name is null. + /// + [Fact] + public void CreateAIAgent_WithAgentDefinition_WithNullName_ThrowsArgumentNullException() + { + // Arrange + var mockClient = new Mock(); + var definition = new PromptAgentDefinition("test-model"); + var options = new AgentVersionCreationOptions(definition); + + // Act & Assert + var exception = Assert.Throws(() => + mockClient.Object.CreateAIAgent(null!, options)); + + Assert.Equal("name", exception.ParamName); + } + + /// + /// Verify that CreateAIAgent throws ArgumentNullException when creationOptions is null. + /// + [Fact] + public void CreateAIAgent_WithAgentDefinition_WithNullDefinition_ThrowsArgumentNullException() + { + // Arrange + var mockClient = new Mock(); + + // Act & Assert + var exception = Assert.Throws(() => + mockClient.Object.CreateAIAgent("test-agent", (AgentVersionCreationOptions)null!)); + + Assert.Equal("creationOptions", exception.ParamName); + } + + #endregion + + #region CreateAIAgent(AIProjectClient, ChatClientAgentOptions, string) Tests + + /// + /// Verify that CreateAIAgent throws ArgumentNullException when AIProjectClient is null. + /// + [Fact] + public void CreateAIAgent_WithOptions_WithNullClient_ThrowsArgumentNullException() + { + // Arrange + AIProjectClient? client = null; + var options = new ChatClientAgentOptions { Name = "test-agent" }; + + // Act & Assert + var exception = Assert.Throws(() => + client!.CreateAIAgent("model", options)); + + Assert.Equal("aiProjectClient", exception.ParamName); + } + + /// + /// Verify that CreateAIAgent throws ArgumentNullException when options is null. + /// + [Fact] + public void CreateAIAgent_WithOptions_WithNullOptions_ThrowsArgumentNullException() + { + // Arrange + var mockClient = new Mock(); + + // Act & Assert + var exception = Assert.Throws(() => + mockClient.Object.CreateAIAgent("model", (ChatClientAgentOptions)null!)); + + Assert.Equal("options", exception.ParamName); + } + + /// + /// Verify that CreateAIAgent throws ArgumentNullException when model is null. + /// + [Fact] + public void CreateAIAgent_WithOptions_WithNullModel_ThrowsArgumentNullException() + { + // Arrange + var mockClient = new Mock(); + var options = new ChatClientAgentOptions { Name = "test-agent" }; + + // Act & Assert + var exception = Assert.Throws(() => + mockClient.Object.CreateAIAgent(null!, options)); + + Assert.Equal("model", exception.ParamName); + } + + /// + /// Verify that CreateAIAgent throws ArgumentNullException when options.Name is null. + /// + [Fact] + public void CreateAIAgent_WithOptions_WithoutName_ThrowsException() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + var options = new ChatClientAgentOptions(); + + // Act & Assert + var exception = Assert.Throws(() => + client.CreateAIAgent("test-model", options)); + + Assert.Contains("Agent name must be provided", exception.Message); + } + + /// + /// Verify that CreateAIAgent with model and options creates a valid agent. + /// + [Fact] + public void CreateAIAgent_WithModelAndOptions_CreatesValidAgent() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(agentName: "test-agent", instructions: "Test instructions"); + var options = new ChatClientAgentOptions + { + Name = "test-agent", + Instructions = "Test instructions" + }; + + // Act + var agent = client.CreateAIAgent("test-model", options); + + // Assert + Assert.NotNull(agent); + Assert.Equal("test-agent", agent.Name); + Assert.Equal("Test instructions", agent.Instructions); + } + + /// + /// Verify that CreateAIAgent with model and options and clientFactory applies the factory. + /// + [Fact] + public void CreateAIAgent_WithModelAndOptions_WithClientFactory_AppliesFactoryCorrectly() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(agentName: "test-agent", instructions: "Test instructions"); + var options = new ChatClientAgentOptions + { + Name = "test-agent", + Instructions = "Test instructions" + }; + TestChatClient? testChatClient = null; + + // Act + var agent = client.CreateAIAgent( + "test-model", + options, + clientFactory: (innerClient) => testChatClient = new TestChatClient(innerClient)); + + // Assert + Assert.NotNull(agent); + var retrievedTestClient = agent.GetService(); + Assert.NotNull(retrievedTestClient); + Assert.Same(testChatClient, retrievedTestClient); + } + + /// + /// Verify that CreateAIAgentAsync with model and options creates a valid agent. + /// + [Fact] + public async Task CreateAIAgentAsync_WithModelAndOptions_CreatesValidAgentAsync() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(agentName: "test-agent", instructions: "Test instructions"); + var options = new ChatClientAgentOptions + { + Name = "test-agent", + Instructions = "Test instructions" + }; + + // Act + var agent = await client.CreateAIAgentAsync("test-model", options); + + // Assert + Assert.NotNull(agent); + Assert.Equal("test-agent", agent.Name); + Assert.Equal("Test instructions", agent.Instructions); + } + + /// + /// Verify that CreateAIAgentAsync with model and options and clientFactory applies the factory. + /// + [Fact] + public async Task CreateAIAgentAsync_WithModelAndOptions_WithClientFactory_AppliesFactoryCorrectlyAsync() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(agentName: "test-agent", instructions: "Test instructions"); + var options = new ChatClientAgentOptions + { + Name = "test-agent", + Instructions = "Test instructions" + }; + TestChatClient? testChatClient = null; + + // Act + var agent = await client.CreateAIAgentAsync( + "test-model", + options, + clientFactory: (innerClient) => testChatClient = new TestChatClient(innerClient)); + + // Assert + Assert.NotNull(agent); + var retrievedTestClient = agent.GetService(); + Assert.NotNull(retrievedTestClient); + Assert.Same(testChatClient, retrievedTestClient); + } + + #endregion + + #region CreateAIAgentAsync(AIProjectClient, string, AgentDefinition) Tests + + /// + /// Verify that CreateAIAgentAsync throws ArgumentNullException when AIProjectClient is null. + /// + [Fact] + public async Task CreateAIAgentAsync_WithAgentDefinition_WithNullClient_ThrowsArgumentNullExceptionAsync() + { + // Arrange + AIProjectClient? client = null; + var definition = new PromptAgentDefinition("test-model"); + var options = new AgentVersionCreationOptions(definition); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + client!.CreateAIAgentAsync("agent-name", options)); + + Assert.Equal("aiProjectClient", exception.ParamName); + } + + /// + /// Verify that CreateAIAgentAsync throws ArgumentNullException when creationOptions is null. + /// + [Fact] + public async Task CreateAIAgentAsync_WithAgentDefinition_WithNullDefinition_ThrowsArgumentNullExceptionAsync() + { + // Arrange + var mockClient = new Mock(); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + mockClient.Object.CreateAIAgentAsync(name: "agent-name", null!)); + + Assert.Equal("creationOptions", exception.ParamName); + } + + #endregion + + #region Tool Validation Tests + + /// + /// Verify that CreateAIAgent creates an agent successfully. + /// + [Fact] + public void CreateAIAgent_WithDefinition_CreatesAgentSuccessfully() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + var definition = new PromptAgentDefinition("test-model") { Instructions = "Test" }; + var options = new AgentVersionCreationOptions(definition); + + // Act + var agent = client.CreateAIAgent("test-agent", options); + + // Assert + Assert.NotNull(agent); + Assert.IsType(agent); + } + + /// + /// Verify that CreateAIAgent without tools parameter creates an agent successfully. + /// + [Fact] + public void CreateAIAgent_WithoutToolsParameter_CreatesAgentSuccessfully() + { + // Arrange + var definition = new PromptAgentDefinition("test-model") { Instructions = "Test" }; + + var definitionResponse = GeneratePromptDefinitionResponse(definition, null); + AIProjectClient client = this.CreateTestAgentClient(agentName: "test-agent", agentDefinitionResponse: definitionResponse); + + var options = new AgentVersionCreationOptions(definition); + + // Act + var agent = client.CreateAIAgent("test-agent", options); + + // Assert + Assert.NotNull(agent); + Assert.IsType(agent); + } + + /// + /// Verify that CreateAIAgent without tools in definition creates an agent successfully. + /// + [Fact] + public void CreateAIAgent_WithoutToolsInDefinition_CreatesAgentSuccessfully() + { + // Arrange + var definition = new PromptAgentDefinition("test-model") { Instructions = "Test" }; + AIProjectClient client = this.CreateTestAgentClient(agentName: "test-agent", agentDefinitionResponse: definition); + + var options = new AgentVersionCreationOptions(definition); + + // Act + var agent = client.CreateAIAgent("test-agent", options); + + // Assert + Assert.NotNull(agent); + Assert.IsType(agent); + } + + /// + /// Verify that CreateAIAgent uses tools from the definition when no separate tools parameter is provided. + /// + [Fact] + public void CreateAIAgent_WithDefinitionTools_UsesDefinitionTools() + { + // Arrange + var definition = new PromptAgentDefinition("test-model") { Instructions = "Test" }; + + // Add a function tool to the definition + definition.Tools.Add(ResponseTool.CreateFunctionTool("required_tool", BinaryData.FromString("{}"), strictModeEnabled: false)); + + // Create a response definition with the same tool + var definitionResponse = GeneratePromptDefinitionResponse(definition, definition.Tools.Select(t => t.AsAITool()).ToList()); + AIProjectClient client = this.CreateTestAgentClient(agentName: "test-agent", agentDefinitionResponse: definitionResponse); + + var options = new AgentVersionCreationOptions(definition); + + // Act + var agent = client.CreateAIAgent("test-agent", options); + + // Assert + Assert.NotNull(agent); + Assert.IsType(agent); + var agentVersion = agent.GetService(); + Assert.NotNull(agentVersion); + if (agentVersion.Definition is PromptAgentDefinition promptDef) + { + Assert.NotEmpty(promptDef.Tools); + Assert.Single(promptDef.Tools); + Assert.Equal("required_tool", (promptDef.Tools.First() as FunctionTool)?.FunctionName); + } + } + + /// + /// Verify that CreateAIAgentAsync when AI Tools are provided, uses them for the definition via http request. + /// + [Fact] + public async Task CreateAIAgentAsync_WithNameAndAITools_SendsToolDefinitionViaHttpAsync() + { + // Arrange + using var httpHandler = new HttpHandlerAssert(async (request) => + { + if (request.Content is not null) + { + var requestBody = await request.Content.ReadAsStringAsync().ConfigureAwait(false); + + Assert.Contains("required_tool", requestBody); + } + + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(TestDataUtil.GetAgentVersionResponseJson(), Encoding.UTF8, "application/json") }; + }); + +#pragma warning disable CA5399 + using var httpClient = new HttpClient(httpHandler); +#pragma warning restore CA5399 + + var client = new AIProjectClient(new Uri("https://test.openai.azure.com/"), new FakeAuthenticationTokenProvider(), new() { Transport = new HttpClientPipelineTransport(httpClient) }); + + // Act + var agent = await client.CreateAIAgentAsync( + name: "test-agent", + model: "test-model", + instructions: "Test", + tools: [AIFunctionFactory.Create(() => true, "required_tool")]); + + // Assert + Assert.NotNull(agent); + Assert.IsType(agent); + var agentVersion = agent.GetService(); + Assert.NotNull(agentVersion); + Assert.IsType(agentVersion.Definition); + } + + /// + /// Verify that CreateAIAgent when AI Tools are provided, uses them for the definition via http request. + /// + [Fact] + public void CreateAIAgent_WithNameAndAITools_SendsToolDefinitionViaHttp() + { + // Arrange + using var httpHandler = new HttpHandlerAssert((request) => + { + if (request.Content is not null) + { +#pragma warning disable VSTHRD002 // Avoid problematic synchronous waits + var requestBody = request.Content.ReadAsStringAsync().GetAwaiter().GetResult(); +#pragma warning restore VSTHRD002 // Avoid problematic synchronous waits + + Assert.Contains("required_tool", requestBody); + } + + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(TestDataUtil.GetAgentVersionResponseJson(), Encoding.UTF8, "application/json") }; + }); + +#pragma warning disable CA5399 + using var httpClient = new HttpClient(httpHandler); +#pragma warning restore CA5399 + + var client = new AIProjectClient(new Uri("https://test.openai.azure.com/"), new FakeAuthenticationTokenProvider(), new() { Transport = new HttpClientPipelineTransport(httpClient) }); + + // Act + var agent = client.CreateAIAgent( + name: "test-agent", + model: "test-model", + instructions: "Test", + tools: [AIFunctionFactory.Create(() => true, "required_tool")]); + + // Assert + Assert.NotNull(agent); + Assert.IsType(agent); + var agentVersion = agent.GetService(); + Assert.NotNull(agentVersion); + Assert.IsType(agentVersion.Definition); + } + + /// + /// Verify that CreateAIAgent without tools creates an agent successfully. + /// + [Fact] + public void CreateAIAgent_WithoutTools_CreatesAgentSuccessfully() + { + // Arrange + var definition = new PromptAgentDefinition("test-model"); + + var agentDefinitionResponse = GeneratePromptDefinitionResponse(definition, null); + AIProjectClient client = this.CreateTestAgentClient(agentName: "test-agent", agentDefinitionResponse: agentDefinitionResponse); + + var options = new AgentVersionCreationOptions(definition); + + // Act + var agent = client.CreateAIAgent("test-agent", options); + + // Assert + Assert.NotNull(agent); + Assert.IsType(agent); + } + + /// + /// Verify that when providing AITools with GetAIAgent, any additional tool that doesn't match the tools in agent definition are ignored. + /// + [Fact] + public void GetAIAgent_AdditionalAITools_WhenNotInTheDefinitionAreIgnored() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + var agentVersion = this.CreateTestAgentVersion(); + + // Manually add tools to the definition to simulate inline tools + if (agentVersion.Definition is PromptAgentDefinition promptDef) + { + promptDef.Tools.Add(ResponseTool.CreateFunctionTool("inline_tool", BinaryData.FromString("{}"), strictModeEnabled: false)); + } + + var invocableInlineAITool = AIFunctionFactory.Create(() => "test", "inline_tool", "An invocable AIFunction for the inline function"); + var shouldBeIgnoredTool = AIFunctionFactory.Create(() => "test", "additional_tool", "An additional test function that should be ignored"); + + // Act & Assert + var agent = client.GetAIAgent(agentVersion, tools: [invocableInlineAITool, shouldBeIgnoredTool]); + Assert.NotNull(agent); + var version = agent.GetService(); + Assert.NotNull(version); + var definition = Assert.IsType(version.Definition); + Assert.NotEmpty(definition.Tools); + Assert.NotNull(GetAgentChatOptions(agent)); + Assert.NotNull(GetAgentChatOptions(agent)!.Tools); + Assert.Single(GetAgentChatOptions(agent)!.Tools!); + Assert.Equal("inline_tool", (definition.Tools.First() as FunctionTool)?.FunctionName); + } + + #endregion + + #region Inline Tools vs Parameter Tools Tests + + /// + /// Verify that tools passed as parameters are accepted by GetAIAgent. + /// + [Fact] + public void GetAIAgent_WithParameterTools_AcceptsTools() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + AgentRecord agentRecord = this.CreateTestAgentRecord(); + var tools = new List + { + AIFunctionFactory.Create(() => "tool1", "param_tool_1", "First parameter tool"), + AIFunctionFactory.Create(() => "tool2", "param_tool_2", "Second parameter tool") + }; + + // Act + var agent = client.GetAIAgent(agentRecord, tools: tools); + + // Assert + Assert.NotNull(agent); + Assert.IsType(agent); + var chatClient = agent.GetService(); + Assert.NotNull(chatClient); + var agentVersion = chatClient.GetService(); + Assert.NotNull(agentVersion); + } + + /// + /// Verify that CreateAIAgent with tools in definition creates an agent successfully. + /// + [Fact] + public void CreateAIAgent_WithDefinitionTools_CreatesAgentSuccessfully() + { + // Arrange + var definition = new PromptAgentDefinition("test-model") { Instructions = "Test instructions" }; + definition.Tools.Add(ResponseTool.CreateFunctionTool("create_tool", BinaryData.FromString("{}"), strictModeEnabled: false)); + + // Simulate agent definition response with the tools + var definitionResponse = GeneratePromptDefinitionResponse(definition, definition.Tools.Select(t => t.AsAITool()).ToList()); + + AIProjectClient client = this.CreateTestAgentClient(agentDefinitionResponse: definitionResponse); + + var options = new AgentVersionCreationOptions(definition); + + // Act + var agent = client.CreateAIAgent("test-agent", options); + + // Assert + Assert.NotNull(agent); + Assert.IsType(agent); + var agentVersion = agent.GetService(); + Assert.NotNull(agentVersion); + if (agentVersion.Definition is PromptAgentDefinition promptDef) + { + Assert.NotEmpty(promptDef.Tools); + Assert.Single(promptDef.Tools); + } + } + + /// + /// Verify that CreateAIAgent creates an agent successfully when definition has a mix of custom and hosted tools. + /// + [Fact] + public void CreateAIAgent_WithMixedToolsInDefinition_CreatesAgentSuccessfully() + { + // Arrange + var definition = new PromptAgentDefinition("test-model") { Instructions = "Test instructions" }; + definition.Tools.Add(ResponseTool.CreateFunctionTool("create_tool", BinaryData.FromString("{}"), strictModeEnabled: false)); + definition.Tools.Add(new HostedWebSearchTool().GetService() ?? new HostedWebSearchTool().AsOpenAIResponseTool()); + definition.Tools.Add(new HostedFileSearchTool().GetService() ?? new HostedFileSearchTool().AsOpenAIResponseTool()); + + // Simulate agent definition response with the tools + var definitionResponse = new PromptAgentDefinition("test-model") { Instructions = "Test instructions" }; + foreach (var tool in definition.Tools) + { + definitionResponse.Tools.Add(tool); + } + + AIProjectClient client = this.CreateTestAgentClient(agentDefinitionResponse: definitionResponse); + + var options = new AgentVersionCreationOptions(definition); + + // Act + var agent = client.CreateAIAgent("test-agent", options); + + // Assert + Assert.NotNull(agent); + Assert.IsType(agent); + var agentVersion = agent.GetService(); + Assert.NotNull(agentVersion); + if (agentVersion.Definition is PromptAgentDefinition promptDef) + { + Assert.NotEmpty(promptDef.Tools); + Assert.Equal(3, promptDef.Tools.Count); + } + } + + /// + /// Verifies that CreateAIAgent uses tools from definition when they are ResponseTool instances, resulting in successful agent creation. + /// + [Fact] + public void CreateAIAgent_WithResponseToolsInDefinition_CreatesAgentSuccessfully() + { + // Arrange + var definition = new PromptAgentDefinition("test-model") { Instructions = "Test instructions" }; + + var fabricToolOptions = new FabricDataAgentToolOptions(); + fabricToolOptions.ProjectConnections.Add(new ToolProjectConnection("connection-id")); + + var sharepointOptions = new SharePointGroundingToolOptions(); + sharepointOptions.ProjectConnections.Add(new ToolProjectConnection("connection-id")); + + var structuredOutputs = new StructuredOutputDefinition("name", "description", BinaryData.FromString(AIJsonUtilities.CreateJsonSchema(new { id = "test" }.GetType()).ToString()), false); + + // Add tools to the definition + definition.Tools.Add(ResponseTool.CreateFunctionTool("create_tool", BinaryData.FromString("{}"), strictModeEnabled: false)); + definition.Tools.Add((ResponseTool)AgentTool.CreateBingCustomSearchTool(new BingCustomSearchToolParameters([new BingCustomSearchConfiguration("connection-id", "instance-name")]))); + definition.Tools.Add((ResponseTool)AgentTool.CreateBrowserAutomationTool(new BrowserAutomationToolParameters(new BrowserAutomationToolConnectionParameters("id")))); + definition.Tools.Add(AgentTool.CreateA2ATool(new Uri("https://test-uri.microsoft.com"))); + definition.Tools.Add((ResponseTool)AgentTool.CreateBingGroundingTool(new BingGroundingSearchToolOptions([new BingGroundingSearchConfiguration("connection-id")]))); + definition.Tools.Add((ResponseTool)AgentTool.CreateMicrosoftFabricTool(fabricToolOptions)); + definition.Tools.Add((ResponseTool)AgentTool.CreateOpenApiTool(new OpenAPIFunctionDefinition("name", BinaryData.FromString(OpenAPISpec), new OpenAPIAnonymousAuthenticationDetails()))); + definition.Tools.Add((ResponseTool)AgentTool.CreateSharepointTool(sharepointOptions)); + definition.Tools.Add((ResponseTool)AgentTool.CreateStructuredOutputsTool(structuredOutputs)); + definition.Tools.Add((ResponseTool)AgentTool.CreateAzureAISearchTool(new AzureAISearchToolOptions([new AzureAISearchToolIndex() { IndexName = "name" }]))); + + // Generate agent definition response with the tools + var definitionResponse = GeneratePromptDefinitionResponse(definition, definition.Tools.Select(t => t.AsAITool()).ToList()); + + AIProjectClient client = this.CreateTestAgentClient(agentDefinitionResponse: definitionResponse); + + var options = new AgentVersionCreationOptions(definition); + + // Act + var agent = client.CreateAIAgent("test-agent", options); + + // Assert + Assert.NotNull(agent); + Assert.IsType(agent); + var agentVersion = agent.GetService(); + Assert.NotNull(agentVersion); + if (agentVersion.Definition is PromptAgentDefinition promptDef) + { + Assert.NotEmpty(promptDef.Tools); + Assert.Equal(10, promptDef.Tools.Count); + } + } + + /// + /// Verify that CreateAIAgent with string parameters and tools creates an agent. + /// + [Fact] + public void CreateAIAgent_WithStringParamsAndTools_CreatesAgent() + { + // Arrange + var tools = new List + { + AIFunctionFactory.Create(() => "weather", "string_param_tool", "Tool from string params") + }; + + var definitionResponse = GeneratePromptDefinitionResponse(new PromptAgentDefinition("test-model") { Instructions = "Test instructions" }, tools); + + AIProjectClient client = this.CreateTestAgentClient(agentName: "test-agent", agentDefinitionResponse: definitionResponse); + + // Act + var agent = client.CreateAIAgent( + "test-agent", + "test-model", + "Test instructions", + tools: tools); + + // Assert + Assert.NotNull(agent); + Assert.IsType(agent); + var agentVersion = agent.GetService(); + Assert.NotNull(agentVersion); + if (agentVersion.Definition is PromptAgentDefinition promptDef) + { + Assert.NotEmpty(promptDef.Tools); + Assert.Single(promptDef.Tools); + } + } + + /// + /// Verify that CreateAIAgentAsync with tools in definition creates an agent. + /// + [Fact] + public async Task CreateAIAgentAsync_WithDefinitionTools_CreatesAgentAsync() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + var definition = new PromptAgentDefinition("test-model") { Instructions = "Test instructions" }; + definition.Tools.Add(ResponseTool.CreateFunctionTool("async_tool", BinaryData.FromString("{}"), strictModeEnabled: false)); + + var options = new AgentVersionCreationOptions(definition); + + // Act + var agent = await client.CreateAIAgentAsync("test-agent", options); + + // Assert + Assert.NotNull(agent); + Assert.IsType(agent); + } + + /// + /// Verify that GetAIAgentAsync with tools parameter creates an agent. + /// + [Fact] + public async Task GetAIAgentAsync_WithToolsParameter_CreatesAgentAsync() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + var tools = new List + { + AIFunctionFactory.Create(() => "async_get_result", "async_get_tool", "An async get tool") + }; + + // Act + var agent = await client.GetAIAgentAsync("test-agent", tools: tools); + + // Assert + Assert.NotNull(agent); + Assert.IsType(agent); + } + + #endregion + + #region Declarative Function Handling Tests + + /// + /// Verify that CreateAIAgent accepts declarative functions from definition. + /// + [Fact] + public void CreateAIAgent_WithDeclarativeFunctionInDefinition_AcceptsDeclarativeFunction() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + var definition = new PromptAgentDefinition("test-model") { Instructions = "Test" }; + + // Create a declarative function (not invocable) using AIFunctionFactory.CreateDeclaration + using var doc = JsonDocument.Parse("{}"); + var declarativeFunction = AIFunctionFactory.CreateDeclaration("test_function", "A test function", doc.RootElement); + + // Add to definition + definition.Tools.Add(declarativeFunction.AsOpenAIResponseTool() ?? throw new InvalidOperationException()); + + var options = new AgentVersionCreationOptions(definition); + + // Act + var agent = client.CreateAIAgent("test-agent", options); + + // Assert + Assert.NotNull(agent); + Assert.IsType(agent); + } + + /// + /// Verify that CreateAIAgent accepts declarative functions from definition. + /// + [Fact] + public void CreateAIAgent_WithDeclarativeFunctionFromDefinition_AcceptsDeclarativeFunction() + { + // Arrange + var definition = new PromptAgentDefinition("test-model") { Instructions = "Test" }; + + // Create a declarative function (not invocable) using AIFunctionFactory.CreateDeclaration + using var doc = JsonDocument.Parse("{}"); + var declarativeFunction = AIFunctionFactory.CreateDeclaration("test_function", "A test function", doc.RootElement); + + // Add to definition + definition.Tools.Add(declarativeFunction.AsOpenAIResponseTool() ?? throw new InvalidOperationException()); + + // Generate response with the declarative function + var definitionResponse = new PromptAgentDefinition("test-model") { Instructions = "Test" }; + definitionResponse.Tools.Add(declarativeFunction.AsOpenAIResponseTool() ?? throw new InvalidOperationException()); + + AIProjectClient client = this.CreateTestAgentClient(agentName: "test-agent", agentDefinitionResponse: definitionResponse); + + var options = new AgentVersionCreationOptions(definition); + + // Act + var agent = client.CreateAIAgent("test-agent", options); + + // Assert + Assert.NotNull(agent); + Assert.IsType(agent); + } + + /// + /// Verify that CreateAIAgent accepts FunctionTools from definition. + /// + [Fact] + public void CreateAIAgent_WithFunctionToolsInDefinition_AcceptsDeclarativeFunction() + { + // Arrange + var functionTool = ResponseTool.CreateFunctionTool( + functionName: "get_user_name", + functionParameters: BinaryData.FromString("{}"), + strictModeEnabled: false, + functionDescription: "Gets the user's name, as used for friendly address." + ); + + var definition = new PromptAgentDefinition("test-model") { Instructions = "Test" }; + definition.Tools.Add(functionTool); + + // Generate response with the declarative function + var definitionResponse = new PromptAgentDefinition("test-model") { Instructions = "Test" }; + definitionResponse.Tools.Add(functionTool); + + AIProjectClient client = this.CreateTestAgentClient(agentName: "test-agent", agentDefinitionResponse: definitionResponse); + + var options = new AgentVersionCreationOptions(definition); + + // Act + var agent = client.CreateAIAgent("test-agent", options); + + // Assert + Assert.NotNull(agent); + Assert.IsType(agent); + var definitionFromAgent = Assert.IsType(agent.GetService()?.Definition); + Assert.Single(definitionFromAgent.Tools); + } + + /// + /// Verify that CreateAIAgentAsync accepts FunctionTools from definition. + /// + [Fact] + public async Task CreateAIAgentAsync_WithFunctionToolsInDefinition_AcceptsDeclarativeFunctionAsync() + { + // Arrange + var functionTool = ResponseTool.CreateFunctionTool( + functionName: "get_user_name", + functionParameters: BinaryData.FromString("{}"), + strictModeEnabled: false, + functionDescription: "Gets the user's name, as used for friendly address." + ); + + var definition = new PromptAgentDefinition("test-model") { Instructions = "Test" }; + definition.Tools.Add(functionTool); + + // Generate response with the declarative function + var definitionResponse = new PromptAgentDefinition("test-model") { Instructions = "Test" }; + definitionResponse.Tools.Add(functionTool); + + AIProjectClient client = this.CreateTestAgentClient(agentName: "test-agent", agentDefinitionResponse: definitionResponse); + + var options = new AgentVersionCreationOptions(definition); + + // Act + var agent = await client.CreateAIAgentAsync("test-agent", options); + + // Assert + Assert.NotNull(agent); + Assert.IsType(agent); + } + + /// + /// Verify that CreateAIAgentAsync accepts declarative functions from definition. + /// + [Fact] + public async Task CreateAIAgentAsync_WithDeclarativeFunctionFromDefinition_AcceptsDeclarativeFunctionAsync() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + var definition = new PromptAgentDefinition("test-model") { Instructions = "Test" }; + + // Create a declarative function (not invocable) using AIFunctionFactory.CreateDeclaration + using var doc = JsonDocument.Parse("{}"); + var declarativeFunction = AIFunctionFactory.CreateDeclaration("test_function", "A test function", doc.RootElement); + + // Add to definition + definition.Tools.Add(declarativeFunction.AsOpenAIResponseTool() ?? throw new InvalidOperationException()); + + var options = new AgentVersionCreationOptions(definition); + + // Act + var agent = await client.CreateAIAgentAsync("test-agent", options); + + // Assert + Assert.NotNull(agent); + Assert.IsType(agent); + } + + /// + /// Verify that CreateAIAgentAsync accepts declarative functions from definition. + /// + [Fact] + public async Task CreateAIAgentAsync_WithDeclarativeFunctionInDefinition_AcceptsDeclarativeFunctionAsync() + { + // Arrange + var definition = new PromptAgentDefinition("test-model") { Instructions = "Test" }; + + // Create a declarative function (not invocable) using AIFunctionFactory.CreateDeclaration + using var doc = JsonDocument.Parse("{}"); + var declarativeFunction = AIFunctionFactory.CreateDeclaration("test_function", "A test function", doc.RootElement); + + // Add to definition + definition.Tools.Add(declarativeFunction.AsOpenAIResponseTool() ?? throw new InvalidOperationException()); + + // Generate response with the declarative function + var definitionResponse = new PromptAgentDefinition("test-model") { Instructions = "Test" }; + definitionResponse.Tools.Add(declarativeFunction.AsOpenAIResponseTool() ?? throw new InvalidOperationException()); + + AIProjectClient client = this.CreateTestAgentClient(agentName: "test-agent", agentDefinitionResponse: definitionResponse); + + var options = new AgentVersionCreationOptions(definition); + + // Act + var agent = await client.CreateAIAgentAsync("test-agent", options); + + // Assert + Assert.NotNull(agent); + Assert.IsType(agent); + } + + #endregion + + #region Options Generation Validation Tests + + /// + /// Verify that ChatClientAgentOptions are generated correctly without tools. + /// + [Fact] + public void CreateAIAgent_GeneratesCorrectChatClientAgentOptions() + { + // Arrange + var definition = new PromptAgentDefinition("test-model") { Instructions = "Test instructions" }; + + var definitionResponse = GeneratePromptDefinitionResponse(definition, null); + AIProjectClient client = this.CreateTestAgentClient(agentName: "test-agent", agentDefinitionResponse: definitionResponse); + + var options = new AgentVersionCreationOptions(definition); + + // Act + var agent = client.CreateAIAgent("test-agent", options); + + // Assert + Assert.NotNull(agent); + var agentVersion = agent.GetService(); + Assert.NotNull(agentVersion); + Assert.Equal("test-agent", agentVersion.Name); + Assert.Equal("Test instructions", (agentVersion.Definition as PromptAgentDefinition)?.Instructions); + } + + /// + /// Verify that ChatClientAgentOptions preserve custom properties from input options. + /// + [Fact] + public void GetAIAgent_WithOptions_PreservesCustomProperties() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(agentName: "test-agent", instructions: "Custom instructions", description: "Custom description"); + var options = new ChatClientAgentOptions + { + Name = "test-agent", + Instructions = "Custom instructions", + Description = "Custom description" + }; + + // Act + var agent = client.GetAIAgent(options); + + // Assert + Assert.NotNull(agent); + Assert.Equal("test-agent", agent.Name); + Assert.Equal("Custom instructions", agent.Instructions); + Assert.Equal("Custom description", agent.Description); + } + + /// + /// Verify that CreateAIAgent with options generates correct ChatClientAgentOptions with tools. + /// + [Fact] + public void CreateAIAgent_WithOptionsAndTools_GeneratesCorrectOptions() + { + // Arrange + var tools = new List + { + AIFunctionFactory.Create(() => "result", "option_tool", "A tool from options") + }; + + var definitionResponse = GeneratePromptDefinitionResponse( + new PromptAgentDefinition("test-model") { Instructions = "Test" }, + tools); + + AIProjectClient client = this.CreateTestAgentClient(agentName: "test-agent", agentDefinitionResponse: definitionResponse); + + var options = new ChatClientAgentOptions + { + Name = "test-agent", + Instructions = "Test", + ChatOptions = new ChatOptions { Tools = tools } + }; + + // Act + var agent = client.CreateAIAgent("test-model", options); + + // Assert + Assert.NotNull(agent); + var agentVersion = agent.GetService(); + Assert.NotNull(agentVersion); + if (agentVersion.Definition is PromptAgentDefinition promptDef) + { + Assert.NotEmpty(promptDef.Tools); + Assert.Single(promptDef.Tools); + } + } + + #endregion + + #region AgentName Validation Tests + + /// + /// Verify that GetAIAgent throws ArgumentException when agent name is invalid. + /// + [Theory] + [MemberData(nameof(InvalidAgentNameTestData.GetInvalidAgentNames), MemberType = typeof(InvalidAgentNameTestData))] + public void GetAIAgent_ByName_WithInvalidAgentName_ThrowsArgumentException(string invalidName) + { + // Arrange + var mockClient = new Mock(); + + // Act & Assert + var exception = Assert.Throws(() => + mockClient.Object.GetAIAgent(invalidName)); + + Assert.Equal("name", exception.ParamName); + Assert.Contains("Agent name must be 1-63 characters long", exception.Message); + } + + /// + /// Verify that GetAIAgentAsync throws ArgumentException when agent name is invalid. + /// + [Theory] + [MemberData(nameof(InvalidAgentNameTestData.GetInvalidAgentNames), MemberType = typeof(InvalidAgentNameTestData))] + public async Task GetAIAgentAsync_ByName_WithInvalidAgentName_ThrowsArgumentExceptionAsync(string invalidName) + { + // Arrange + var mockClient = new Mock(); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + mockClient.Object.GetAIAgentAsync(invalidName)); + + Assert.Equal("name", exception.ParamName); + Assert.Contains("Agent name must be 1-63 characters long", exception.Message); + } + + /// + /// Verify that GetAIAgent with ChatClientAgentOptions throws ArgumentException when agent name is invalid. + /// + [Theory] + [MemberData(nameof(InvalidAgentNameTestData.GetInvalidAgentNames), MemberType = typeof(InvalidAgentNameTestData))] + public void GetAIAgent_WithOptions_WithInvalidAgentName_ThrowsArgumentException(string invalidName) + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + var options = new ChatClientAgentOptions { Name = invalidName }; + + // Act & Assert + var exception = Assert.Throws(() => + client.GetAIAgent(options)); + + Assert.Equal("name", exception.ParamName); + Assert.Contains("Agent name must be 1-63 characters long", exception.Message); + } + + /// + /// Verify that GetAIAgentAsync with ChatClientAgentOptions throws ArgumentException when agent name is invalid. + /// + [Theory] + [MemberData(nameof(InvalidAgentNameTestData.GetInvalidAgentNames), MemberType = typeof(InvalidAgentNameTestData))] + public async Task GetAIAgentAsync_WithOptions_WithInvalidAgentName_ThrowsArgumentExceptionAsync(string invalidName) + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + var options = new ChatClientAgentOptions { Name = invalidName }; + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + client.GetAIAgentAsync(options)); + + Assert.Equal("name", exception.ParamName); + Assert.Contains("Agent name must be 1-63 characters long", exception.Message); + } + + /// + /// Verify that CreateAIAgent throws ArgumentException when agent name is invalid. + /// + [Theory] + [MemberData(nameof(InvalidAgentNameTestData.GetInvalidAgentNames), MemberType = typeof(InvalidAgentNameTestData))] + public void CreateAIAgent_WithBasicParams_WithInvalidAgentName_ThrowsArgumentException(string invalidName) + { + // Arrange + var mockClient = new Mock(); + + // Act & Assert + var exception = Assert.Throws(() => + mockClient.Object.CreateAIAgent(invalidName, "model", "instructions")); + + Assert.Equal("name", exception.ParamName); + Assert.Contains("Agent name must be 1-63 characters long", exception.Message); + } + + /// + /// Verify that CreateAIAgentAsync throws ArgumentException when agent name is invalid. + /// + [Theory] + [MemberData(nameof(InvalidAgentNameTestData.GetInvalidAgentNames), MemberType = typeof(InvalidAgentNameTestData))] + public async Task CreateAIAgentAsync_WithBasicParams_WithInvalidAgentName_ThrowsArgumentExceptionAsync(string invalidName) + { + // Arrange + var mockClient = new Mock(); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + mockClient.Object.CreateAIAgentAsync(invalidName, "model", "instructions")); + + Assert.Equal("name", exception.ParamName); + Assert.Contains("Agent name must be 1-63 characters long", exception.Message); + } + + /// + /// Verify that CreateAIAgent with AgentVersionCreationOptions throws ArgumentException when agent name is invalid. + /// + [Theory] + [MemberData(nameof(InvalidAgentNameTestData.GetInvalidAgentNames), MemberType = typeof(InvalidAgentNameTestData))] + public void CreateAIAgent_WithAgentDefinition_WithInvalidAgentName_ThrowsArgumentException(string invalidName) + { + // Arrange + var mockClient = new Mock(); + var definition = new PromptAgentDefinition("test-model"); + var options = new AgentVersionCreationOptions(definition); + + // Act & Assert + var exception = Assert.Throws(() => + mockClient.Object.CreateAIAgent(invalidName, options)); + + Assert.Equal("name", exception.ParamName); + Assert.Contains("Agent name must be 1-63 characters long", exception.Message); + } + + /// + /// Verify that CreateAIAgentAsync with AgentVersionCreationOptions throws ArgumentException when agent name is invalid. + /// + [Theory] + [MemberData(nameof(InvalidAgentNameTestData.GetInvalidAgentNames), MemberType = typeof(InvalidAgentNameTestData))] + public async Task CreateAIAgentAsync_WithAgentDefinition_WithInvalidAgentName_ThrowsArgumentExceptionAsync(string invalidName) + { + // Arrange + var mockClient = new Mock(); + var definition = new PromptAgentDefinition("test-model"); + var options = new AgentVersionCreationOptions(definition); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + mockClient.Object.CreateAIAgentAsync(invalidName, options)); + + Assert.Equal("name", exception.ParamName); + Assert.Contains("Agent name must be 1-63 characters long", exception.Message); + } + + /// + /// Verify that CreateAIAgent with ChatClientAgentOptions throws ArgumentException when agent name is invalid. + /// + [Theory] + [MemberData(nameof(InvalidAgentNameTestData.GetInvalidAgentNames), MemberType = typeof(InvalidAgentNameTestData))] + public void CreateAIAgent_WithOptions_WithInvalidAgentName_ThrowsArgumentException(string invalidName) + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + var options = new ChatClientAgentOptions { Name = invalidName }; + + // Act & Assert + var exception = Assert.Throws(() => + client.CreateAIAgent("test-model", options)); + + Assert.Equal("name", exception.ParamName); + Assert.Contains("Agent name must be 1-63 characters long", exception.Message); + } + + /// + /// Verify that CreateAIAgentAsync with ChatClientAgentOptions throws ArgumentException when agent name is invalid. + /// + [Theory] + [MemberData(nameof(InvalidAgentNameTestData.GetInvalidAgentNames), MemberType = typeof(InvalidAgentNameTestData))] + public async Task CreateAIAgentAsync_WithOptions_WithInvalidAgentName_ThrowsArgumentExceptionAsync(string invalidName) + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + var options = new ChatClientAgentOptions { Name = invalidName }; + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + client.CreateAIAgentAsync("test-model", options)); + + Assert.Equal("name", exception.ParamName); + Assert.Contains("Agent name must be 1-63 characters long", exception.Message); + } + + /// + /// Verify that GetAIAgent with AgentReference throws ArgumentException when agent name is invalid. + /// + [Theory] + [MemberData(nameof(InvalidAgentNameTestData.GetInvalidAgentNames), MemberType = typeof(InvalidAgentNameTestData))] + public void GetAIAgent_WithAgentReference_WithInvalidAgentName_ThrowsArgumentException(string invalidName) + { + // Arrange + var mockClient = new Mock(); + var agentReference = new AgentReference(invalidName, "1"); + + // Act & Assert + var exception = Assert.Throws(() => + mockClient.Object.GetAIAgent(agentReference)); + + Assert.Equal("name", exception.ParamName); + Assert.Contains("Agent name must be 1-63 characters long", exception.Message); + } + + #endregion + + #region AzureAIChatClient Behavior Tests + + /// + /// Verify that the underlying chat client created by extension methods can be wrapped with clientFactory. + /// + [Fact] + public void GetAIAgent_WithClientFactory_WrapsUnderlyingChatClient() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + AgentRecord agentRecord = this.CreateTestAgentRecord(); + int factoryCallCount = 0; + + // Act + var agent = client.GetAIAgent( + agentRecord, + clientFactory: (innerClient) => + { + factoryCallCount++; + return new TestChatClient(innerClient); + }); + + // Assert + Assert.NotNull(agent); + Assert.Equal(1, factoryCallCount); + var wrappedClient = agent.GetService(); + Assert.NotNull(wrappedClient); + } + + /// + /// Verify that clientFactory is called with the correct underlying chat client. + /// + [Fact] + public void CreateAIAgent_WithClientFactory_ReceivesCorrectUnderlyingClient() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + var definition = new PromptAgentDefinition("test-model") { Instructions = "Test" }; + IChatClient? receivedClient = null; + + var options = new AgentVersionCreationOptions(definition); + + // Act + var agent = client.CreateAIAgent( + "test-agent", + options, + clientFactory: (innerClient) => + { + receivedClient = innerClient; + return new TestChatClient(innerClient); + }); + + // Assert + Assert.NotNull(agent); + Assert.NotNull(receivedClient); + var wrappedClient = agent.GetService(); + Assert.NotNull(wrappedClient); + } + + /// + /// Verify that multiple clientFactory calls create independent wrapped clients. + /// + [Fact] + public void GetAIAgent_MultipleCallsWithClientFactory_CreatesIndependentClients() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + AgentRecord agentRecord = this.CreateTestAgentRecord(); + + // Act + var agent1 = client.GetAIAgent( + agentRecord, + clientFactory: (innerClient) => new TestChatClient(innerClient)); + + var agent2 = client.GetAIAgent( + agentRecord, + clientFactory: (innerClient) => new TestChatClient(innerClient)); + + // Assert + Assert.NotNull(agent1); + Assert.NotNull(agent2); + var client1 = agent1.GetService(); + var client2 = agent2.GetService(); + Assert.NotNull(client1); + Assert.NotNull(client2); + Assert.NotSame(client1, client2); + } + + /// + /// Verify that agent created with clientFactory maintains agent properties. + /// + [Fact] + public void CreateAIAgent_WithClientFactory_PreservesAgentProperties() + { + // Arrange + const string AgentName = "test-agent"; + const string Model = "test-model"; + const string Instructions = "Test instructions"; + AIProjectClient client = this.CreateTestAgentClient(AgentName, Instructions); + + // Act + var agent = client.CreateAIAgent( + AgentName, + Model, + Instructions, + clientFactory: (innerClient) => new TestChatClient(innerClient)); + + // Assert + Assert.NotNull(agent); + Assert.Equal(AgentName, agent.Name); + Assert.Equal(Instructions, agent.Instructions); + var wrappedClient = agent.GetService(); + Assert.NotNull(wrappedClient); + } + + /// + /// Verify that agent created with clientFactory is created successfully. + /// + [Fact] + public void CreateAIAgent_WithClientFactory_CreatesAgentSuccessfully() + { + // Arrange + var definition = new PromptAgentDefinition("test-model") { Instructions = "Test" }; + + var agentDefinitionResponse = GeneratePromptDefinitionResponse(definition, null); + AIProjectClient client = this.CreateTestAgentClient(agentName: "test-agent", agentDefinitionResponse: agentDefinitionResponse); + + var options = new AgentVersionCreationOptions(definition); + + // Act + var agent = client.CreateAIAgent( + "test-agent", + options, + clientFactory: (innerClient) => new TestChatClient(innerClient)); + + // Assert + Assert.NotNull(agent); + var wrappedClient = agent.GetService(); + Assert.NotNull(wrappedClient); + var agentVersion = agent.GetService(); + Assert.NotNull(agentVersion); + } + + #endregion + + #region User-Agent Header Tests + + /// + /// Verify that GetAIAgent(string name) passes RequestOptions to the Protocol method. + /// + [Fact] + public void GetAIAgent_WithStringName_PassesRequestOptionsToProtocol() + { + // Arrange + RequestOptions? capturedRequestOptions = null; + + var mockAgentOperations = new Mock(); + mockAgentOperations + .Setup(x => x.GetAgent(It.IsAny(), It.IsAny())) + .Callback((name, options) => capturedRequestOptions = options) + .Returns(ClientResult.FromResponse(new MockPipelineResponse(200, BinaryData.FromString(TestDataUtil.GetAgentResponseJson())))); + + var mockAgentClient = new Mock(new Uri("https://test.openai.azure.com/"), new FakeAuthenticationTokenProvider()); + mockAgentClient.SetupGet(x => x.Agents).Returns(mockAgentOperations.Object); + mockAgentClient.Setup(x => x.GetConnection(It.IsAny())).Returns(new ClientConnection("fake-connection-id", "http://localhost", ClientPipeline.Create(), CredentialKind.None)); + + // Act + var agent = mockAgentClient.Object.GetAIAgent("test-agent"); + + // Assert + Assert.NotNull(agent); + Assert.NotNull(capturedRequestOptions); + } + + /// + /// Verify that GetAIAgentAsync(string name) passes RequestOptions to the Protocol method. + /// + [Fact] + public async Task GetAIAgentAsync_WithStringName_PassesRequestOptionsToProtocolAsync() + { + // Arrange + RequestOptions? capturedRequestOptions = null; + + var mockAgentOperations = new Mock(); + mockAgentOperations + .Setup(x => x.GetAgentAsync(It.IsAny(), It.IsAny())) + .Callback((name, options) => capturedRequestOptions = options) + .Returns(Task.FromResult(ClientResult.FromResponse(new MockPipelineResponse(200, BinaryData.FromString(TestDataUtil.GetAgentResponseJson()))))); + + var mockAgentClient = new Mock(new Uri("https://test.openai.azure.com/"), new FakeAuthenticationTokenProvider()); + mockAgentClient.SetupGet(x => x.Agents).Returns(mockAgentOperations.Object); + mockAgentClient.Setup(x => x.GetConnection(It.IsAny())).Returns(new ClientConnection("fake-connection-id", "http://localhost", ClientPipeline.Create(), CredentialKind.None)); + // Act + var agent = await mockAgentClient.Object.GetAIAgentAsync("test-agent"); + + // Assert + Assert.NotNull(agent); + Assert.NotNull(capturedRequestOptions); + } + + /// + /// Verify that CreateAIAgent(string model, ChatClientAgentOptions options) passes RequestOptions to the Protocol method. + /// + [Fact] + public void CreateAIAgent_WithChatClientAgentOptions_PassesRequestOptionsToProtocol() + { + // Arrange + RequestOptions? capturedRequestOptions = null; + + var mockAgentOperations = new Mock(); + mockAgentOperations + .Setup(x => x.CreateAgentVersion(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((name, content, options) => capturedRequestOptions = options) + .Returns(ClientResult.FromResponse(new MockPipelineResponse(200, BinaryData.FromString(TestDataUtil.GetAgentVersionResponseJson())))); + + var mockAgentClient = new Mock(new Uri("https://test.openai.azure.com/"), new FakeAuthenticationTokenProvider()); + mockAgentClient.SetupGet(x => x.Agents).Returns(mockAgentOperations.Object); + mockAgentClient.Setup(x => x.GetConnection(It.IsAny())).Returns(new ClientConnection("fake-connection-id", "http://localhost", ClientPipeline.Create(), CredentialKind.None)); + + var agentOptions = new ChatClientAgentOptions { Name = "test-agent" }; + + // Act + var agent = mockAgentClient.Object.CreateAIAgent("gpt-4", agentOptions); + + // Assert + Assert.NotNull(agent); + Assert.NotNull(capturedRequestOptions); + } + + /// + /// Verify that CreateAIAgentAsync(string model, ChatClientAgentOptions options) passes RequestOptions to the Protocol method. + /// + [Fact] + public async Task CreateAIAgentAsync_WithChatClientAgentOptions_PassesRequestOptionsToProtocolAsync() + { + // Arrange + RequestOptions? capturedRequestOptions = null; + + var mockAgentOperations = new Mock(); + mockAgentOperations + .Setup(x => x.CreateAgentVersionAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((name, content, options) => capturedRequestOptions = options) + .Returns(Task.FromResult(ClientResult.FromResponse(new MockPipelineResponse(200, BinaryData.FromString(TestDataUtil.GetAgentVersionResponseJson()))))); + + var mockAgentClient = new Mock(new Uri("https://test.openai.azure.com/"), new FakeAuthenticationTokenProvider()); + mockAgentClient.SetupGet(x => x.Agents).Returns(mockAgentOperations.Object); + mockAgentClient.Setup(x => x.GetConnection(It.IsAny())).Returns(new ClientConnection("fake-connection-id", "http://localhost", ClientPipeline.Create(), CredentialKind.None)); + + var agentOptions = new ChatClientAgentOptions { Name = "test-agent" }; + + // Act + var agent = await mockAgentClient.Object.CreateAIAgentAsync("gpt-4", agentOptions); + + // Assert + Assert.NotNull(agent); + Assert.NotNull(capturedRequestOptions); + } + + /// + /// Verifies that the user-agent header is added to both synchronous and asynchronous requests made by agent creation methods. + /// + [Fact] + public async Task CreateAIAgent_UserAgentHeaderAddedToRequestsAsync() + { + using var httpHandler = new HttpHandlerAssert(request => + { + Assert.Equal("POST", request.Method.Method); + Assert.Contains("MEAI", request.Headers.UserAgent.ToString()); + + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(TestDataUtil.GetAgentResponseJson(), Encoding.UTF8, "application/json") }; + }); + +#pragma warning disable CA5399 + using var httpClient = new HttpClient(httpHandler); +#pragma warning restore CA5399 + + // Arrange + var aiProjectClient = new AIProjectClient(new Uri("https://test.openai.azure.com/"), new FakeAuthenticationTokenProvider(), new() { Transport = new HttpClientPipelineTransport(httpClient) }); + + var agentOptions = new ChatClientAgentOptions { Name = "test-agent" }; + + // Act + var agent1 = aiProjectClient.CreateAIAgent("test", agentOptions); + var agent2 = await aiProjectClient.CreateAIAgentAsync("test", agentOptions); + + // Assert + Assert.NotNull(agent1); + Assert.NotNull(agent2); + } + + /// + /// Verifies that the user-agent header is added to both synchronous and asynchronous GetAIAgent requests. + /// + [Fact] + public async Task GetAIAgent_UserAgentHeaderAddedToRequestsAsync() + { + using var httpHandler = new HttpHandlerAssert(request => + { + Assert.Equal("GET", request.Method.Method); + Assert.Contains("MEAI", request.Headers.UserAgent.ToString()); + + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(TestDataUtil.GetAgentResponseJson(), Encoding.UTF8, "application/json") }; + }); + +#pragma warning disable CA5399 + using var httpClient = new HttpClient(httpHandler); +#pragma warning restore CA5399 + + // Arrange + var aiProjectClient = new AIProjectClient(new Uri("https://test.openai.azure.com/"), new FakeAuthenticationTokenProvider(), new() { Transport = new HttpClientPipelineTransport(httpClient) }); + + // Act + var agent1 = aiProjectClient.GetAIAgent("test"); + var agent2 = await aiProjectClient.GetAIAgentAsync("test"); + + // Assert + Assert.NotNull(agent1); + Assert.NotNull(agent2); + } + + #endregion + + #region GetAIAgent(AIProjectClient, AgentReference) Tests + + /// + /// Verify that GetAIAgent throws ArgumentNullException when AIProjectClient is null. + /// + [Fact] + public void GetAIAgent_WithAgentReference_WithNullClient_ThrowsArgumentNullException() + { + // Arrange + AIProjectClient? client = null; + var agentReference = new AgentReference("test-name", "1"); + + // Act & Assert + var exception = Assert.Throws(() => + client!.GetAIAgent(agentReference)); + + Assert.Equal("aiProjectClient", exception.ParamName); + } + + /// + /// Verify that GetAIAgent throws ArgumentNullException when agentReference is null. + /// + [Fact] + public void GetAIAgent_WithAgentReference_WithNullAgentReference_ThrowsArgumentNullException() + { + // Arrange + var mockClient = new Mock(); + + // Act & Assert + var exception = Assert.Throws(() => + mockClient.Object.GetAIAgent((AgentReference)null!)); + + Assert.Equal("agentReference", exception.ParamName); + } + + /// + /// Verify that GetAIAgent with AgentReference creates a valid agent. + /// + [Fact] + public void GetAIAgent_WithAgentReference_CreatesValidAgent() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + var agentReference = new AgentReference("test-name", "1"); + + // Act + var agent = client.GetAIAgent(agentReference); + + // Assert + Assert.NotNull(agent); + Assert.Equal("test-name", agent.Name); + Assert.Equal("test-name:1", agent.Id); + } + + /// + /// Verify that GetAIAgent with AgentReference and clientFactory applies the factory. + /// + [Fact] + public void GetAIAgent_WithAgentReference_WithClientFactory_AppliesFactoryCorrectly() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + var agentReference = new AgentReference("test-name", "1"); + TestChatClient? testChatClient = null; + + // Act + var agent = client.GetAIAgent( + agentReference, + clientFactory: (innerClient) => testChatClient = new TestChatClient(innerClient)); + + // Assert + Assert.NotNull(agent); + var retrievedTestClient = agent.GetService(); + Assert.NotNull(retrievedTestClient); + Assert.Same(testChatClient, retrievedTestClient); + } + + /// + /// Verify that GetAIAgent with AgentReference sets the agent ID correctly. + /// + [Fact] + public void GetAIAgent_WithAgentReference_SetsAgentIdCorrectly() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + var agentReference = new AgentReference("test-name", "2"); + + // Act + var agent = client.GetAIAgent(agentReference); + + // Assert + Assert.NotNull(agent); + Assert.Equal("test-name:2", agent.Id); + } + + /// + /// Verify that GetAIAgent with AgentReference and tools includes the tools in ChatOptions. + /// + [Fact] + public void GetAIAgent_WithAgentReference_WithTools_IncludesToolsInChatOptions() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + var agentReference = new AgentReference("test-name", "1"); + var tools = new List + { + AIFunctionFactory.Create(() => "test", "test_function", "A test function") + }; + + // Act + var agent = client.GetAIAgent(agentReference, tools: tools); + + // Assert + Assert.NotNull(agent); + var chatOptions = GetAgentChatOptions(agent); + Assert.NotNull(chatOptions); + Assert.NotNull(chatOptions.Tools); + Assert.Single(chatOptions.Tools); + } + + #endregion + + #region GetService Tests + + /// + /// Verify that GetService returns AgentRecord for agents created from AgentRecord. + /// + [Fact] + public void GetService_WithAgentRecord_ReturnsAgentRecord() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + AgentRecord agentRecord = this.CreateTestAgentRecord(); + + // Act + var agent = client.GetAIAgent(agentRecord); + var retrievedRecord = agent.GetService(); + + // Assert + Assert.NotNull(retrievedRecord); + Assert.Equal(agentRecord.Id, retrievedRecord.Id); + } + + /// + /// Verify that GetService returns null for AgentRecord when agent is created from AgentReference. + /// + [Fact] + public void GetService_WithAgentReference_ReturnsNullForAgentRecord() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + var agentReference = new AgentReference("test-name", "1"); + + // Act + var agent = client.GetAIAgent(agentReference); + var retrievedRecord = agent.GetService(); + + // Assert + Assert.Null(retrievedRecord); + } + + #endregion + + #region GetService Tests + + /// + /// Verify that GetService returns AgentVersion for agents created from AgentVersion. + /// + [Fact] + public void GetService_WithAgentVersion_ReturnsAgentVersion() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + AgentVersion agentVersion = this.CreateTestAgentVersion(); + + // Act + var agent = client.GetAIAgent(agentVersion); + var retrievedVersion = agent.GetService(); + + // Assert + Assert.NotNull(retrievedVersion); + Assert.Equal(agentVersion.Id, retrievedVersion.Id); + } + + /// + /// Verify that GetService returns null for AgentVersion when agent is created from AgentReference. + /// + [Fact] + public void GetService_WithAgentReference_ReturnsNullForAgentVersion() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + var agentReference = new AgentReference("test-name", "1"); + + // Act + var agent = client.GetAIAgent(agentReference); + var retrievedVersion = agent.GetService(); + + // Assert + Assert.Null(retrievedVersion); + } + + #endregion + + #region ChatClientMetadata Tests + + /// + /// Verify that ChatClientMetadata is properly populated for agents created from AgentRecord. + /// + [Fact] + public void ChatClientMetadata_WithAgentRecord_IsPopulatedCorrectly() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + AgentRecord agentRecord = this.CreateTestAgentRecord(); + + // Act + var agent = client.GetAIAgent(agentRecord); + var metadata = agent.GetService(); + + // Assert + Assert.NotNull(metadata); + Assert.NotNull(metadata.DefaultModelId); + } + + /// + /// Verify that ChatClientMetadata.DefaultModelId is set from PromptAgentDefinition model property. + /// + [Fact] + public void ChatClientMetadata_WithPromptAgentDefinition_SetsDefaultModelIdFromModel() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + var definition = new PromptAgentDefinition("gpt-4-turbo") + { + Instructions = "Test instructions" + }; + AgentRecord agentRecord = this.CreateTestAgentRecord(definition); + + // Act + var agent = client.GetAIAgent(agentRecord); + var metadata = agent.GetService(); + + // Assert + Assert.NotNull(metadata); + // The metadata should contain the model information from the agent definition + Assert.NotNull(metadata.DefaultModelId); + Assert.Equal("gpt-4-turbo", metadata.DefaultModelId); + } + + /// + /// Verify that ChatClientMetadata is properly populated for agents created from AgentVersion. + /// + [Fact] + public void ChatClientMetadata_WithAgentVersion_IsPopulatedCorrectly() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + AgentVersion agentVersion = this.CreateTestAgentVersion(); + + // Act + var agent = client.GetAIAgent(agentVersion); + var metadata = agent.GetService(); + + // Assert + Assert.NotNull(metadata); + Assert.NotNull(metadata.DefaultModelId); + Assert.Equal((agentVersion.Definition as PromptAgentDefinition)!.Model, metadata.DefaultModelId); + } + + #endregion + + #region AgentReference Availability Tests + + /// + /// Verify that GetService returns AgentReference for agents created from AgentReference. + /// + [Fact] + public void GetService_WithAgentReference_ReturnsAgentReference() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + var agentReference = new AgentReference("test-agent", "1.0"); + + // Act + var agent = client.GetAIAgent(agentReference); + var retrievedReference = agent.GetService(); + + // Assert + Assert.NotNull(retrievedReference); + Assert.Equal("test-agent", retrievedReference.Name); + Assert.Equal("1.0", retrievedReference.Version); + } + + /// + /// Verify that GetService returns null for AgentReference when agent is created from AgentRecord. + /// + [Fact] + public void GetService_WithAgentRecord_ReturnsAlsoAgentReference() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + AgentRecord agentRecord = this.CreateTestAgentRecord(); + + // Act + var agent = client.GetAIAgent(agentRecord); + var retrievedReference = agent.GetService(); + + // Assert + Assert.NotNull(retrievedReference); + Assert.Equal(agentRecord.Name, retrievedReference.Name); + } + + /// + /// Verify that GetService returns null for AgentReference when agent is created from AgentVersion. + /// + [Fact] + public void GetService_WithAgentVersion_ReturnsAlsoAgentReference() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + AgentVersion agentVersion = this.CreateTestAgentVersion(); + + // Act + var agent = client.GetAIAgent(agentVersion); + var retrievedReference = agent.GetService(); + + // Assert + Assert.NotNull(retrievedReference); + Assert.Equal(agentVersion.Name, retrievedReference.Name); + } + + /// + /// Verify that GetService returns AgentReference with correct version information. + /// + [Fact] + public void GetService_WithAgentReference_ReturnsCorrectVersionInformation() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + var agentReference = new AgentReference("versioned-agent", "3.5"); + + // Act + var agent = client.GetAIAgent(agentReference); + var retrievedReference = agent.GetService(); + + // Assert + Assert.NotNull(retrievedReference); + Assert.Equal("versioned-agent", retrievedReference.Name); + Assert.Equal("3.5", retrievedReference.Version); + } + + #endregion + + #region Helper Methods + + /// + /// Creates a test AIProjectClient with fake behavior. + /// + private FakeAgentClient CreateTestAgentClient(string? agentName = null, string? instructions = null, string? description = null, AgentDefinition? agentDefinitionResponse = null) + { + return new FakeAgentClient(agentName, instructions, description, agentDefinitionResponse); + } + + /// + /// Creates a test AgentRecord for testing. + /// + private AgentRecord CreateTestAgentRecord(AgentDefinition? agentDefinition = null) + { + return ModelReaderWriter.Read(BinaryData.FromString(TestDataUtil.GetAgentResponseJson(agentDefinition: agentDefinition)))!; + } + + private const string OpenAPISpec = """ + { + "openapi": "3.0.3", + "info": { "title": "Tiny Test API", "version": "1.0.0" }, + "paths": { + "/ping": { + "get": { + "summary": "Health check", + "operationId": "getPing", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { "message": { "type": "string" } }, + "required": ["message"] + }, + "example": { "message": "pong" } + } + } + } + } + } + } + } + } + """; + + /// + /// Creates a test AgentVersion for testing. + /// + private AgentVersion CreateTestAgentVersion() + { + return ModelReaderWriter.Read(BinaryData.FromString(TestDataUtil.GetAgentVersionResponseJson()))!; + } + + /// + /// Fake AIProjectClient for testing. + /// + private sealed class FakeAgentClient : AIProjectClient + { + public FakeAgentClient(string? agentName = null, string? instructions = null, string? description = null, AgentDefinition? agentDefinitionResponse = null) + { + this.Agents = new FakeAIProjectAgentsOperations(agentName, instructions, description, agentDefinitionResponse); + } + + public override ClientConnection GetConnection(string connectionId) + { + return new ClientConnection("fake-connection-id", "http://localhost", ClientPipeline.Create(), CredentialKind.None); + } + + public override AIProjectAgentsOperations Agents { get; } + + private sealed class FakeAIProjectAgentsOperations : AIProjectAgentsOperations + { + private readonly string? _agentName; + private readonly string? _instructions; + private readonly string? _description; + private readonly AgentDefinition? _agentDefinition; + + public FakeAIProjectAgentsOperations(string? agentName = null, string? instructions = null, string? description = null, AgentDefinition? agentDefinitionResponse = null) + { + this._agentName = agentName; + this._instructions = instructions; + this._description = description; + this._agentDefinition = agentDefinitionResponse; + } + + public override ClientResult GetAgent(string agentName, RequestOptions options) + { + var responseJson = TestDataUtil.GetAgentResponseJson(this._agentName, this._agentDefinition, this._instructions, this._description); + return ClientResult.FromValue(ModelReaderWriter.Read(BinaryData.FromString(responseJson))!, new MockPipelineResponse(200, BinaryData.FromString(responseJson))); + } + + public override ClientResult GetAgent(string agentName, CancellationToken cancellationToken = default) + { + var responseJson = TestDataUtil.GetAgentResponseJson(this._agentName, this._agentDefinition, this._instructions, this._description); + return ClientResult.FromValue(ModelReaderWriter.Read(BinaryData.FromString(responseJson))!, new MockPipelineResponse(200)); + } + + public override Task GetAgentAsync(string agentName, RequestOptions options) + { + var responseJson = TestDataUtil.GetAgentResponseJson(this._agentName, this._agentDefinition, this._instructions, this._description); + return Task.FromResult(ClientResult.FromValue(ModelReaderWriter.Read(BinaryData.FromString(responseJson))!, new MockPipelineResponse(200, BinaryData.FromString(responseJson)))); + } + + public override Task> GetAgentAsync(string agentName, CancellationToken cancellationToken = default) + { + var responseJson = TestDataUtil.GetAgentResponseJson(this._agentName, this._agentDefinition, this._instructions, this._description); + return Task.FromResult(ClientResult.FromValue(ModelReaderWriter.Read(BinaryData.FromString(responseJson))!, new MockPipelineResponse(200))); + } + + public override ClientResult CreateAgentVersion(string agentName, BinaryContent content, RequestOptions? options = null) + { + var responseJson = TestDataUtil.GetAgentVersionResponseJson(this._agentName, this._agentDefinition, this._instructions, this._description); + return ClientResult.FromValue(ModelReaderWriter.Read(BinaryData.FromString(responseJson))!, new MockPipelineResponse(200, BinaryData.FromString(responseJson))); + } + + public override ClientResult CreateAgentVersion(string agentName, AgentVersionCreationOptions? options = null, CancellationToken cancellationToken = default) + { + var responseJson = TestDataUtil.GetAgentVersionResponseJson(this._agentName, this._agentDefinition, this._instructions, this._description); + return ClientResult.FromValue(ModelReaderWriter.Read(BinaryData.FromString(responseJson))!, new MockPipelineResponse(200)); + } + + public override Task CreateAgentVersionAsync(string agentName, BinaryContent content, RequestOptions? options = null) + { + var responseJson = TestDataUtil.GetAgentVersionResponseJson(this._agentName, this._agentDefinition, this._instructions, this._description); + return Task.FromResult(ClientResult.FromValue(ModelReaderWriter.Read(BinaryData.FromString(responseJson))!, new MockPipelineResponse(200, BinaryData.FromString(responseJson)))); + } + + public override Task> CreateAgentVersionAsync(string agentName, AgentVersionCreationOptions? options = null, CancellationToken cancellationToken = default) + { + var responseJson = TestDataUtil.GetAgentVersionResponseJson(this._agentName, this._agentDefinition, this._instructions, this._description); + return Task.FromResult(ClientResult.FromValue(ModelReaderWriter.Read(BinaryData.FromString(responseJson))!, new MockPipelineResponse(200))); + } + } + } + + private static PromptAgentDefinition GeneratePromptDefinitionResponse(PromptAgentDefinition inputDefinition, List? tools) + { + var definitionResponse = new PromptAgentDefinition(inputDefinition.Model) { Instructions = inputDefinition.Instructions }; + if (tools is not null) + { + foreach (var tool in tools) + { + definitionResponse.Tools.Add(tool.GetService() ?? tool.AsOpenAIResponseTool()); + } + } + + return definitionResponse; + } + + /// + /// Test custom chat client that can be used to verify clientFactory functionality. + /// + private sealed class TestChatClient : DelegatingChatClient + { + public TestChatClient(IChatClient innerClient) : base(innerClient) + { + } + } + + /// + /// Mock pipeline response for testing ClientResult wrapping. + /// + private sealed class MockPipelineResponse : PipelineResponse + { + private readonly int _status; + private readonly BinaryData _content; + private readonly MockPipelineResponseHeaders _headers; + + public MockPipelineResponse(int status, BinaryData? content = null) + { + this._status = status; + this._content = content ?? BinaryData.Empty; + this._headers = new MockPipelineResponseHeaders(); + } + + public override int Status => this._status; + + public override string ReasonPhrase => "OK"; + + public override Stream? ContentStream + { + get => null; + set { } + } + + public override BinaryData Content => this._content; + + protected override PipelineResponseHeaders HeadersCore => this._headers; + + public override BinaryData BufferContent(CancellationToken cancellationToken = default) => + throw new NotSupportedException("Buffering content is not supported for mock responses."); + + public override ValueTask BufferContentAsync(CancellationToken cancellationToken = default) => + throw new NotSupportedException("Buffering content asynchronously is not supported for mock responses."); + + public override void Dispose() + { + } + + private sealed class MockPipelineResponseHeaders : PipelineResponseHeaders + { + private readonly Dictionary _headers = new(StringComparer.OrdinalIgnoreCase) + { + { "Content-Type", "application/json" }, + { "x-ms-request-id", "test-request-id" } + }; + + public override bool TryGetValue(string name, out string? value) + { + return this._headers.TryGetValue(name, out value); + } + + public override bool TryGetValues(string name, out IEnumerable? values) + { + if (this._headers.TryGetValue(name, out var value)) + { + values = new[] { value }; + return true; + } + + values = null; + return false; + } + + public override IEnumerator> GetEnumerator() + { + return this._headers.GetEnumerator(); + } + } + } + + #endregion + + /// + /// Helper method to access internal ChatOptions property via reflection. + /// + private static ChatOptions? GetAgentChatOptions(ChatClientAgent agent) + { + if (agent is null) + { + return null; + } + + var chatOptionsProperty = typeof(ChatClientAgent).GetProperty( + "ChatOptions", + System.Reflection.BindingFlags.Public | + System.Reflection.BindingFlags.NonPublic | + System.Reflection.BindingFlags.Instance); + + return chatOptionsProperty?.GetValue(agent) as ChatOptions; + } +} + +/// +/// Provides test data for invalid agent name validation tests. +/// +internal static class InvalidAgentNameTestData +{ + /// + /// Gets a collection of invalid agent names for theory-based testing. + /// + /// Collection of invalid agent name test cases. + public static IEnumerable GetInvalidAgentNames() + { + yield return new object[] { "-agent" }; + yield return new object[] { "agent-" }; + yield return new object[] { "agent_name" }; + yield return new object[] { "agent name" }; + yield return new object[] { "agent@name" }; + yield return new object[] { "agent#name" }; + yield return new object[] { "agent$name" }; + yield return new object[] { "agent%name" }; + yield return new object[] { "agent&name" }; + yield return new object[] { "agent*name" }; + yield return new object[] { "agent.name" }; + yield return new object[] { "agent/name" }; + yield return new object[] { "agent\\name" }; + yield return new object[] { "agent:name" }; + yield return new object[] { "agent;name" }; + yield return new object[] { "agent,name" }; + yield return new object[] { "agentname" }; + yield return new object[] { "agent?name" }; + yield return new object[] { "agent!name" }; + yield return new object[] { "agent~name" }; + yield return new object[] { "agent`name" }; + yield return new object[] { "agent^name" }; + yield return new object[] { "agent|name" }; + yield return new object[] { "agent[name" }; + yield return new object[] { "agent]name" }; + yield return new object[] { "agent{name" }; + yield return new object[] { "agent}name" }; + yield return new object[] { "agent(name" }; + yield return new object[] { "agent)name" }; + yield return new object[] { "agent+name" }; + yield return new object[] { "agent=name" }; + yield return new object[] { "a" + new string('b', 63) }; + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/AzureAIProjectChatClientTests.cs b/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/AzureAIProjectChatClientTests.cs new file mode 100644 index 0000000000..647beb4451 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/AzureAIProjectChatClientTests.cs @@ -0,0 +1,212 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.ClientModel.Primitives; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using Azure.AI.Projects; + +namespace Microsoft.Agents.AI.AzureAI.UnitTests; + +public class AzureAIProjectChatClientTests +{ + /// + /// Verify that when the ChatOptions has a "conv_" prefixed conversation ID, the chat client uses conversation in the http requests via the chat client + /// + [Fact] + public async Task ChatClient_UsesDefaultConversationIdAsync() + { + // Arrange + var requestTriggered = false; + using var httpHandler = new HttpHandlerAssert(async (request) => + { + if (request.RequestUri!.PathAndQuery.Contains("openai/responses")) + { + requestTriggered = true; + + // Assert + if (request.Content is not null) + { + var requestBody = await request.Content.ReadAsStringAsync().ConfigureAwait(false); + Assert.Contains("conv_12345", requestBody); + } + + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(TestDataUtil.GetOpenAIDefaultResponseJson(), Encoding.UTF8, "application/json") }; + } + + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(TestDataUtil.GetAgentResponseJson(), Encoding.UTF8, "application/json") }; + }); + +#pragma warning disable CA5399 + using var httpClient = new HttpClient(httpHandler); +#pragma warning restore CA5399 + + var client = new AIProjectClient(new Uri("https://test.openai.azure.com/"), new FakeAuthenticationTokenProvider(), new() { Transport = new HttpClientPipelineTransport(httpClient) }); + + var agent = await client.GetAIAgentAsync( + new ChatClientAgentOptions + { + Name = "test-agent", + Instructions = "Test instructions", + ChatOptions = new() { ConversationId = "conv_12345" } + }); + + // Act + var thread = agent.GetNewThread(); + await agent.RunAsync("Hello", thread); + + Assert.True(requestTriggered); + var chatClientThread = Assert.IsType(thread); + Assert.Equal("conv_12345", chatClientThread.ConversationId); + } + + /// + /// Verify that when the chat client doesn't have a default "conv_" conversation id, the chat client still uses the conversation ID in HTTP requests. + /// + [Fact] + public async Task ChatClient_UsesPerRequestConversationId_WhenNoDefaultConversationIdIsProvidedAsync() + { + // Arrange + var requestTriggered = false; + using var httpHandler = new HttpHandlerAssert(async (request) => + { + if (request.RequestUri!.PathAndQuery.Contains("openai/responses")) + { + requestTriggered = true; + + // Assert + if (request.Content is not null) + { + var requestBody = await request.Content.ReadAsStringAsync().ConfigureAwait(false); + Assert.Contains("conv_12345", requestBody); + } + + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(TestDataUtil.GetOpenAIDefaultResponseJson(), Encoding.UTF8, "application/json") }; + } + + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(TestDataUtil.GetAgentResponseJson(), Encoding.UTF8, "application/json") }; + }); + +#pragma warning disable CA5399 + using var httpClient = new HttpClient(httpHandler); +#pragma warning restore CA5399 + + var client = new AIProjectClient(new Uri("https://test.openai.azure.com/"), new FakeAuthenticationTokenProvider(), new() { Transport = new HttpClientPipelineTransport(httpClient) }); + + var agent = await client.GetAIAgentAsync( + new ChatClientAgentOptions + { + Name = "test-agent", + Instructions = "Test instructions", + }); + + // Act + var thread = agent.GetNewThread(); + await agent.RunAsync("Hello", thread, options: new ChatClientAgentRunOptions() { ChatOptions = new() { ConversationId = "conv_12345" } }); + + Assert.True(requestTriggered); + var chatClientThread = Assert.IsType(thread); + Assert.Equal("conv_12345", chatClientThread.ConversationId); + } + + /// + /// Verify that even when the chat client has a default conversation id, the chat client will prioritize the per-request conversation id provided in HTTP requests. + /// + [Fact] + public async Task ChatClient_UsesPerRequestConversationId_EvenWhenDefaultConversationIdIsProvidedAsync() + { + // Arrange + var requestTriggered = false; + using var httpHandler = new HttpHandlerAssert(async (request) => + { + if (request.RequestUri!.PathAndQuery.Contains("openai/responses")) + { + requestTriggered = true; + + // Assert + if (request.Content is not null) + { + var requestBody = await request.Content.ReadAsStringAsync().ConfigureAwait(false); + Assert.Contains("conv_12345", requestBody); + } + + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(TestDataUtil.GetOpenAIDefaultResponseJson(), Encoding.UTF8, "application/json") }; + } + + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(TestDataUtil.GetAgentResponseJson(), Encoding.UTF8, "application/json") }; + }); + +#pragma warning disable CA5399 + using var httpClient = new HttpClient(httpHandler); +#pragma warning restore CA5399 + + var client = new AIProjectClient(new Uri("https://test.openai.azure.com/"), new FakeAuthenticationTokenProvider(), new() { Transport = new HttpClientPipelineTransport(httpClient) }); + + var agent = await client.GetAIAgentAsync( + new ChatClientAgentOptions + { + Name = "test-agent", + Instructions = "Test instructions", + ChatOptions = new() { ConversationId = "conv_should_not_use_default" } + }); + + // Act + var thread = agent.GetNewThread(); + await agent.RunAsync("Hello", thread, options: new ChatClientAgentRunOptions() { ChatOptions = new() { ConversationId = "conv_12345" } }); + + Assert.True(requestTriggered); + var chatClientThread = Assert.IsType(thread); + Assert.Equal("conv_12345", chatClientThread.ConversationId); + } + + /// + /// Verify that when the chat client is provided without a "conv_" prefixed conversation ID, the chat client uses the previous conversation ID in HTTP requests. + /// + [Fact] + public async Task ChatClient_UsesPreviousResponseId_WhenConversationIsNotPrefixedAsConvAsync() + { + // Arrange + var requestTriggered = false; + using var httpHandler = new HttpHandlerAssert(async (request) => + { + if (request.RequestUri!.PathAndQuery.Contains("openai/responses")) + { + requestTriggered = true; + + // Assert + if (request.Content is not null) + { + var requestBody = await request.Content.ReadAsStringAsync().ConfigureAwait(false); + Assert.Contains("resp_0888a", requestBody); + } + + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(TestDataUtil.GetOpenAIDefaultResponseJson(), Encoding.UTF8, "application/json") }; + } + + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(TestDataUtil.GetAgentResponseJson(), Encoding.UTF8, "application/json") }; + }); + +#pragma warning disable CA5399 + using var httpClient = new HttpClient(httpHandler); +#pragma warning restore CA5399 + + var client = new AIProjectClient(new Uri("https://test.openai.azure.com/"), new FakeAuthenticationTokenProvider(), new() { Transport = new HttpClientPipelineTransport(httpClient) }); + + var agent = await client.GetAIAgentAsync( + new ChatClientAgentOptions + { + Name = "test-agent", + Instructions = "Test instructions", + }); + + // Act + var thread = agent.GetNewThread(); + await agent.RunAsync("Hello", thread, options: new ChatClientAgentRunOptions() { ChatOptions = new() { ConversationId = "resp_0888a" } }); + + Assert.True(requestTriggered); + var chatClientThread = Assert.IsType(thread); + Assert.Equal("resp_0888a46cbf2b1ff3006914596e05d08195a77c3f5187b769a7", chatClientThread.ConversationId); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/FakeAuthenticationTokenProvider.cs b/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/FakeAuthenticationTokenProvider.cs new file mode 100644 index 0000000000..d37ed881ff --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/FakeAuthenticationTokenProvider.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.ClientModel; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.AzureAI.UnitTests; + +internal sealed class FakeAuthenticationTokenProvider : AuthenticationTokenProvider +{ + public override GetTokenOptions? CreateTokenOptions(IReadOnlyDictionary properties) + { + return new GetTokenOptions(new Dictionary()); + } + + public override AuthenticationToken GetToken(GetTokenOptions options, CancellationToken cancellationToken) + { + return new AuthenticationToken("token-value", "token-type", DateTimeOffset.UtcNow.AddHours(1)); + } + + public override ValueTask GetTokenAsync(GetTokenOptions options, CancellationToken cancellationToken) + { + return new ValueTask(this.GetToken(options, cancellationToken)); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/HttpHandlerAssert.cs b/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/HttpHandlerAssert.cs new file mode 100644 index 0000000000..3b8025ed9e --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/HttpHandlerAssert.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.AzureAI.UnitTests; + +internal sealed class HttpHandlerAssert : HttpClientHandler +{ + private readonly Func? _assertion; + private readonly Func>? _assertionAsync; + + public HttpHandlerAssert(Func assertion) + { + this._assertion = assertion; + } + public HttpHandlerAssert(Func> assertionAsync) + { + this._assertionAsync = assertionAsync; + } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if (this._assertionAsync is not null) + { + return await this._assertionAsync.Invoke(request); + } + + return this._assertion!.Invoke(request); + } + +#if NET + protected override HttpResponseMessage Send(HttpRequestMessage request, CancellationToken cancellationToken) + { + return this._assertion!(request); + } +#endif +} diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/Microsoft.Agents.AI.AzureAI.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/Microsoft.Agents.AI.AzureAI.UnitTests.csproj new file mode 100644 index 0000000000..79bc577661 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/Microsoft.Agents.AI.AzureAI.UnitTests.csproj @@ -0,0 +1,23 @@ + + + + $(ProjectsTargetFrameworks) + + + + + + + + + Always + + + Always + + + Always + + + + diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/TestData/AgentResponse.json b/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/TestData/AgentResponse.json new file mode 100644 index 0000000000..6e93dd65c4 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/TestData/AgentResponse.json @@ -0,0 +1,17 @@ +{ + "object": "agent", + "id": "agent_abc123", + "name": "agent_abc123", + "versions": { + "latest": { + "metadata": {}, + "object": "agent.version", + "id": "agent_abc123:1", + "name": "agent_abc123", + "version": "1", + "description": "", + "created_at": 1761771936, + "definition": "agent-definition-placeholder" + } + } +} \ No newline at end of file diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/TestData/AgentVersionResponse.json b/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/TestData/AgentVersionResponse.json new file mode 100644 index 0000000000..26e5b335ca --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/TestData/AgentVersionResponse.json @@ -0,0 +1,9 @@ +{ + "object": "agent.version", + "id": "agent_abc123:1", + "name": "agent_abc123", + "version": "1", + "description": "", + "created_at": 1761771936, + "definition": "agent-definition-placeholder" +} diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/TestData/OpenAIDefaultResponse.json b/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/TestData/OpenAIDefaultResponse.json new file mode 100644 index 0000000000..a270ebf4d4 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/TestData/OpenAIDefaultResponse.json @@ -0,0 +1,68 @@ +{ + "id": "resp_0888a46cbf2b1ff3006914596e05d08195a77c3f5187b769a7", + "object": "response", + "created_at": 1762941294, + "status": "completed", + "background": false, + "billing": { + "payer": "developer" + }, + "error": null, + "incomplete_details": null, + "instructions": null, + "max_output_tokens": null, + "max_tool_calls": null, + "model": "gpt-4o-mini-2024-07-18", + "output": [ + { + "id": "msg_0888a46cbf2b1ff3006914596f814481958e8cf500a6dabbec", + "type": "message", + "status": "completed", + "content": [ + { + "type": "output_text", + "annotations": [], + "logprobs": [], + "text": "Hello! How can I assist you today?" + } + ], + "role": "assistant" + } + ], + "parallel_tool_calls": true, + "previous_response_id": null, + "prompt_cache_key": null, + "prompt_cache_retention": null, + "reasoning": { + "effort": null, + "summary": null + }, + "safety_identifier": null, + "service_tier": "default", + "store": true, + "temperature": 1.0, + "text": { + "format": { + "type": "text" + }, + "verbosity": "medium" + }, + "tool_choice": "auto", + "tools": [], + "top_logprobs": 0, + "top_p": 1.0, + "truncation": "disabled", + "usage": { + "input_tokens": 9, + "input_tokens_details": { + "cached_tokens": 0 + }, + "output_tokens": 10, + "output_tokens_details": { + "reasoning_tokens": 0 + }, + "total_tokens": 19 + }, + "user": null, + "metadata": {} +} \ No newline at end of file diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/TestDataUtil.cs b/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/TestDataUtil.cs new file mode 100644 index 0000000000..c65d10de43 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/TestDataUtil.cs @@ -0,0 +1,101 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.ClientModel.Primitives; +using System.IO; +using Azure.AI.Projects.OpenAI; + +namespace Microsoft.Agents.AI.AzureAI.UnitTests; + +/// +/// Utility class for loading and processing test data files. +/// +internal static class TestDataUtil +{ + private static readonly string s_agentResponseJson = File.ReadAllText("TestData/AgentResponse.json"); + private static readonly string s_agentVersionResponseJson = File.ReadAllText("TestData/AgentVersionResponse.json"); + private static readonly string s_openAIDefaultResponseJson = File.ReadAllText("TestData/OpenAIDefaultResponse.json"); + + private const string AgentDefinitionPlaceholder = "\"agent-definition-placeholder\""; + + private const string DefaultAgentDefinition = """ + { + "kind": "prompt", + "model": "gpt-5-mini", + "instructions": "You are a storytelling agent. You craft engaging one-line stories based on user prompts and context.", + "tools": [] + } + """; + + /// + /// Gets the agent response JSON with optional placeholder replacements applied. + /// + public static string GetAgentResponseJson(string? agentName = null, AgentDefinition? agentDefinition = null, string? instructions = null, string? description = null) + { + var json = s_agentResponseJson; + json = ApplyAgentName(json, agentName); + json = ApplyAgentDefinition(json, agentDefinition); + json = ApplyInstructions(json, instructions); + json = ApplyDescription(json, description); + return json; + } + + /// + /// Gets the agent version response JSON with optional placeholder replacements applied. + /// + public static string GetAgentVersionResponseJson(string? agentName = null, AgentDefinition? agentDefinition = null, string? instructions = null, string? description = null) + { + var json = s_agentVersionResponseJson; + json = ApplyAgentName(json, agentName); + json = ApplyAgentDefinition(json, agentDefinition); + json = ApplyInstructions(json, instructions); + json = ApplyDescription(json, description); + return json; + } + + /// + /// Gets the OpenAI default response JSON with optional placeholder replacements applied. + /// + public static string GetOpenAIDefaultResponseJson(string? agentName = null, AgentDefinition? agentDefinition = null, string? instructions = null, string? description = null) + { + var json = s_openAIDefaultResponseJson; + json = ApplyAgentName(json, agentName); + json = ApplyAgentDefinition(json, agentDefinition); + json = ApplyInstructions(json, instructions); + json = ApplyDescription(json, description); + return json; + } + + private static string ApplyAgentName(string json, string? agentName) + { + if (!string.IsNullOrEmpty(agentName)) + { + return json.Replace("\"agent_abc123\"", $"\"{agentName}\""); + } + return json; + } + + private static string ApplyAgentDefinition(string json, AgentDefinition? definition) + { + return (definition is not null) + ? json.Replace(AgentDefinitionPlaceholder, ModelReaderWriter.Write(definition).ToString()) + : json.Replace(AgentDefinitionPlaceholder, DefaultAgentDefinition); + } + + private static string ApplyInstructions(string json, string? instructions) + { + if (!string.IsNullOrEmpty(instructions)) + { + return json.Replace("You are a storytelling agent. You craft engaging one-line stories based on user prompts and context.", instructions); + } + return json; + } + + private static string ApplyDescription(string json, string? description) + { + if (!string.IsNullOrEmpty(description)) + { + return json.Replace("\"description\": \"\"", $"\"description\": \"{description}\""); + } + return json; + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/ExternalClientTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/ExternalClientTests.cs index 241e05a843..ad57ea9a52 100644 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/ExternalClientTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/ExternalClientTests.cs @@ -212,4 +212,26 @@ public sealed class ExternalClientTests(ITestOutputHelper outputHelper) : IDispo Assert.NotEmpty(response.Text); Assert.Contains("John Doe", response.Text); } + + [Fact] + public void AsDurableAgentProxy_ThrowsWhenAgentNotRegistered() + { + // Setup: Register one agent but try to use a different one + AIAgent registeredAgent = TestHelper.GetAzureOpenAIChatClient(s_configuration).CreateAIAgent( + instructions: "You are a helpful assistant.", + name: "RegisteredAgent"); + + using TestHelper testHelper = TestHelper.Start([registeredAgent], this._outputHelper); + + // Create an agent with a different name that isn't registered + AIAgent unregisteredAgent = TestHelper.GetAzureOpenAIChatClient(s_configuration).CreateAIAgent( + instructions: "You are a helpful assistant.", + name: "UnregisteredAgent"); + + // Act & Assert: Should throw AgentNotRegisteredException + AgentNotRegisteredException exception = Assert.Throws( + () => unregisteredAgent.AsDurableAgentProxy(testHelper.Services)); + + Assert.Equal("UnregisteredAgent", exception.AgentName); + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/OrchestrationTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/OrchestrationTests.cs new file mode 100644 index 0000000000..6b905f2623 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/OrchestrationTests.cs @@ -0,0 +1,95 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics; +using System.Reflection; +using Microsoft.DurableTask; +using Microsoft.DurableTask.Client; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Configuration; +using OpenAI; +using Xunit.Abstractions; + +namespace Microsoft.Agents.AI.DurableTask.IntegrationTests; + +/// +/// Tests for orchestration execution scenarios with Durable Task Agents. +/// +[Collection("Sequential")] +[Trait("Category", "Integration")] +public sealed class OrchestrationTests(ITestOutputHelper outputHelper) : IDisposable +{ + private static readonly TimeSpan s_defaultTimeout = Debugger.IsAttached + ? TimeSpan.FromMinutes(5) + : TimeSpan.FromSeconds(30); + + private static readonly IConfiguration s_configuration = + new ConfigurationBuilder() + .AddUserSecrets(Assembly.GetExecutingAssembly()) + .AddEnvironmentVariables() + .Build(); + + private readonly ITestOutputHelper _outputHelper = outputHelper; + private readonly CancellationTokenSource _cts = new(delay: s_defaultTimeout); + + private CancellationToken TestTimeoutToken => this._cts.Token; + + public void Dispose() => this._cts.Dispose(); + + [Fact] + public async Task GetAgent_ThrowsWhenAgentNotRegisteredAsync() + { + // Define an orchestration that tries to use an unregistered agent + static async Task TestOrchestrationAsync(TaskOrchestrationContext context) + { + // Get an agent that hasn't been registered + DurableAIAgent agent = context.GetAgent("NonExistentAgent"); + + // This should throw when RunAsync is called because the agent doesn't exist + await agent.RunAsync("Hello"); + return "Should not reach here"; + } + + // Setup: Create test helper without registering "NonExistentAgent" + using TestHelper testHelper = TestHelper.Start( + this._outputHelper, + configureAgents: agents => + { + // Register a different agent, but not "NonExistentAgent" + agents.AddAIAgentFactory( + "OtherAgent", + sp => TestHelper.GetAzureOpenAIChatClient(s_configuration).CreateAIAgent( + name: "OtherAgent", + instructions: "You are a test agent.")); + }, + durableTaskRegistry: registry => + registry.AddOrchestratorFunc( + name: nameof(TestOrchestrationAsync), + orchestrator: TestOrchestrationAsync)); + + DurableTaskClient client = testHelper.GetClient(); + + // Act: Start the orchestration + string instanceId = await client.ScheduleNewOrchestrationInstanceAsync( + orchestratorName: nameof(TestOrchestrationAsync), + cancellation: this.TestTimeoutToken); + + // Wait for the orchestration to complete and check for failure + OrchestrationMetadata status = await client.WaitForInstanceCompletionAsync( + instanceId, + getInputsAndOutputs: true, + this.TestTimeoutToken); + + // Assert: Verify the orchestration failed with the expected exception + Assert.NotNull(status); + Assert.Equal(OrchestrationRuntimeStatus.Failed, status.RuntimeStatus); + Assert.NotNull(status.FailureDetails); + + // Verify the exception type is AgentNotRegisteredException + Assert.True( + status.FailureDetails.ErrorType == typeof(AgentNotRegisteredException).FullName, + $"Expected AgentNotRegisteredException but got ErrorType: {status.FailureDetails.ErrorType}, Message: {status.FailureDetails.ErrorMessage}"); + + // Verify the exception message contains the agent name + Assert.Contains("NonExistentAgent", status.FailureDetails.ErrorMessage, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/AgentProvider.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/AgentProvider.cs new file mode 100644 index 0000000000..daec465020 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/AgentProvider.cs @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Azure.AI.Projects.OpenAI; +using Microsoft.Extensions.Configuration; + +namespace Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Agents; + +internal abstract class AgentProvider(IConfiguration configuration) +{ + public static class Names + { + public const string FunctionTool = "FUNCTIONTOOL"; + public const string Marketing = "MARKETING"; + public const string MathChat = "MATHCHAT"; + public const string InputArguments = "INPUTARGUMENTS"; + } + + public static class Settings + { + public const string FoundryEndpoint = "FOUNDRY_PROJECT_ENDPOINT"; + public const string FoundryModelMini = "FOUNDRY_MODEL_DEPLOYMENT_NAME"; + public const string FoundryModelFull = "FOUNDRY_MEDIA_DEPLOYMENT_NAME"; + public const string FoundryGroundingTool = "FOUNDRY_CONNECTION_GROUNDING_TOOL"; + } + + public static AgentProvider Create(IConfiguration configuration, string providerType) => + providerType.ToUpperInvariant() switch + { + Names.FunctionTool => new FunctionToolAgentProvider(configuration), + Names.Marketing => new MarketingAgentProvider(configuration), + Names.MathChat => new MathChatAgentProvider(configuration), + Names.InputArguments => new PoemAgentProvider(configuration), + _ => new TestAgentProvider(configuration), + }; + + public async ValueTask CreateAgentsAsync() + { + Uri foundryEndpoint = new(this.GetSetting(Settings.FoundryEndpoint)); + + await foreach (AgentVersion agent in this.CreateAgentsAsync(foundryEndpoint)) + { + Console.WriteLine($"Created agent: {agent.Name}:{agent.Version}"); + } + } + + protected abstract IAsyncEnumerable CreateAgentsAsync(Uri foundryEndpoint); + + protected string GetSetting(string settingName) => + configuration[settingName] ?? + throw new InvalidOperationException($"Undefined configuration setting: {settingName}"); +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/FunctionToolAgentProvider.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/FunctionToolAgentProvider.cs new file mode 100644 index 0000000000..4ac24c440a --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/FunctionToolAgentProvider.cs @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using Azure.AI.Projects; +using Azure.AI.Projects.OpenAI; +using Azure.Identity; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Configuration; +using OpenAI.Responses; +using Shared.Foundry; + +namespace Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Agents; + +internal sealed class FunctionToolAgentProvider(IConfiguration configuration) : AgentProvider(configuration) +{ + protected override async IAsyncEnumerable CreateAgentsAsync(Uri foundryEndpoint) + { + MenuPlugin menuPlugin = new(); + AIFunction[] functions = + [ + AIFunctionFactory.Create(menuPlugin.GetMenu), + AIFunctionFactory.Create(menuPlugin.GetSpecials), + AIFunctionFactory.Create(menuPlugin.GetItemPrice), + ]; + + AIProjectClient aiProjectClient = new(foundryEndpoint, new AzureCliCredential()); + + yield return + await aiProjectClient.CreateAgentAsync( + agentName: "MenuAgent", + agentDefinition: this.DefineMenuAgent(functions), + agentDescription: "Provides information about the restaurant menu"); + } + + private PromptAgentDefinition DefineMenuAgent(AIFunction[] functions) + { + PromptAgentDefinition agentDefinition = + new(this.GetSetting(Settings.FoundryModelMini)) + { + Instructions = + """ + Answer the users questions on the menu. + For questions or input that do not require searching the documentation, inform the + user that you can only answer questions what's on the menu. + """ + }; + + foreach (AIFunction function in functions) + { + agentDefinition.Tools.Add(function.AsOpenAIResponseTool()); + } + + return agentDefinition; + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/MarketingAgentProvider.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/MarketingAgentProvider.cs new file mode 100644 index 0000000000..a983794759 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/MarketingAgentProvider.cs @@ -0,0 +1,76 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using Azure.AI.Projects; +using Azure.AI.Projects.OpenAI; +using Azure.Identity; +using Microsoft.Extensions.Configuration; +using Shared.Foundry; + +namespace Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Agents; + +internal sealed class MarketingAgentProvider(IConfiguration configuration) : AgentProvider(configuration) +{ + protected override async IAsyncEnumerable CreateAgentsAsync(Uri foundryEndpoint) + { + AIProjectClient aiProjectClient = new(foundryEndpoint, new AzureCliCredential()); + + yield return + await aiProjectClient.CreateAgentAsync( + agentName: "AnalystAgent", + agentDefinition: this.DefineAnalystAgent(), + agentDescription: "Analyst agent for Marketing workflow"); + + yield return + await aiProjectClient.CreateAgentAsync( + agentName: "WriterAgent", + agentDefinition: this.DefineWriterAgent(), + agentDescription: "Writer agent for Marketing workflow"); + + yield return + await aiProjectClient.CreateAgentAsync( + agentName: "EditorAgent", + agentDefinition: this.DefineEditorAgent(), + agentDescription: "Editor agent for Marketing workflow"); + } + + private PromptAgentDefinition DefineAnalystAgent() => + new(this.GetSetting(Settings.FoundryModelFull)) + { + Instructions = + """ + You are a marketing analyst. Given a product description, identify: + - Key features + - Target audience + - Unique selling points + """, + Tools = + { + //AgentTool.CreateBingGroundingTool( // TODO: Use Bing Grounding when available + // new BingGroundingSearchToolParameters( + // [new BingGroundingSearchConfiguration(this.GetSetting(Settings.FoundryGroundingTool))])) + } + }; + + private PromptAgentDefinition DefineWriterAgent() => + new(this.GetSetting(Settings.FoundryModelFull)) + { + Instructions = + """ + You are a marketing copywriter. Given a block of text describing features, audience, and USPs, + compose a compelling marketing copy (like a newsletter section) that highlights these points. + Output should be short (around 150 words), output just the copy as a single text block. + """ + }; + + private PromptAgentDefinition DefineEditorAgent() => + new(this.GetSetting(Settings.FoundryModelFull)) + { + Instructions = + """ + You are an editor. Given the draft copy, correct grammar, improve clarity, ensure consistent tone, + give format and make it polished. Output the final improved copy as a single text block. + """ + }; +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/MathChatAgentProvider.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/MathChatAgentProvider.cs new file mode 100644 index 0000000000..27cdca3515 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/MathChatAgentProvider.cs @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using Azure.AI.Projects; +using Azure.AI.Projects.OpenAI; +using Azure.Identity; +using Microsoft.Extensions.Configuration; +using Shared.Foundry; + +namespace Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Agents; + +internal sealed class MathChatAgentProvider(IConfiguration configuration) : AgentProvider(configuration) +{ + protected override async IAsyncEnumerable CreateAgentsAsync(Uri foundryEndpoint) + { + AIProjectClient aiProjectClient = new(foundryEndpoint, new AzureCliCredential()); + + yield return + await aiProjectClient.CreateAgentAsync( + agentName: "StudentAgent", + agentDefinition: this.DefineStudentAgent(), + agentDescription: "Student agent for MathChat workflow"); + + yield return + await aiProjectClient.CreateAgentAsync( + agentName: "TeacherAgent", + agentDefinition: this.DefineTeacherAgent(), + agentDescription: "Teacher agent for MathChat workflow"); + } + + private PromptAgentDefinition DefineStudentAgent() => + new(this.GetSetting(Settings.FoundryModelMini)) + { + Instructions = + """ + Your job is help a math teacher practice teaching by making intentional mistakes. + You attempt to solve the given math problem, but with intentional mistakes so the teacher can help. + Always incorporate the teacher's advice to fix your next response. + You have the math-skills of a 6th grader. + """ + }; + + private PromptAgentDefinition DefineTeacherAgent() => + new(this.GetSetting(Settings.FoundryModelMini)) + { + Instructions = + """ + Review and coach the student's approach to solving the given math problem. + Don't repeat the solution or try and solve it. + If the student has demonstrated comprehension and responded to all of your feedback, + give the student your congratulations by using the word "congratulations". + """ + }; +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/MenuPlugin.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/MenuPlugin.cs index d69e856af8..38592e2e12 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/MenuPlugin.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/MenuPlugin.cs @@ -5,32 +5,33 @@ using System.Collections.Generic; using System.ComponentModel; using System.Linq; using Microsoft.Extensions.AI; -using Microsoft.SemanticKernel; namespace Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Agents; +#pragma warning disable CA1822 + public sealed class MenuPlugin { public IEnumerable GetTools() { - yield return AIFunctionFactory.Create(this.GetMenu, name: $"{nameof(MenuPlugin)}_{nameof(GetMenu)}"); - yield return AIFunctionFactory.Create(this.GetSpecials, name: $"{nameof(MenuPlugin)}_{nameof(GetSpecials)}"); - yield return AIFunctionFactory.Create(this.GetItemPrice, name: $"{nameof(MenuPlugin)}_{nameof(GetItemPrice)}"); + yield return AIFunctionFactory.Create(this.GetMenu); + yield return AIFunctionFactory.Create(this.GetSpecials); + yield return AIFunctionFactory.Create(this.GetItemPrice); } - [KernelFunction, Description("Provides a list items on the menu.")] + [Description("Provides a list items on the menu.")] public MenuItem[] GetMenu() { return s_menuItems; } - [KernelFunction, Description("Provides a list of specials from the menu.")] + [Description("Provides a list of specials from the menu.")] public MenuItem[] GetSpecials() { return [.. s_menuItems.Where(i => i.IsSpecial)]; } - [KernelFunction, Description("Provides the price of the requested menu item.")] + [Description("Provides the price of the requested menu item.")] public float? GetItemPrice( [Description("The name of the menu item.")] string name) diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/PoemAgentProvider.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/PoemAgentProvider.cs new file mode 100644 index 0000000000..9706c6227c --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/PoemAgentProvider.cs @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using Azure.AI.Projects; +using Azure.AI.Projects.OpenAI; +using Azure.Identity; +using Microsoft.Extensions.Configuration; +using Shared.Foundry; + +namespace Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Agents; + +internal sealed class PoemAgentProvider(IConfiguration configuration) : AgentProvider(configuration) +{ + protected override async IAsyncEnumerable CreateAgentsAsync(Uri foundryEndpoint) + { + AIProjectClient aiProjectClient = new(foundryEndpoint, new AzureCliCredential()); + + yield return + await aiProjectClient.CreateAgentAsync( + agentName: "PoemAgent", + agentDefinition: this.DefinePoemAgent(), + agentDescription: "Authors original poems"); + } + + private PromptAgentDefinition DefinePoemAgent() => + new(this.GetSetting(Settings.FoundryModelMini)) + { + Instructions = + """ + Write a one verse poem on the requested topic in the style of: {{style}}. + """, + StructuredInputs = + { + ["style"] = + new StructuredInputDefinition + { + IsRequired = false, + DefaultValue = BinaryData.FromString(@"""haiku"""), + Description = "The style of poem to write", + } + } + }; +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/TestAgent.yaml b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/TestAgent.yaml deleted file mode 100644 index eddcc7b0aa..0000000000 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/TestAgent.yaml +++ /dev/null @@ -1,5 +0,0 @@ -type: foundry_agent -name: BasicAgent -description: Basic agent for integration tests -model: - id: ${FOUNDRY_MEDIA_DEPLOYMENT_NAME} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/TestAgentProvider.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/TestAgentProvider.cs new file mode 100644 index 0000000000..078b6321c0 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/TestAgentProvider.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using Azure.AI.Projects; +using Azure.AI.Projects.OpenAI; +using Azure.Identity; +using Microsoft.Extensions.Configuration; +using Shared.Foundry; + +namespace Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Agents; + +internal sealed class TestAgentProvider(IConfiguration configuration) : AgentProvider(configuration) +{ + protected override async IAsyncEnumerable CreateAgentsAsync(Uri foundryEndpoint) + { + AIProjectClient aiProjectClient = new(foundryEndpoint, new AzureCliCredential()); + + yield return + await aiProjectClient.CreateAgentAsync( + agentName: "TestAgent", + agentDefinition: this.DefineMenuAgent(), + agentDescription: "Provides information about the restaurant menu"); + } + + private PromptAgentDefinition DefineMenuAgent() => + new(this.GetSetting(Settings.FoundryModelFull)); +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/ToolAgent.yaml b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/ToolAgent.yaml deleted file mode 100644 index 17cc84b010..0000000000 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/ToolAgent.yaml +++ /dev/null @@ -1,15 +0,0 @@ -type: foundry_agent -name: ToolAgent -description: Agent with a function tool defined. -model: - id: ${FOUNDRY_MODEL_DEPLOYMENT_NAME} -tools: - - id: MenuPlugin_GetMenu - type: function - description: Provides a list items on the menu. - - id: MenuPlugin_GetSpecials - type: function - description: Provides a list of specials from the menu. - - id: MenuPlugin_GetItemPrice - type: function - description: Provides the price of the requested menu item. diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/AzureAgentProviderTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/AzureAgentProviderTest.cs index 87b9160196..da3f6f2fd5 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/AzureAgentProviderTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/AzureAgentProviderTest.cs @@ -1,10 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. -using System; using System.Linq; using System.Threading.Tasks; -using Azure; -using Azure.AI.Agents.Persistent; using Azure.Identity; using Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Framework; using Microsoft.Extensions.AI; @@ -18,7 +15,7 @@ public sealed class AzureAgentProviderTest(ITestOutputHelper output) : Integrati public async Task ConversationTestAsync() { // Arrange - AzureAgentProvider provider = new(this.FoundryConfiguration.Endpoint, new AzureCliCredential()); + AzureAgentProvider provider = new(this.TestEndpoint, new AzureCliCredential()); // Act string conversationId = await provider.CreateConversationAsync(); // Assert @@ -43,35 +40,4 @@ public sealed class AzureAgentProviderTest(ITestOutputHelper output) : Integrati Assert.NotNull(message); Assert.Equal(messages[3].Text, message.Text); } - - [Fact] - public async Task GetAgentTestAsync() - { - // Arrange - AzureAgentProvider provider = new(this.FoundryConfiguration.Endpoint, new AzureCliCredential()); - string agentName = $"TestAgent-{DateTime.UtcNow:yyMMdd-HHmmss-fff}"; - - string agent1Id = await this.CreateAgentAsync(); - string agent2Id = await this.CreateAgentAsync(agentName); - - // Act - AIAgent agent1 = await provider.GetAgentAsync(agent1Id); - // Assert - Assert.Equal(agent1Id, agent1.Id); - - // Act - AIAgent agent2 = await provider.GetAgentAsync(agent2Id); - // Assert - Assert.Equal(agent2Id, agent2.Id); - - // Act & Assert - await Assert.ThrowsAsync(() => provider.GetAgentAsync(agentName)); - } - - private async ValueTask CreateAgentAsync(string? name = null) - { - PersistentAgentsClient client = new(this.FoundryConfiguration.Endpoint, new AzureCliCredential()); - PersistentAgent agent = await client.Administration.CreateAgentAsync(this.FoundryConfiguration.DeploymentName, name: name); - return agent.Id; - } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/DeclarativeWorkflowTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/DeclarativeWorkflowTest.cs index a57149c015..8757ff1f3f 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/DeclarativeWorkflowTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/DeclarativeWorkflowTest.cs @@ -4,6 +4,7 @@ using System; using System.IO; using System.Linq; using System.Threading.Tasks; +using Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Agents; using Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Framework; using Xunit.Abstractions; @@ -16,13 +17,14 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow { [Theory] [InlineData("CheckSystem.yaml", "CheckSystem.json")] - [InlineData("SendActivity.yaml", "SendActivity.json")] - [InlineData("InvokeAgent.yaml", "InvokeAgent.json")] - [InlineData("InvokeAgent.yaml", "InvokeAgent.json", true)] [InlineData("ConversationMessages.yaml", "ConversationMessages.json")] [InlineData("ConversationMessages.yaml", "ConversationMessages.json", true)] + [InlineData("InputArguments.yaml", "InputArguments.json")] + [InlineData("InvokeAgent.yaml", "InvokeAgent.json")] + [InlineData("InvokeAgent.yaml", "InvokeAgent.json", true)] + [InlineData("SendActivity.yaml", "SendActivity.json")] public Task ValidateCaseAsync(string workflowFileName, string testcaseFileName, bool externalConveration = false) => - this.RunWorkflowAsync(Path.Combine(Environment.CurrentDirectory, "Workflows", workflowFileName), testcaseFileName, externalConveration); + this.RunWorkflowAsync(GetWorkflowPath(workflowFileName, isSample: false), testcaseFileName, externalConveration); [Theory] [InlineData("Marketing.yaml", "Marketing.json")] @@ -30,14 +32,24 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow [InlineData("MathChat.yaml", "MathChat.json", true)] [InlineData("DeepResearch.yaml", "DeepResearch.json", Skip = "Long running")] public Task ValidateScenarioAsync(string workflowFileName, string testcaseFileName, bool externalConveration = false) => - this.RunWorkflowAsync(Path.Combine(GetRepoFolder(), "workflow-samples", workflowFileName), testcaseFileName, externalConveration); + this.RunWorkflowAsync(GetWorkflowPath(workflowFileName, isSample: true), testcaseFileName, externalConveration); - [Fact] - public Task ValidateMultiTurnAsync() => - this.RunWorkflowAsync(Path.Combine(GetRepoFolder(), "workflow-samples", "HumanInLoop.yaml"), "HumanInLoop.json", useJsonCheckpoint: true); + [Theory] + [InlineData("ConfirmInput.yaml", "ConfirmInput.json", false)] + [InlineData("RequestExternalInput.yaml", "RequestExternalInput.json", false)] + public Task ValidateMultiTurnAsync(string workflowFileName, string testcaseFileName, bool isSample) => + this.RunWorkflowAsync(GetWorkflowPath(workflowFileName, isSample), testcaseFileName, useJsonCheckpoint: true); + + private static string GetWorkflowPath(string workflowFileName, bool isSample) => + isSample + ? Path.Combine(GetRepoFolder(), "workflow-samples", workflowFileName) + : Path.Combine(Environment.CurrentDirectory, "Workflows", workflowFileName); protected override async Task RunAndVerifyAsync(Testcase testcase, string workflowPath, DeclarativeWorkflowOptions workflowOptions, TInput input, bool useJsonCheckpoint) { + AgentProvider agentProvider = AgentProvider.Create(this.Configuration, Path.GetFileNameWithoutExtension(workflowPath)); + await agentProvider.CreateAgentsAsync().ConfigureAwait(false); + Workflow workflow = DeclarativeWorkflowBuilder.Build(workflowPath, workflowOptions); WorkflowHarness harness = new(workflow, runId: Path.GetFileNameWithoutExtension(workflowPath)); diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/AgentFactory.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/AgentFactory.cs deleted file mode 100644 index 4ceee44c6b..0000000000 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/AgentFactory.cs +++ /dev/null @@ -1,90 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Frozen; -using System.Collections.Generic; -using System.Diagnostics; -using System.IO; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Azure.AI.Agents.Persistent; -using Azure.AI.Projects; -using Azure.Identity; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.SemanticKernel; -using Microsoft.SemanticKernel.Agents; -using Microsoft.SemanticKernel.Agents.AzureAI; -using Shared.IntegrationTests; - -namespace Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Framework; - -internal static class AgentFactory -{ - private static readonly Dictionary s_agentDefinitions = - new() - { - ["FOUNDRY_AGENT_TEST"] = "TestAgent.yaml", - ["FOUNDRY_AGENT_TOOL"] = "ToolAgent.yaml", - ["FOUNDRY_AGENT_ANSWER"] = "QuestionAgent.yaml", - ["FOUNDRY_AGENT_STUDENT"] = "StudentAgent.yaml", - ["FOUNDRY_AGENT_TEACHER"] = "TeacherAgent.yaml", - ["FOUNDRY_AGENT_RESEARCHANALYST"] = "AnalystAgent.yaml", - ["FOUNDRY_AGENT_RESEARCHCODER"] = "CoderAgent.yaml", - ["FOUNDRY_AGENT_RESEARCHMANAGER"] = "ManagerAgent.yaml", - ["FOUNDRY_AGENT_RESEARCHWEATHER"] = "WeatherAgent.yaml", - ["FOUNDRY_AGENT_RESEARCHWEB"] = "WebAgent.yaml", - }; - - private static FrozenDictionary? s_agentMap; - - public static async Task> GetAgentsAsync(AzureAIConfiguration config, IConfiguration configuration, CancellationToken cancellationToken = default) - { - if (s_agentMap is not null) - { - return s_agentMap; - } - - PersistentAgentsClient clientAgents = new(config.Endpoint, new AzureCliCredential()); - AIProjectClient clientProjects = new(new Uri(config.Endpoint), new AzureCliCredential()); - IKernelBuilder kernelBuilder = Kernel.CreateBuilder(); - kernelBuilder.Services.AddSingleton(clientAgents); - kernelBuilder.Services.AddSingleton(clientProjects); - kernelBuilder.Plugins.AddFromType(); - AgentCreationOptions creationOptions = new() { Kernel = kernelBuilder.Build() }; - AzureAIAgentFactory factory = new(); - string repoRoot = WorkflowTest.GetRepoFolder(); - - return s_agentMap = (await Task.WhenAll(s_agentDefinitions.Select(kvp => CreateAgentAsync(kvp.Key, kvp.Value, cancellationToken)))).ToFrozenDictionary(t => t.Name, t => t.Id); - - async Task<(string Name, string? Id)> CreateAgentAsync(string id, string file, CancellationToken cancellationToken) - { - try - { - string filePath = Path.Combine(Environment.CurrentDirectory, "Agents", file); - if (!File.Exists(filePath)) - { - filePath = Path.Combine(repoRoot, "workflow-samples", "setup", file); - } - Assert.True(File.Exists(filePath), $"Agent definition file not found: {file}"); - - Debug.WriteLine($"TEST AGENT: Creating - {file}"); - string agentText = File.ReadAllText(filePath); - - Agent? agent = await factory.CreateAgentFromYamlAsync(agentText, creationOptions, configuration, cancellationToken); - - Assert.NotNull(agent?.Name); - - Debug.WriteLine($"TEST AGENT: {agent.Name} => {agent.Id} [{id}]"); - - return (id, agent.Id); - } - catch (Exception exception) - { - Console.WriteLine($"FAILURE: Error creating agent {id}: {exception.Message}"); - throw; - } - } - } -} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/IntegrationTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/IntegrationTest.cs index de83afd723..b525749b6c 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/IntegrationTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/IntegrationTest.cs @@ -1,16 +1,15 @@ // Copyright (c) Microsoft. All rights reserved. using System; -using System.Collections.Frozen; using System.Collections.Generic; using System.Reflection; using System.Threading.Tasks; using Azure.Identity; +using Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Agents; using Microsoft.Agents.AI.Workflows.Declarative.PowerFx; using Microsoft.Bot.ObjectModel; using Microsoft.Extensions.AI; using Microsoft.Extensions.Configuration; -using Shared.IntegrationTests; using Xunit.Abstractions; namespace Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Framework; @@ -21,25 +20,20 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Framework; public abstract class IntegrationTest : IDisposable { private IConfigurationRoot? _configuration; - private AzureAIConfiguration? _foundryConfiguration; protected IConfigurationRoot Configuration => this._configuration ??= InitializeConfig(); - internal AzureAIConfiguration FoundryConfiguration - { - get - { - this._foundryConfiguration ??= this.Configuration.GetSection("AzureAI").Get(); - Assert.NotNull(this._foundryConfiguration); - return this._foundryConfiguration; - } - } + public Uri TestEndpoint { get; } public TestOutputAdapter Output { get; } protected IntegrationTest(ITestOutputHelper output) { this.Output = new TestOutputAdapter(output); + this.TestEndpoint = + new Uri( + this.Configuration[AgentProvider.Settings.FoundryEndpoint] ?? + throw new InvalidOperationException($"Undefined configuration setting: {AgentProvider.Settings.FoundryEndpoint}")); Console.SetOut(this.Output); SetProduct(); } @@ -70,15 +64,8 @@ public abstract class IntegrationTest : IDisposable protected async ValueTask CreateOptionsAsync(bool externalConversation = false, params IEnumerable functionTools) { - FrozenDictionary agentMap = await AgentFactory.GetAgentsAsync(this.FoundryConfiguration, this.Configuration); - - IConfiguration workflowConfig = - new ConfigurationBuilder() - .AddInMemoryCollection(agentMap) - .Build(); - AzureAgentProvider agentProvider = - new(this.FoundryConfiguration.Endpoint, new AzureCliCredential()) + new(this.TestEndpoint, new AzureCliCredential()) { Functions = functionTools, }; @@ -92,7 +79,6 @@ public abstract class IntegrationTest : IDisposable return new DeclarativeWorkflowOptions(agentProvider) { - Configuration = workflowConfig, ConversationId = conversationId, LoggerFactory = this.Output }; @@ -100,7 +86,6 @@ public abstract class IntegrationTest : IDisposable private static IConfigurationRoot InitializeConfig() => new ConfigurationBuilder() - .AddJsonFile("appsettings.Development.json", true) .AddEnvironmentVariables() .AddUserSecrets(Assembly.GetExecutingAssembly()) .Build(); diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/WorkflowHarness.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/WorkflowHarness.cs index 391a4b63d6..ed3e0367f7 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/WorkflowHarness.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/WorkflowHarness.cs @@ -17,23 +17,26 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Framework; internal sealed class WorkflowHarness(Workflow workflow, string runId) { private CheckpointManager? _checkpointManager; - private CheckpointInfo? LastCheckpoint { get; set; } + private CheckpointInfo? _lastCheckpoint; public async Task RunTestcaseAsync(Testcase testcase, TInput input, bool useJson = false) where TInput : notnull { WorkflowEvents workflowEvents = await this.RunWorkflowAsync(input, useJson); - int requestCount = (workflowEvents.InputEvents.Count + 1) / 2; + int requestCount = workflowEvents.InputEvents.Count; int responseCount = 0; while (requestCount > responseCount) { + ExternalRequest request = workflowEvents.InputEvents[workflowEvents.InputEvents.Count - 1].Request; Assert.NotNull(testcase.Setup.Responses); Assert.NotEmpty(testcase.Setup.Responses); string inputText = testcase.Setup.Responses[responseCount].Value; + Console.WriteLine($"ID: {request.RequestId}"); Console.WriteLine($"INPUT: {inputText}"); ++responseCount; - WorkflowEvents runEvents = await this.ResumeAsync(new AnswerResponse(inputText)).ConfigureAwait(false); + ExternalResponse response = request.CreateResponse(new ExternalInputResponse(new ChatMessage(ChatRole.User, inputText))); + WorkflowEvents runEvents = await this.ResumeAsync(response).ConfigureAwait(false); workflowEvents = new WorkflowEvents([.. workflowEvents.Events, .. runEvents.Events]); - requestCount = (workflowEvents.InputEvents.Count + 1) / 2; + requestCount = workflowEvents.InputEvents.Count; } return workflowEvents; @@ -44,15 +47,15 @@ internal sealed class WorkflowHarness(Workflow workflow, string runId) Console.WriteLine("RUNNING WORKFLOW..."); Checkpointed run = await InProcessExecution.StreamAsync(workflow, input, this.GetCheckpointManager(useJson), runId); IReadOnlyList workflowEvents = await MonitorAndDisposeWorkflowRunAsync(run).ToArrayAsync(); - this.LastCheckpoint = workflowEvents.OfType().LastOrDefault()?.CompletionInfo?.Checkpoint; + this._lastCheckpoint = workflowEvents.OfType().LastOrDefault()?.CompletionInfo?.Checkpoint; return new WorkflowEvents(workflowEvents); } - public async Task ResumeAsync(object response) + public async Task ResumeAsync(ExternalResponse response) { Console.WriteLine("\nRESUMING WORKFLOW..."); - Assert.NotNull(this.LastCheckpoint); - Checkpointed run = await InProcessExecution.ResumeStreamAsync(workflow, this.LastCheckpoint, this.GetCheckpointManager(), runId); + Assert.NotNull(this._lastCheckpoint); + Checkpointed run = await InProcessExecution.ResumeStreamAsync(workflow, this._lastCheckpoint, this.GetCheckpointManager(), runId); IReadOnlyList workflowEvents = await MonitorAndDisposeWorkflowRunAsync(run, response).ToArrayAsync(); return new WorkflowEvents(workflowEvents); } @@ -93,29 +96,32 @@ internal sealed class WorkflowHarness(Workflow workflow, string runId) return this._checkpointManager; } - private static async IAsyncEnumerable MonitorAndDisposeWorkflowRunAsync(Checkpointed run, object? response = null) + private static async IAsyncEnumerable MonitorAndDisposeWorkflowRunAsync(Checkpointed run, ExternalResponse? response = null) { await using IAsyncDisposable disposeRun = run; + if (response is not null) + { + await run.Run.SendResponseAsync(response).ConfigureAwait(false); + } + + bool exitLoop = false; + bool hasRequest = false; + await foreach (WorkflowEvent workflowEvent in run.Run.WatchStreamAsync().ConfigureAwait(false)) { - bool exitLoop = false; - switch (workflowEvent) { - case RequestInfoEvent requestInfo: - Console.WriteLine($"REQUEST #{requestInfo.Request.RequestId}"); - if (response is not null) - { - ExternalResponse requestResponse = requestInfo.Request.CreateResponse(response); - await run.Run.SendResponseAsync(requestResponse).ConfigureAwait(false); - response = null; - } - else + case SuperStepCompletedEvent: + if (hasRequest) { exitLoop = true; } break; + case RequestInfoEvent requestInfo: + Console.WriteLine($"REQUEST #{requestInfo.Request.RequestId}"); + hasRequest = true; + break; case ConversationUpdateEvent conversationEvent: Console.WriteLine($"CONVERSATION: {conversationEvent.ConversationId}"); diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/FunctionCallingWorkflowTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/FunctionCallingWorkflowTest.cs index 419c37e0eb..3238c59b54 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/FunctionCallingWorkflowTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/FunctionCallingWorkflowTest.cs @@ -33,6 +33,9 @@ public sealed class FunctionCallingWorkflowTest(ITestOutputHelper output) : Inte private async Task RunWorkflowAsync(bool autoInvoke, params IEnumerable functionTools) { + AgentProvider agentProvider = AgentProvider.Create(this.Configuration, AgentProvider.Names.FunctionTool); + await agentProvider.CreateAgentsAsync().ConfigureAwait(false); + string workflowPath = GetWorkflowPath("FunctionTool.yaml"); Dictionary functionMap = autoInvoke ? [] : functionTools.ToDictionary(tool => tool.Name, tool => tool); DeclarativeWorkflowOptions workflowOptions = await this.CreateOptionsAsync(externalConversation: false, autoInvoke ? functionTools : []); @@ -47,11 +50,11 @@ public sealed class FunctionCallingWorkflowTest(ITestOutputHelper output) : Inte Assert.False(autoInvoke); RequestInfoEvent inputEvent = workflowEvents.InputEvents[workflowEvents.InputEvents.Count - 1]; - AgentFunctionToolRequest? toolRequest = inputEvent.Request.Data.As(); + ExternalInputRequest? toolRequest = inputEvent.Request.Data.As(); Assert.NotNull(toolRequest); List<(FunctionCallContent, AIFunction)> functionCalls = []; - foreach (FunctionCallContent functionCall in toolRequest.FunctionCalls) + foreach (FunctionCallContent functionCall in toolRequest.AgentResponse.Messages.SelectMany(message => message.Contents).OfType()) { this.Output.WriteLine($"TOOL REQUEST: {functionCall.Name}"); if (!functionMap.TryGetValue(functionCall.Name, out AIFunction? functionTool)) @@ -62,11 +65,12 @@ public sealed class FunctionCallingWorkflowTest(ITestOutputHelper output) : Inte functionCalls.Add((functionCall, functionTool)); } - IList functionResults = await InvokeToolsAsync(functionCalls); + IList functionResults = await InvokeToolsAsync(functionCalls); ++responseCount; - WorkflowEvents runEvents = await harness.ResumeAsync(AgentFunctionToolResponse.Create(toolRequest, functionResults)).ConfigureAwait(false); + ChatMessage resultMessage = new(ChatRole.Tool, functionResults); + WorkflowEvents runEvents = await harness.ResumeAsync(inputEvent.Request.CreateResponse(new ExternalInputResponse(resultMessage))).ConfigureAwait(false); workflowEvents = new WorkflowEvents([.. workflowEvents.Events, .. runEvents.Events]); } @@ -79,13 +83,13 @@ public sealed class FunctionCallingWorkflowTest(ITestOutputHelper output) : Inte Assert.NotEmpty(workflowEvents.InputEvents); } - Assert.Equal(autoInvoke ? 3 : 5, workflowEvents.AgentResponseEvents.Count); + Assert.Equal(autoInvoke ? 3 : 4, workflowEvents.AgentResponseEvents.Count); Assert.All(workflowEvents.AgentResponseEvents, response => response.Response.Text.Contains("4.95")); } - private static async ValueTask> InvokeToolsAsync(IEnumerable<(FunctionCallContent, AIFunction)> functionCalls) + private static async ValueTask> InvokeToolsAsync(IEnumerable<(FunctionCallContent, AIFunction)> functionCalls) { - List results = []; + List results = []; foreach ((FunctionCallContent functionCall, AIFunction functionTool) in functionCalls) { diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/MediaInputTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/MediaInputTest.cs index 280df88b4b..33122e2fa8 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/MediaInputTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/MediaInputTest.cs @@ -4,10 +4,11 @@ using System; using System.IO; using System.Net.Http; using System.Threading.Tasks; -using Azure.AI.Agents.Persistent; +using Azure.AI.Projects; using Azure.Identity; using Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Framework; using Microsoft.Extensions.AI; +using OpenAI.Files; using Xunit.Abstractions; namespace Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests; @@ -18,9 +19,9 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests; public sealed class MediaInputTest(ITestOutputHelper output) : IntegrationTest(output) { private const string WorkflowFileName = "MediaInput.yaml"; - private const string ImageReference = "https://upload.wikimedia.org/wikipedia/commons/5/56/White_shark.jpg"; + private const string ImageReference = "https://sample-files.com/downloads/documents/pdf/basic-text.pdf"; - [Fact(Skip = "Service issue prevents this simple case")] + [Fact] public async Task ValidateImageUrlAsync() { this.Output.WriteLine($"Image: {ImageReference}"); @@ -30,20 +31,21 @@ public sealed class MediaInputTest(ITestOutputHelper output) : IntegrationTest(o [Fact] public async Task ValidateImageDataAsync() { - byte[] imageData = await DownloadImageAsync(); + byte[] imageData = await DownloadFileAsync(); string encodedData = Convert.ToBase64String(imageData); string imageUrl = $"data:image/png;base64,{encodedData}"; this.Output.WriteLine($"Image: {imageUrl.Substring(0, 112)}..."); await this.ValidateImageAsync(new DataContent(imageUrl)); } - [Fact] + [Fact(Skip = "Not behaving will in git-hub build pipeline")] public async Task ValidateImageUploadAsync() { - byte[] imageData = await DownloadImageAsync(); - PersistentAgentsClient client = new(this.FoundryConfiguration.Endpoint, new AzureCliCredential()); + byte[] imageData = await DownloadFileAsync(); + AIProjectClient client = new(this.TestEndpoint, new AzureCliCredential()); using MemoryStream contentStream = new(imageData); - PersistentAgentFileInfo fileInfo = await client.Files.UploadFileAsync(contentStream, PersistentAgentFilePurpose.Agents, "image.jpg"); + OpenAIFileClient fileClient = client.GetProjectOpenAIClient().GetOpenAIFileClient(); + OpenAIFile fileInfo = await fileClient.UploadFileAsync(contentStream, "basic-text.pdf", FileUploadPurpose.Assistants); try { this.Output.WriteLine($"Image: {fileInfo.Id}"); @@ -51,11 +53,11 @@ public sealed class MediaInputTest(ITestOutputHelper output) : IntegrationTest(o } finally { - await client.Files.DeleteFileAsync(fileInfo.Id); + await fileClient.DeleteFileAsync(fileInfo.Id); } } - private static async Task DownloadImageAsync() + private static async Task DownloadFileAsync() { using HttpClient client = new(); client.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/110.0"); diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.csproj index 39f590a220..9e86f4250a 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.csproj @@ -5,12 +5,15 @@ - true + true true + true + true + @@ -21,9 +24,6 @@ - - - diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Testcases/ConfirmInput.json b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Testcases/ConfirmInput.json new file mode 100644 index 0000000000..4469633d23 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Testcases/ConfirmInput.json @@ -0,0 +1,29 @@ +{ + "description": "Human in the loop sample - RequestExternalInput.yaml.", + "setup": { + "input": { + "type": "String", + "value": "1234" + }, + "responses": [ + { + "type": "String", + "value": "1234" + } + ] + }, + "validation": { + "conversation_count": 1, + "min_action_count": 4, + "max_action_count": -1, + "min_response_count": 0, + "actions": { + "start": [ + "set_project" + ], + "final": [ + "sendActivity_confirmed" + ] + } + } +} \ No newline at end of file diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Testcases/ConversationMessages.json b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Testcases/ConversationMessages.json index 86615bbd5e..38194a8a96 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Testcases/ConversationMessages.json +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Testcases/ConversationMessages.json @@ -10,7 +10,7 @@ "conversation_count": 2, "min_action_count": 8, "min_message_count": 1, - "min_response_count": 0, + "min_response_count": 1, "actions": { "start": [ "conversation_create1", diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Testcases/InputArguments.json b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Testcases/InputArguments.json new file mode 100644 index 0000000000..f4962e1bc6 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Testcases/InputArguments.json @@ -0,0 +1,23 @@ +{ + "description": "Authors a poem in the style specified by the input argument.", + "setup": { + "input": { + "type": "String", + "value": "Why is the sky blue?" + } + }, + "validation": { + "conversation_count": 1, + "min_action_count": 1, + "min_response_count": 1, + "min_message_count": 2, + "actions": { + "start": [ + "invoke_poem" + ], + "final": [ + "invoke_poem" + ] + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Testcases/InvokeAgent.json b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Testcases/InvokeAgent.json index 7a28a5b094..2b55754d91 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Testcases/InvokeAgent.json +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Testcases/InvokeAgent.json @@ -9,7 +9,8 @@ "validation": { "conversation_count": 3, "min_action_count": 3, - "min_response_count": 2, + "min_response_count": 3, + "min_message_count": 4, "actions": { "start": [ "invoke_inner1", diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Testcases/Marketing.json b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Testcases/Marketing.json index 68c40219d0..6af29b49c5 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Testcases/Marketing.json +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Testcases/Marketing.json @@ -10,6 +10,7 @@ "conversation_count": 1, "min_action_count": 3, "min_response_count": 3, + "min_message_count": 6, "actions": { "start": [ "invoke_analyst", diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Testcases/MathChat.json b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Testcases/MathChat.json index 988732a7a8..ea5337263a 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Testcases/MathChat.json +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Testcases/MathChat.json @@ -12,6 +12,8 @@ "max_action_count": -1, "min_response_count": 2, "max_response_count": 8, + "min_message_count": 4, + "max_message_count": -1, "actions": { "start": [ ], diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Testcases/RequestExternalInput.json b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Testcases/RequestExternalInput.json new file mode 100644 index 0000000000..6d5fd5e3d7 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Testcases/RequestExternalInput.json @@ -0,0 +1,29 @@ +{ + "description": "Human in the loop sample - RequestExternalInput.yaml.", + "setup": { + "input": { + "type": "String", + "value": "n/a" + }, + "responses": [ + { + "type": "String", + "value": "This is external input" + } + ] + }, + "validation": { + "conversation_count": 1, + "min_action_count": 2, + "min_response_count": 0, + "min_message_count": 1, + "actions": { + "start": [ + "get_input" + ], + "final": [ + "show_input" + ] + } + } +} \ No newline at end of file diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/CheckSystem.yaml b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/CheckSystem.yaml index c3542fb057..c20236fd69 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/CheckSystem.yaml +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/CheckSystem.yaml @@ -12,43 +12,43 @@ trigger: - condition: =IsBlank(System.Conversation) id: conversation_check actions: - - kind: EndDialog + - kind: EndWorkflow id: conversation_bad - condition: =IsBlank(System.Conversation.Id) id: conversation_id_check1 actions: - - kind: EndDialog + - kind: EndWorkflow id: conversation_id_bad1 - condition: =IsBlank(System.ConversationId) id: conversation_id_check2 actions: - - kind: EndDialog + - kind: EndWorkflow id: conversation_id_bad2 - condition: =IsBlank(System.LastMessage) id: message_check actions: - - kind: EndDialog + - kind: EndWorkflow id: message_bad - condition: =IsBlank(System.LastMessage.Id) id: message_id_check1 actions: - - kind: EndDialog + - kind: EndWorkflow id: message_id_bad1 - condition: =IsBlank(System.LastMessageId) id: message_id_check2 actions: - - kind: EndDialog + - kind: EndWorkflow id: message_id_bad2 - condition: =IsBlank(System.LastMessageText) id: message_text_check actions: - - kind: EndDialog + - kind: EndWorkflow id: message_text_bad elseActions: diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/ConfirmInput.yaml b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/ConfirmInput.yaml new file mode 100644 index 0000000000..339537c74a --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/ConfirmInput.yaml @@ -0,0 +1,61 @@ +# +# This workflow demonstrates how to use the Question action +# to request user input and confirm it matches the original input. +# +# Note: This workflow doesn't make use of any agents. +# +kind: Workflow +trigger: + + kind: OnConversationStart + id: workflow_demo + actions: + + # Capture original input + - kind: SetVariable + id: set_project + variable: Local.OriginalInput + value: =System.LastMessage.Text + + # Request input from user + - kind: Question + id: question_confirm + alwaysPrompt: false + autoSend: false + property: Local.ConfirmedInput + prompt: + kind: Message + text: + - "CONFIRM:" + entity: + kind: StringPrebuiltEntity + + # Confirm input + - kind: ConditionGroup + id: check_completion + conditions: + + # Didn't match + - condition: =Local.OriginalInput <> Local.ConfirmedInput + id: check_confirm + actions: + + - kind: SendActivity + id: sendActivity_mismatch + activity: |- + "{Local.ConfirmedInput}" does not match the original input of "{Local.OriginalInput}". Please try again. + + - kind: GotoAction + id: goto_again + actionId: question_confirm + + # Confirmed + elseActions: + - kind: SendActivity + id: sendActivity_confirmed + activity: |- + You entered: + {Local.OriginalInput} + + Confirmed input: + {Local.ConfirmedInput} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/FunctionTool.yaml b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/FunctionTool.yaml index 8cb65db8d6..3694845ee5 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/FunctionTool.yaml +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/FunctionTool.yaml @@ -9,13 +9,13 @@ trigger: id: invoke_greet conversationId: =System.ConversationId agent: - name: =Env.FOUNDRY_AGENT_TOOL + name: MenuAgent - kind: InvokeAzureAgent id: invoke_menu conversationId: =System.ConversationId agent: - name: =Env.FOUNDRY_AGENT_TOOL + name: MenuAgent input: messages: =UserMessage("What's on today's menu?") @@ -23,6 +23,6 @@ trigger: id: invoke_item conversationId: =System.ConversationId agent: - name: =Env.FOUNDRY_AGENT_TOOL + name: MenuAgent input: messages: =UserMessage("How much is the clam chowder?") diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/InputArguments.yaml b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/InputArguments.yaml new file mode 100644 index 0000000000..c963de31e3 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/InputArguments.yaml @@ -0,0 +1,15 @@ +kind: Workflow +trigger: + + kind: OnConversationStart + id: workflow_test + actions: + + - kind: InvokeAzureAgent + id: invoke_poem + conversationId: =System.ConversationId + agent: + name: PoemAgent + input: + arguments: + style: "ee cummings" diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/InvokeAgent.yaml b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/InvokeAgent.yaml index 159637402f..371a821bc4 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/InvokeAgent.yaml +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/InvokeAgent.yaml @@ -8,27 +8,25 @@ trigger: - kind: InvokeAzureAgent id: invoke_inner1 agent: - name: =Env.FOUNDRY_AGENT_TEST + name: TestAgent input: messages: =UserMessage("Can an LLM think of funny jokes?") - output: - autoSend: false - kind: InvokeAzureAgent id: invoke_inner2 agent: - name: =Env.FOUNDRY_AGENT_TEST + name: TestAgent input: messages: =UserMessage("Do you know the joke about the chicken crossing the road? Tell me an improved version of that joke.") + output: + autoSend: true - kind: InvokeAzureAgent id: invoke_external conversationId: =System.ConversationId agent: - name: =Env.FOUNDRY_AGENT_TEST + name: TestAgent input: - additionalInstructions: |- - Rate the originality of this well known joke that is being re-told on a scale of 1 to 10. - Take note on where improvements or changes were made. + messages: =UserMessage("Rate the originality of this well known joke that is being re-told on a scale of 1 to 10. Take note on where improvements or changes were made.") output: messages: Local.RatingResponse diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/MediaInput.yaml b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/MediaInput.yaml index c2a428f6d4..fce766988d 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/MediaInput.yaml +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/MediaInput.yaml @@ -9,7 +9,7 @@ trigger: id: invoke_vision conversationId: =System.ConversationId agent: - name: =Env.FOUNDRY_AGENT_TEST + name: TestAgent input: additionalInstructions: |- Describe the image contained in the user request, if any; diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/RequestExternalInput.yaml b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/RequestExternalInput.yaml new file mode 100644 index 0000000000..1070316c4f --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/RequestExternalInput.yaml @@ -0,0 +1,14 @@ +kind: Workflow +trigger: + + kind: OnConversationStart + id: workflow_test + actions: + + - kind: RequestExternalInput + id: get_input + variable: Local.MyInput + + - kind: SendMessage + id: show_input + message: "You provided: {Local.MyInput}" diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/SendActivity.yaml b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/SendActivity.yaml index 18384df2da..60bac51982 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/SendActivity.yaml +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/SendActivity.yaml @@ -15,7 +15,7 @@ trigger: - kind: SetVariable id: set_user_name variable: Global.UserName - value: =Env.USERNAME + value: TestAgent # Respond with input - kind: SendActivity diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/CodeGen/DeclarativeEjectionTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/CodeGen/DeclarativeEjectionTest.cs index 8350a81524..6f87f77fb4 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/CodeGen/DeclarativeEjectionTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/CodeGen/DeclarativeEjectionTest.cs @@ -15,6 +15,7 @@ public sealed class DeclarativeEjectionTest(ITestOutputHelper output) : Workflow { [Theory] [InlineData("AddConversationMessage.yaml")] + [InlineData("CancelWorkflow.yaml")] [InlineData("ClearAllVariables.yaml")] [InlineData("CopyConversationMessages.yaml")] [InlineData("Condition.yaml")] @@ -23,7 +24,7 @@ public sealed class DeclarativeEjectionTest(ITestOutputHelper output) : Workflow [InlineData("EditTable.yaml")] [InlineData("EditTableV2.yaml")] [InlineData("EndConversation.yaml")] - [InlineData("EndDialog.yaml")] + [InlineData("EndWorkflow.yaml")] [InlineData("Goto.yaml")] [InlineData("InvokeAgent.yaml")] [InlineData("LoopBreak.yaml")] diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/CodeGen/InvokeAzureAgentTemplateTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/CodeGen/InvokeAzureAgentTemplateTest.cs index e1727e5666..a22fcd9920 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/CodeGen/InvokeAzureAgentTemplateTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/CodeGen/InvokeAzureAgentTemplateTest.cs @@ -44,18 +44,6 @@ public class InvokeAzureAgentTemplateTest(ITestOutputHelper output) : WorkflowAc BoolExpression.Expression("1 < 2")); } - [Fact] - public void AdditionalInstructions() - { - // Act, Assert - this.ExecuteTest( - nameof(VariableConversation), - StringExpression.Literal("asst_123abc"), - StringExpression.Variable(PropertyPath.TopicVariable("TestConversation")), - "MyMessages", - additionalInstructions: "Test instructions..."); - } - [Fact] public void InputMessagesVariable() { @@ -86,7 +74,6 @@ public class InvokeAzureAgentTemplateTest(ITestOutputHelper output) : WorkflowAc StringExpression.Builder conversation, string? messagesVariable = null, BoolExpression.Builder? autoSend = null, - string? additionalInstructions = null, ValueExpression.Builder? messages = null) { // Arrange @@ -97,7 +84,6 @@ public class InvokeAzureAgentTemplateTest(ITestOutputHelper output) : WorkflowAc conversation, messagesVariable, autoSend, - additionalInstructions is null ? null : (TemplateLine.Builder)TemplateLine.Parse(additionalInstructions), messages); // Act @@ -117,7 +103,6 @@ public class InvokeAzureAgentTemplateTest(ITestOutputHelper output) : WorkflowAc StringExpression.Builder conversation, string? messagesVariable = null, BoolExpression.Builder? autoSend = null, - TemplateLine.Builder? additionalInstructions = null, ValueExpression.Builder? messages = null) { InitializablePropertyPath? outputMessages = null; @@ -141,7 +126,6 @@ public class InvokeAzureAgentTemplateTest(ITestOutputHelper output) : WorkflowAc new AzureAgentInput.Builder { Messages = messages, - AdditionalInstructions = additionalInstructions, }, Output = new AzureAgentOutput.Builder 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 6f2282902f..d606770ff8 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs @@ -117,7 +117,7 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow this.AssertNotExecuted("sendActivity_even"); this.AssertMessage("ODD"); } - this.AssertExecuted("end_all"); + this.AssertExecuted("activity_final"); } [Theory] @@ -141,12 +141,36 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow this.AssertExecuted("sendActivity_odd"); this.AssertNotExecuted("sendActivity_else"); } - this.AssertExecuted("end_all"); + this.AssertExecuted("activity_final"); } [Theory] + [InlineData(12, 4)] + [InlineData(37, 9)] + public async Task ConditionActionWithFallThroughAsync(int input, int expectedActions) + { + await this.RunWorkflowAsync("ConditionFallThrough.yaml", input); + this.AssertExecutionCount(expectedActions); + this.AssertExecuted("setVariable_test"); + this.AssertExecuted("conditionGroup_test", isScope: true); + if (input % 2 == 0) + { + this.AssertNotExecuted("conditionItem_odd"); + this.AssertNotExecuted("sendActivity_odd"); + } + else + { + this.AssertExecuted("conditionItem_odd", isScope: true); + this.AssertExecuted("sendActivity_odd"); + this.AssertMessage("ODD"); + } + this.AssertExecuted("activity_final"); + } + + [Theory] + [InlineData("CancelWorkflow.yaml", 1, "end_all")] [InlineData("EndConversation.yaml", 1, "end_all")] - [InlineData("EndDialog.yaml", 1, "end_all")] + [InlineData("EndWorkflow.yaml", 1, "end_all")] [InlineData("EditTable.yaml", 2, "edit_var")] [InlineData("EditTableV2.yaml", 2, "edit_var")] [InlineData("ParseValue.yaml", 2, "parse_var")] @@ -170,8 +194,6 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow [InlineData(typeof(AdaptiveCardPrompt.Builder))] [InlineData(typeof(BeginDialog.Builder))] [InlineData(typeof(CSATQuestion.Builder))] - [InlineData(typeof(CancelAllDialogs.Builder))] - [InlineData(typeof(CancelDialog.Builder))] [InlineData(typeof(CreateSearchQuery.Builder))] [InlineData(typeof(DeleteActivity.Builder))] [InlineData(typeof(DisableTrigger.Builder))] @@ -231,7 +253,7 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow [InlineData("Condition.yaml", "setVariable_test")] [InlineData("ConditionElse.yaml", "setVariable_test")] [InlineData("EndConversation.yaml", "end_all")] - [InlineData("EndDialog.yaml", "end_all")] + [InlineData("EndWorkflow.yaml", "end_all")] [InlineData("EditTable.yaml", "edit_var")] [InlineData("EditTableV2.yaml", "edit_var")] [InlineData("Goto.yaml", "goto_end")] diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Events/AgentFunctionToolRequestTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Events/AgentFunctionToolRequestTest.cs deleted file mode 100644 index 64639251aa..0000000000 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Events/AgentFunctionToolRequestTest.cs +++ /dev/null @@ -1,48 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Collections.Generic; -using Microsoft.Agents.AI.Workflows.Declarative.Events; -using Microsoft.Extensions.AI; -using Xunit.Abstractions; - -namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.Events; - -/// -/// Verify class -/// -public sealed class AgentFunctionToolRequestTest(ITestOutputHelper output) : EventTest(output) -{ - [Fact] - public void VerifySerializationEmpty() - { - // Arrange & Act - AgentFunctionToolRequest copy = VerifyEventSerialization(new AgentFunctionToolRequest("testagent", [])); - - // Assert - Assert.Equal("testagent", copy.AgentName); - Assert.Empty(copy.FunctionCalls); - } - - [Fact] - public void VerifySerializationWithRequests() - { - // Arrange & Act - AgentFunctionToolRequest copy = - VerifyEventSerialization( - new AgentFunctionToolRequest( - "agent", - [ - new FunctionCallContent("call1", "result1"), - new FunctionCallContent("call2", "result2", new Dictionary() { { "name", "Clam Chowder" } }), - ])); - - // Assert - Assert.Equal("agent", copy.AgentName); - Assert.Equal(2, copy.FunctionCalls.Count); - Assert.IsType(copy.FunctionCalls[0]); - Assert.Null(copy.FunctionCalls[0].Arguments); - Assert.IsType(copy.FunctionCalls[1]); - Assert.NotNull(copy.FunctionCalls[1].Arguments); - Assert.NotEmpty(copy.FunctionCalls[1].Arguments!); - } -} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Events/AgentFunctionToolResponseTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Events/AgentFunctionToolResponseTest.cs deleted file mode 100644 index 9467d67117..0000000000 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Events/AgentFunctionToolResponseTest.cs +++ /dev/null @@ -1,46 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Microsoft.Agents.AI.Workflows.Declarative.Events; -using Microsoft.Extensions.AI; -using Xunit.Abstractions; - -namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.Events; - -/// -/// Verify class -/// -public sealed class AgentFunctionToolResponseTest(ITestOutputHelper output) : EventTest(output) -{ - [Fact] - public void VerifySerializationEmpty() - { - // Arrange & Act - AgentFunctionToolResponse copy = VerifyEventSerialization(new AgentFunctionToolResponse("testagent", [])); - - // Assert - Assert.Equal("testagent", copy.AgentName); - Assert.Empty(copy.FunctionResults); - } - - [Fact] - public void VerifySerializationWithResults() - { - // Arrange & Act - AgentFunctionToolResponse copy = - VerifyEventSerialization( - new AgentFunctionToolResponse( - "agent", - [ - new FunctionResultContent("call1", "result1"), - new FunctionResultContent("call2", "result2"), - ])); - - // Assert - Assert.Equal("agent", copy.AgentName); - Assert.Equal(2, copy.FunctionResults.Count); - Assert.IsType(copy.FunctionResults[0]); - Assert.Equal("call1", copy.FunctionResults[0].CallId); - Assert.IsType(copy.FunctionResults[1]); - Assert.Equal("call2", copy.FunctionResults[1].CallId); - } -} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Events/EventTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Events/EventTest.cs index 7dc1c895d3..a4965ebc61 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Events/EventTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Events/EventTest.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System.Linq; using System.Text.Json; using Microsoft.Extensions.AI; using Xunit.Abstractions; @@ -19,4 +20,17 @@ public abstract class EventTest(ITestOutputHelper output) : WorkflowTest(output) Assert.NotNull(copy); return copy; } + + protected static void AssertMessage(ChatMessage source, ChatMessage copy) + { + Assert.Equal(source.Role, copy.Role); + Assert.Equal(source.Text, copy.Text); + Assert.Equal(source.Contents.Count, copy.Contents.Count); + } + + protected static TContent AssertContent(ChatMessage message) where TContent : AIContent + { + TContent[] contents = message.Contents.OfType().ToArray(); + return Assert.Single(contents); + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Events/ExternalInputRequestTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Events/ExternalInputRequestTest.cs new file mode 100644 index 0000000000..49d06337fe --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Events/ExternalInputRequestTest.cs @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.Workflows.Declarative.Events; +using Microsoft.Extensions.AI; +using Xunit.Abstractions; + +namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.Events; + +/// +/// Verify class +/// +public sealed class ExternalInputRequestTest(ITestOutputHelper output) : EventTest(output) +{ + [Fact] + public void VerifySerializationWithText() + { + // Arrange + ExternalInputRequest source = new(new AgentRunResponse(new ChatMessage(ChatRole.User, "Wassup?"))); + + // Act + ExternalInputRequest copy = VerifyEventSerialization(source); + + // Assert + ChatMessage messageCopy = Assert.Single(source.AgentResponse.Messages); + AssertMessage(messageCopy, copy.AgentResponse.Messages[0]); + } + + [Fact] + public void VerifySerializationWithRequests() + { + // Arrange + ExternalInputRequest source = + new(new AgentRunResponse( + new ChatMessage( + ChatRole.Assistant, + [ + new McpServerToolApprovalRequestContent("call1", new McpServerToolCallContent("call1", "testmcp", "server-name")), + new FunctionApprovalRequestContent("call2", new FunctionCallContent("call2", "result1")), + new FunctionCallContent("call3", "myfunc"), + new TextContent("Heya"), + ]))); + + // Act + ExternalInputRequest copy = VerifyEventSerialization(source); + + // Assert + ChatMessage messageCopy = Assert.Single(source.AgentResponse.Messages); + Assert.Equal(messageCopy.Contents.Count, copy.AgentResponse.Messages[0].Contents.Count); + + McpServerToolApprovalRequestContent mcpRequest = AssertContent(messageCopy); + Assert.Equal("call1", mcpRequest.Id); + + FunctionApprovalRequestContent functionRequest = AssertContent(messageCopy); + Assert.Equal("call2", functionRequest.Id); + + FunctionCallContent functionCall = AssertContent(messageCopy); + Assert.Equal("call3", functionCall.CallId); + + TextContent textContent = AssertContent(messageCopy); + Assert.Equal("Heya", textContent.Text); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Events/ExternalInputResponseTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Events/ExternalInputResponseTest.cs new file mode 100644 index 0000000000..b1fb358727 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Events/ExternalInputResponseTest.cs @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.Workflows.Declarative.Events; +using Microsoft.Extensions.AI; +using Xunit.Abstractions; + +namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.Events; + +/// +/// Verify class +/// +public sealed class ExternalInputResponseTest(ITestOutputHelper output) : EventTest(output) +{ + [Fact] + public void VerifySerializationEmpty() + { + // Arrange + ExternalInputResponse source = new(new ChatMessage(ChatRole.User, "Wassup?")); + + // Act + ExternalInputResponse copy = VerifyEventSerialization(source); + + // Assert + ChatMessage messageCopy = Assert.Single(source.Messages); + AssertMessage(messageCopy, copy.Messages[0]); + } + + [Fact] + public void VerifySerializationWithResponses() + { + // Arrange + ExternalInputResponse source = + new(new ChatMessage( + ChatRole.Assistant, + [ + new McpServerToolApprovalRequestContent("call1", new McpServerToolCallContent("call1", "testmcp", "server-name")).CreateResponse(approved: true), + new FunctionApprovalRequestContent("call2", new FunctionCallContent("call2", "result1")).CreateResponse(approved: true), + new FunctionResultContent("call3", 33), + new TextContent("Heya"), + ])); + + // Act + ExternalInputResponse copy = VerifyEventSerialization(source); + + // Assert + ChatMessage responseMessage = Assert.Single(source.Messages); + Assert.Equal(responseMessage.Contents.Count, copy.Messages[0].Contents.Count); + + McpServerToolApprovalResponseContent mcpApproval = AssertContent(responseMessage); + Assert.Equal("call1", mcpApproval.Id); + + FunctionApprovalResponseContent functionApproval = AssertContent(responseMessage); + Assert.Equal("call2", functionApproval.Id); + + FunctionResultContent functionResult = AssertContent(responseMessage); + Assert.Equal("call3", functionResult.CallId); + + TextContent textContent = AssertContent(responseMessage); + Assert.Equal("Heya", textContent.Text); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Events/UserInputRequestTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Events/UserInputRequestTest.cs deleted file mode 100644 index c190e93193..0000000000 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Events/UserInputRequestTest.cs +++ /dev/null @@ -1,46 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Microsoft.Agents.AI.Workflows.Declarative.Events; -using Microsoft.Extensions.AI; -using Xunit.Abstractions; - -namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.Events; - -/// -/// Verify class -/// -public sealed class UserInputRequestTest(ITestOutputHelper output) : EventTest(output) -{ - [Fact] - public void VerifySerializationEmpty() - { - // Arrange & Act - UserInputRequest copy = VerifyEventSerialization(new UserInputRequest("test agent", [])); - - // Assert - Assert.Equal("test agent", copy.AgentName); - Assert.Empty(copy.InputRequests); - } - - [Fact] - public void VerifySerializationWithRequests() - { - // Arrange & Act - UserInputRequest copy = - VerifyEventSerialization( - new UserInputRequest( - "agent", - [ - new McpServerToolApprovalRequestContent("call1", new McpServerToolCallContent("call1", "testmcp", "server-name")), - new FunctionApprovalRequestContent("call2", new FunctionCallContent("call2", "result1")), - ])); - - // Assert - Assert.Equal("agent", copy.AgentName); - Assert.Equal(2, copy.InputRequests.Count); - McpServerToolApprovalRequestContent mcpRequest = Assert.IsType(copy.InputRequests[0]); - Assert.Equal("call1", mcpRequest.Id); - FunctionApprovalRequestContent functionRequest = Assert.IsType(copy.InputRequests[1]); - Assert.Equal("call2", functionRequest.Id); - } -} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Events/UserInputResponseTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Events/UserInputResponseTest.cs deleted file mode 100644 index 2d36d01611..0000000000 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Events/UserInputResponseTest.cs +++ /dev/null @@ -1,46 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Microsoft.Agents.AI.Workflows.Declarative.Events; -using Microsoft.Extensions.AI; -using Xunit.Abstractions; - -namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.Events; - -/// -/// Verify class -/// -public sealed class UserInputResponseTest(ITestOutputHelper output) : EventTest(output) -{ - [Fact] - public void VerifySerializationEmpty() - { - // Arrange & Act - UserInputResponse copy = VerifyEventSerialization(new UserInputResponse("testagent", [])); - - // Assert - Assert.Equal("testagent", copy.AgentName); - Assert.Empty(copy.InputResponses); - } - - [Fact] - public void VerifySerializationWithResponses() - { - // Arrange & Act - UserInputResponse copy = - VerifyEventSerialization( - new UserInputResponse( - "agent", - [ - new McpServerToolApprovalRequestContent("call1", new McpServerToolCallContent("call1", "testmcp", "server-name")).CreateResponse(approved: true), - new FunctionApprovalRequestContent("call2", new FunctionCallContent("call2", "result1")).CreateResponse(approved: true), - ])); - - // Assert - Assert.Equal("agent", copy.AgentName); - Assert.Equal(2, copy.InputResponses.Count); - McpServerToolApprovalResponseContent mcpResponse = Assert.IsType(copy.InputResponses[0]); - Assert.Equal("call1", mcpResponse.Id); - FunctionApprovalResponseContent functionResponse = Assert.IsType(copy.InputResponses[1]); - Assert.Equal("call2", functionResponse.Id); - } -} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Events/UserMessageRequestTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Events/UserMessageRequestTest.cs deleted file mode 100644 index db6ab351ce..0000000000 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Events/UserMessageRequestTest.cs +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Microsoft.Agents.AI.Workflows.Declarative.Events; -using Xunit.Abstractions; - -namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.Events; - -/// -/// Verify class -/// -public sealed class UserMessageRequestTest(ITestOutputHelper output) : EventTest(output) -{ - [Fact] - public void VerifySerialization() - { - // Arrange & Act - AnswerRequest copy = VerifyEventSerialization(new AnswerRequest("wassup")); - - // Assert - Assert.Equal("wassup", copy.Prompt); - } -} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Events/UserMessageResponseTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Events/UserMessageResponseTest.cs deleted file mode 100644 index efeef6052d..0000000000 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Events/UserMessageResponseTest.cs +++ /dev/null @@ -1,33 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Microsoft.Agents.AI.Workflows.Declarative.Events; -using Microsoft.Extensions.AI; -using Xunit.Abstractions; - -namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.Events; - -/// -/// Verify class -/// -public sealed class UserMessageResponseTest(ITestOutputHelper output) : EventTest(output) -{ - [Fact] - public void VerifySerializationText() - { - // Arrange & Act - AnswerResponse copy = VerifyEventSerialization(new AnswerResponse("test response")); - - // Assert - Assert.Equal("test response", copy.Value.Text); - } - - [Fact] - public void VerifySerializationMessage() - { - // Arrange & Act - AnswerResponse copy = VerifyEventSerialization(new AnswerResponse(new ChatMessage(ChatRole.User, "test response"))); - - // Assert - Assert.Equal("test response", copy.Value.Text); - } -} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Extensions/JsonDocumentExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Extensions/JsonDocumentExtensionsTests.cs index 8c27bf6c8c..bd86aa1958 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Extensions/JsonDocumentExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Extensions/JsonDocumentExtensionsTests.cs @@ -58,6 +58,38 @@ public sealed class JsonDocumentExtensionsTests Assert.Equal(expectedTimeSpan, result["time"]); } + [Fact] + public void ParseRecord_Object_NoSchema_Succeeds() + { + // Arrange + JsonDocument document = JsonDocument.Parse( + """ + { + "text": "hello", + "numberInt": 7, + "numberLong": 9223372036854775807, + "numberDecimal": 12.5, + "numberDouble": 3.99E99, + "flag": true, + "date": "2024-10-01T12:34:56Z", + "time": "12:34:56" + } + """); + + // Act + Dictionary result = document.ParseRecord(VariableType.RecordType); + + // Assert + Assert.Equal("hello", result["text"]); + Assert.Equal(7, result["numberInt"]); + Assert.Equal(9223372036854775807L, result["numberLong"]); + Assert.Equal(12.5m, result["numberDecimal"]); + Assert.Equal(3.99E99, result["numberDouble"]); + Assert.Equal(true, result["flag"]); + Assert.Equal("2024-10-01T12:34:56Z", result["date"]); + Assert.Equal("12:34:56", result["time"]); + } + [Fact] public void ParseRecord_Object_NestedRecord_Succeeds() { diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Extensions/PortableValueExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Extensions/PortableValueExtensionsTests.cs index 9764961467..27cc627b62 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Extensions/PortableValueExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Extensions/PortableValueExtensionsTests.cs @@ -53,6 +53,13 @@ public sealed class PortableValueExtensionsTests [Fact] public void ChatMessageType() => TestValidType(new ChatMessage(ChatRole.User, "input"), RecordType.Empty()); + [Fact] + public void ListEmptyType() + { + TableValue convertedValue = (TableValue)TestValidType(Array.Empty(), TableType.Empty()); + Assert.Equal(0, convertedValue.Count()); + } + [Fact] public void ListSimpleType() { diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Extensions/TemplateExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Extensions/TemplateExtensionsTests.cs index 86591587c2..07421c9ecb 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Extensions/TemplateExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Extensions/TemplateExtensionsTests.cs @@ -14,8 +14,8 @@ public sealed class TemplateExtensionsTests { // Arrange RecalcEngine engine = new(); - IEnumerable template = new List - { + IEnumerable template = + [ new TemplateLine.Builder { Segments = @@ -24,7 +24,7 @@ public sealed class TemplateExtensionsTests new TextSegment.Builder { Value = "World" } } }.Build() - }; + ]; // Act string result = engine.Format(template); @@ -38,8 +38,8 @@ public sealed class TemplateExtensionsTests { // Arrange RecalcEngine engine = new(); - IEnumerable template = new List - { + IEnumerable template = + [ new TemplateLine.Builder { Segments = @@ -54,7 +54,7 @@ public sealed class TemplateExtensionsTests new TextSegment.Builder { Value = "Line 2" } } }.Build() - }; + ]; // Act string result = engine.Format(template); diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests.csproj index 4eca981e33..491ec95778 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests.csproj @@ -14,6 +14,7 @@ + diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/PowerFx/Functions/AgentMessageTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/PowerFx/Functions/AgentMessageTests.cs new file mode 100644 index 0000000000..7ca7041549 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/PowerFx/Functions/AgentMessageTests.cs @@ -0,0 +1,68 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using Microsoft.Agents.AI.Workflows.Declarative.PowerFx; +using Microsoft.Agents.AI.Workflows.Declarative.PowerFx.Functions; +using Microsoft.Extensions.AI; +using Microsoft.PowerFx.Types; + +namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.PowerFx.Functions; + +public sealed class AgentMessageTests +{ + [Fact] + public void Construct_Function() + { + AgentMessage function = new(); + Assert.NotNull(function); + } + + [Fact] + public void Execute_ReturnsBlank_ForEmptyInput() + { + // Arrange + StringValue sourceValue = FormulaValue.New(string.Empty); + + // Act + FormulaValue result = AgentMessage.Execute(sourceValue); + + // Assert + Assert.IsType(result); + } + + [Fact] + public void Execute_ReturnsExpectedRecord_ForNonEmptyInput() + { + const string Text = "Hello"; + FormulaValue sourceValue = FormulaValue.New(Text); + StringValue stringValue = Assert.IsType(sourceValue); + + FormulaValue result = AgentMessage.Execute(stringValue); + + RecordValue recordResult = Assert.IsType(result, exactMatch: false); + + // Discriminator + FormulaValue discriminator = recordResult.GetField(TypeSchema.Discriminator); + StringValue discriminatorValue = Assert.IsType(discriminator); + Assert.Equal(nameof(ChatMessage), discriminatorValue.Value); + + // Role + FormulaValue role = recordResult.GetField(TypeSchema.Message.Fields.Role); + StringValue roleValue = Assert.IsType(role); + Assert.Equal(ChatRole.Assistant.Value, roleValue.Value); + + // Content table + FormulaValue content = recordResult.GetField(TypeSchema.Message.Fields.Content); + TableValue table = Assert.IsType(content, exactMatch: false); + + List rows = table.Rows.Select(value => value.Value).ToList(); + Assert.Single(rows); + + StringValue contentType = Assert.IsType(rows[0].GetField(TypeSchema.Message.Fields.ContentType)); + Assert.Equal(TypeSchema.Message.ContentTypes.Text, contentType.Value); + + StringValue contentValue = Assert.IsType(rows[0].GetField(TypeSchema.Message.Fields.ContentValue)); + Assert.Equal(Text, contentValue.Value); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/PowerFx/Functions/MessageTextTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/PowerFx/Functions/MessageTextTests.cs new file mode 100644 index 0000000000..5cbc374990 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/PowerFx/Functions/MessageTextTests.cs @@ -0,0 +1,113 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using Microsoft.Agents.AI.Workflows.Declarative.Extensions; +using Microsoft.Agents.AI.Workflows.Declarative.PowerFx.Functions; +using Microsoft.Extensions.AI; +using Microsoft.PowerFx.Types; + +namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.PowerFx.Functions; + +public sealed class MessageTextTests +{ + [Fact] + public void Construct_Function() + { + MessageText.StringInput function1 = new(); + Assert.NotNull(function1); + + MessageText.RecordInput function2 = new(); + Assert.NotNull(function2); + + MessageText.TableInput function3 = new(); + Assert.NotNull(function3); + } + + [Fact] + public void Execute_ReturnsEmpty_ForEmptyInput() + { + // Arrange + StringValue sourceValue = FormulaValue.New(string.Empty); + + // Act + FormulaValue result = MessageText.StringInput.Execute(sourceValue); + + // Assert + StringValue stringResult = Assert.IsType(result); + Assert.Empty(stringResult.Value); + } + + [Fact] + public void Execute_ReturnsText_ForStringInput() + { + // Arrange + StringValue sourceValue = FormulaValue.New("wowsie"); + + // Act + FormulaValue result = MessageText.StringInput.Execute(sourceValue); + + // Assert + StringValue stringResult = Assert.IsType(result); + Assert.Equal(sourceValue.Value, stringResult.Value); + } + + [Fact] + public void Execute_ReturnsText_ForMessageInput() + { + // Arrange + RecordValue sourceValue = new ChatMessage(ChatRole.User, "test message").ToRecord(); + + // Act + FormulaValue result = MessageText.RecordInput.Execute(sourceValue); + + // Assert + StringValue stringResult = Assert.IsType(result); + Assert.Equal("test message", stringResult.Value); + } + + [Fact] + public void Execute_ReturnsEmpty_ForUnknownInput() + { + // Arrange + RecordValue sourceValue = FormulaValue.NewRecordFromFields(new NamedValue("Anything", FormulaValue.New(333))); + + // Act + FormulaValue result = MessageText.RecordInput.Execute(sourceValue); + + // Assert + StringValue stringResult = Assert.IsType(result); + Assert.Empty(stringResult.Value); + } + + [Fact] + public void Execute_ReturnsText_ForMessagesInput() + { + // Arrange + TableValue sourceValue = new ChatMessage[] + { + new(ChatRole.User, "test message 1"), + new(ChatRole.User, "test message 2"), + }.ToTable(); + + // Act + FormulaValue result = MessageText.TableInput.Execute(sourceValue); + + // Assert + StringValue stringResult = Assert.IsType(result); + Assert.Equal("test message 1\ntest message 2", stringResult.Value); + } + + [Fact] + public void Execute_ReturnsEmpty_ForEmptyList() + { + // Arrange + TableValue sourceValue = Array.Empty().ToTable(); + + // Act + FormulaValue result = MessageText.TableInput.Execute(sourceValue); + + // Assert + StringValue stringResult = Assert.IsType(result); + Assert.Empty(stringResult.Value); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/PowerFx/Functions/UserMessageTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/PowerFx/Functions/UserMessageTests.cs index 712fb139bf..705831473c 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/PowerFx/Functions/UserMessageTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/PowerFx/Functions/UserMessageTests.cs @@ -22,11 +22,10 @@ public class UserMessageTests public void Execute_ReturnsBlank_ForEmptyInput() { // Arrange - FormulaValue sourceValue = FormulaValue.New(string.Empty); - StringValue stringValue = Assert.IsType(sourceValue); + StringValue sourceValue = FormulaValue.New(string.Empty); // Act - FormulaValue result = UserMessage.Execute(stringValue); + FormulaValue result = UserMessage.Execute(sourceValue); // Assert Assert.IsType(result); diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/EndDialog.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/CancelWorkflow.cs similarity index 100% rename from dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/EndDialog.cs rename to dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/CancelWorkflow.cs diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/CancelWorkflow.yaml b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/CancelWorkflow.yaml new file mode 100644 index 0000000000..209e94f81b --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/CancelWorkflow.yaml @@ -0,0 +1,13 @@ +kind: Workflow +trigger: + + kind: OnConversationStart + id: my_workflow + actions: + + - kind: CancelWorkflow + id: end_all + + - kind: SendActivity + id: send_activity_1 + activity: NEVER 1! diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/CaseInsensitive.yaml b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/CaseInsensitive.yaml index b6b76c6957..afdbee877a 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/CaseInsensitive.yaml +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/CaseInsensitive.yaml @@ -21,7 +21,7 @@ trigger: - id: condition_match condition: =Local.TestValue1 + Local.TestValue2 = 7 actions: - - kind: EndDialog + - kind: EndWorkflow id: end_when_match - kind: SendActivity diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/Condition.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/Condition.cs index 2e38cc1859..68deb1f19e 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/Condition.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/Condition.cs @@ -1,4 +1,4 @@ -// ------------------------------------------------------------------------------ +// ------------------------------------------------------------------------------ // // This code was generated by a tool. // @@ -129,6 +129,27 @@ public static class WorkflowProvider } } + /// + /// Formats a message template and sends an activity event. + /// + internal sealed class ActivityFinalExecutor(FormulaSession session) : ActionExecutor(id: "activity_final", session) + { + // + protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) + { + string activityText = + await context.FormatTemplateAsync( + """ + All done! + """ + ); + AgentRunResponse response = new([new ChatMessage(ChatRole.Assistant, activityText)]); + await context.AddEventAsync(new AgentRunResponseEvent(this.Id, response)).ConfigureAwait(false); + + return default; + } + } + public static Workflow CreateWorkflow( DeclarativeWorkflowOptions options, Func? inputTransform = null) @@ -147,7 +168,7 @@ public static class WorkflowProvider DelegateExecutor conditionItemEvenactions = new(id: "conditionItem_evenActions", myWorkflowRoot.Session); SendactivityEvenExecutor sendActivityEven = new(myWorkflowRoot.Session); DelegateExecutor conditionGroupTestPost = new(id: "conditionGroup_test_Post", myWorkflowRoot.Session); - DelegateExecutor endAll = new(id: "end_all", myWorkflowRoot.Session); + ActivityFinalExecutor activityFinal = new(myWorkflowRoot.Session); DelegateExecutor conditionItemOddPost = new(id: "conditionItem_odd_Post", myWorkflowRoot.Session); DelegateExecutor conditionItemEvenPost = new(id: "conditionItem_even_Post", myWorkflowRoot.Session); DelegateExecutor conditionItemOddactionsPost = new(id: "conditionItem_oddActions_Post", myWorkflowRoot.Session); @@ -166,7 +187,7 @@ public static class WorkflowProvider builder.AddEdge(conditionItemOddactions, sendActivityOdd); builder.AddEdge(conditionItemEven, conditionItemEvenactions); builder.AddEdge(conditionItemEvenactions, sendActivityEven); - builder.AddEdge(conditionGroupTestPost, endAll); + builder.AddEdge(conditionGroupTestPost, activityFinal); builder.AddEdge(conditionItemOddPost, conditionGroupTestPost); builder.AddEdge(conditionItemEvenPost, conditionGroupTestPost); builder.AddEdge(sendActivityOdd, conditionItemOddactionsPost); @@ -177,4 +198,4 @@ public static class WorkflowProvider // Build the workflow return builder.Build(validateOrphans: false); } -} +} \ No newline at end of file diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/Condition.yaml b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/Condition.yaml index c79f5f0552..fd4274c357 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/Condition.yaml +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/Condition.yaml @@ -27,5 +27,6 @@ trigger: id: sendActivity_even activity: EVEN - - kind: EndConversation - id: end_all + - kind: SendActivity + id: activity_final + activity: All done! diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/ConditionElse.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/ConditionElse.cs index 8aebc5c8a8..47e278bc59 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/ConditionElse.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/ConditionElse.cs @@ -1,4 +1,4 @@ -// ------------------------------------------------------------------------------ +// ------------------------------------------------------------------------------ // // This code was generated by a tool. // @@ -123,6 +123,27 @@ public static class WorkflowProvider } } + /// + /// Formats a message template and sends an activity event. + /// + internal sealed class ActivityFinalExecutor(FormulaSession session) : ActionExecutor(id: "activity_final", session) + { + // + protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) + { + string activityText = + await context.FormatTemplateAsync( + """ + All done! + """ + ); + AgentRunResponse response = new([new ChatMessage(ChatRole.Assistant, activityText)]); + await context.AddEventAsync(new AgentRunResponseEvent(this.Id, response)).ConfigureAwait(false); + + return default; + } + } + public static Workflow CreateWorkflow( DeclarativeWorkflowOptions options, Func? inputTransform = null) @@ -141,7 +162,7 @@ public static class WorkflowProvider DelegateExecutor conditionItemOddRestart = new(id: "conditionItem_odd_Restart", myWorkflowRoot.Session); SendactivityElseExecutor sendActivityElse = new(myWorkflowRoot.Session); DelegateExecutor conditionGroupTestPost = new(id: "conditionGroup_test_Post", myWorkflowRoot.Session); - DelegateExecutor endAll = new(id: "end_all", myWorkflowRoot.Session); + ActivityFinalExecutor activityFinal = new(myWorkflowRoot.Session); DelegateExecutor conditionItemOddPost = new(id: "conditionItem_odd_Post", myWorkflowRoot.Session); DelegateExecutor conditionItemOddactionsPost = new(id: "conditionItem_oddActions_Post", myWorkflowRoot.Session); DelegateExecutor conditionGroupTestelseactionsPost = new(id: "conditionGroup_testElseActions_Post", myWorkflowRoot.Session); @@ -159,7 +180,7 @@ public static class WorkflowProvider builder.AddEdge(conditionItemOddactions, sendActivityOdd); builder.AddEdge(conditionItemOddRestart, conditionGroupTestelseactions); builder.AddEdge(conditionGroupTestelseactions, sendActivityElse); - builder.AddEdge(conditionGroupTestPost, endAll); + builder.AddEdge(conditionGroupTestPost, activityFinal); builder.AddEdge(conditionItemOddPost, conditionGroupTestPost); builder.AddEdge(sendActivityOdd, conditionItemOddactionsPost); builder.AddEdge(conditionItemOddactionsPost, conditionItemOddPost); @@ -169,4 +190,4 @@ public static class WorkflowProvider // Build the workflow return builder.Build(validateOrphans: false); } -} +} \ No newline at end of file diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/ConditionElse.yaml b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/ConditionElse.yaml index 667720a913..b527c7c3f2 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/ConditionElse.yaml +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/ConditionElse.yaml @@ -24,5 +24,6 @@ trigger: id: sendActivity_else activity: EVEN - - kind: EndConversation - id: end_all + - kind: SendActivity + id: activity_final + activity: All done! diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/ConditionFallThrough.yaml b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/ConditionFallThrough.yaml new file mode 100644 index 0000000000..0633bce8f8 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/ConditionFallThrough.yaml @@ -0,0 +1,25 @@ +kind: Workflow +trigger: + + kind: OnConversationStart + id: my_workflow + actions: + + - kind: SetVariable + id: setVariable_test + variable: Local.TestValue + value: =Value(System.LastMessageText) + + - kind: ConditionGroup + id: conditionGroup_test + conditions: + - id: conditionItem_odd + condition: =Mod(Local.TestValue, 2) = 1 + actions: + - kind: SendActivity + id: sendActivity_odd + activity: ODD + + - kind: SendActivity + id: activity_final + activity: All done! diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/EndWorkflow.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/EndWorkflow.cs new file mode 100644 index 0000000000..d17ddeb3a6 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/EndWorkflow.cs @@ -0,0 +1,94 @@ +// ------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// ------------------------------------------------------------------------------ + +#nullable enable +#pragma warning disable IDE0005 // Extra using directive is ok. + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Agents.AI.Workflows.Declarative; +using Microsoft.Agents.AI.Workflows.Declarative.Kit; +using Microsoft.Extensions.AI; + +namespace Test.WorkflowProviders; + +/// +/// This class provides a factory method to create a instance. +/// +/// +/// The workflow defined here was generated from a declarative workflow definition. +/// Declarative workflows utilize Power FX for defining conditions and expressions. +/// To learn more about Power FX, see: +/// https://learn.microsoft.com/power-platform/power-fx/formula-reference-copilot-studio +/// +public static class WorkflowProvider +{ + /// + /// The root executor for a declarative workflow. + /// + internal sealed class MyWorkflowRootExecutor( + DeclarativeWorkflowOptions options, + Func inputTransform) : + RootExecutor("my_workflow_Root", options, inputTransform) + where TInput : notnull + { + protected override async ValueTask ExecuteAsync(TInput message, IWorkflowContext context, CancellationToken cancellationToken) + { + } + } + + /// + /// Formats a message template and sends an activity event. + /// + internal sealed class SendActivity1Executor(FormulaSession session) : ActionExecutor(id: "send_activity_1", session) + { + // + protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) + { + string activityText = + await context.FormatTemplateAsync( + """ + NEVER 1! + """ + ); + AgentRunResponse response = new([new ChatMessage(ChatRole.Assistant, activityText)]); + await context.AddEventAsync(new AgentRunResponseEvent(this.Id, response)).ConfigureAwait(false); + + return default; + } + } + + public static Workflow CreateWorkflow( + DeclarativeWorkflowOptions options, + Func? inputTransform = null) + where TInput : notnull + { + // Create root executor to initialize the workflow. + inputTransform ??= (message) => DeclarativeWorkflowBuilder.DefaultTransform(message); + MyWorkflowRootExecutor myWorkflowRoot = new(options, inputTransform); + DelegateExecutor myWorkflow = new(id: "my_workflow", myWorkflowRoot.Session); + DelegateExecutor endAll = new(id: "end_all", myWorkflowRoot.Session); + DelegateExecutor endAllRestart = new(id: "end_all_Restart", myWorkflowRoot.Session); + SendActivity1Executor sendActivity1 = new(myWorkflowRoot.Session); + + // Define the workflow builder + WorkflowBuilder builder = new(myWorkflowRoot); + + // Connect executors + builder.AddEdge(myWorkflowRoot, myWorkflow); + builder.AddEdge(myWorkflow, endAll); + builder.AddEdge(endAllRestart, sendActivity1); + + // Build the workflow + return builder.Build(validateOrphans: false); + } +} \ No newline at end of file diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/EndDialog.yaml b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/EndWorkflow.yaml similarity index 88% rename from dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/EndDialog.yaml rename to dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/EndWorkflow.yaml index e883b609d8..3aa0937667 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/EndDialog.yaml +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/EndWorkflow.yaml @@ -5,7 +5,7 @@ trigger: id: my_workflow actions: - - kind: EndDialog + - kind: EndWorkflow id: end_all - kind: SendActivity diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/InvokeAgent.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/InvokeAgent.cs index efb5b29719..08d324cbcd 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/InvokeAgent.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/InvokeAgent.cs @@ -68,7 +68,6 @@ public static class WorkflowProvider string? conversationId = await context.ReadStateAsync(key: "ConversationId", scopeName: "System").ConfigureAwait(false); bool autoSend = true; - string? additionalInstructions = null; IList? inputMessages = await context.EvaluateListAsync("[UserMessage(System.LastMessageText)]").ConfigureAwait(false); AgentRunResponse agentResponse = @@ -77,7 +76,6 @@ public static class WorkflowProvider agentName, conversationId, autoSend, - additionalInstructions, inputMessages, cancellationToken).ConfigureAwait(false); diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/InProcessExecutionTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/InProcessExecutionTests.cs index ac4f8a5d95..cffdb8c73c 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/InProcessExecutionTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/InProcessExecutionTests.cs @@ -66,7 +66,7 @@ public class InProcessExecutionTests messageSent.Should().BeTrue("TurnToken should be accepted"); // Collect events - List events = new(); + List events = []; await foreach (WorkflowEvent evt in run.WatchStreamAsync()) { events.Add(evt); diff --git a/python/CHANGELOG.md b/python/CHANGELOG.md index 3c914b9fcb..0edfd01c42 100644 --- a/python/CHANGELOG.md +++ b/python/CHANGELOG.md @@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.0.0b251114] - 2025-11-14 + +### Added + +- **samples**: Bing Custom Search sample using `HostedWebSearchTool` ([#2226](https://github.com/microsoft/agent-framework/pull/2226)) +- **samples**: Fabric and Browser Automation samples ([#2207](https://github.com/microsoft/agent-framework/pull/2207)) +- **samples**: Hosted agent samples ([#2205](https://github.com/microsoft/agent-framework/pull/2205)) +- **samples**: Azure OpenAI Responses API Hosted MCP sample ([#2108](https://github.com/microsoft/agent-framework/pull/2108)) +- **samples**: Bing Grounding and Custom Search samples ([#2200](https://github.com/microsoft/agent-framework/pull/2200)) + +### Changed + +- **agent-framework-azure-ai**: Enhance Azure AI Search citations with complete URL information ([#2066](https://github.com/microsoft/agent-framework/pull/2066)) +- **agent-framework-azurefunctions**: Update samples to latest stable Azure Functions Worker packages ([#2189](https://github.com/microsoft/agent-framework/pull/2189)) +- **agent-framework-azure-ai**: Agent name now required for `AzureAIClient` ([#2198](https://github.com/microsoft/agent-framework/pull/2198)) +- **build**: Use `uv build` for packaging ([#2161](https://github.com/microsoft/agent-framework/pull/2161)) +- **tooling**: Pre-commit improvements ([#2222](https://github.com/microsoft/agent-framework/pull/2222)) +- **dependencies**: Updated package versions ([#2208](https://github.com/microsoft/agent-framework/pull/2208)) + +### Fixed + +- **agent-framework-core**: Prevent duplicate MCP tools and prompts ([#1876](https://github.com/microsoft/agent-framework/pull/1876)) ([#1890](https://github.com/microsoft/agent-framework/pull/1890)) +- **agent-framework-devui**: Fix HIL regression ([#2167](https://github.com/microsoft/agent-framework/pull/2167)) +- **agent-framework-chatkit**: ChatKit sample fixes ([#2174](https://github.com/microsoft/agent-framework/pull/2174)) + ## [1.0.0b251112.post1] - 2025-11-12 ### Added @@ -229,7 +254,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.0b251112.post1...HEAD +[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251114...HEAD +[1.0.0b251114]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251112.post1...python-1.0.0b251114 [1.0.0b251112.post1]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251112...python-1.0.0b251112.post1 [1.0.0b251112]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251111...python-1.0.0b251112 [1.0.0b251111]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251108...python-1.0.0b251111 diff --git a/python/packages/a2a/pyproject.toml b/python/packages/a2a/pyproject.toml index b7d1fb840f..d186fc0f7b 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.0b251112.post1" +version = "1.0.0b251114" 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/ag-ui/pyproject.toml b/python/packages/ag-ui/pyproject.toml index 95b0f46bc3..9b31801054 100644 --- a/python/packages/ag-ui/pyproject.toml +++ b/python/packages/ag-ui/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "agent-framework-ag-ui" -version = "1.0.0b251112.post1" +version = "1.0.0b251114" description = "AG-UI protocol integration for Agent Framework" readme = "README.md" license-files = ["LICENSE"] diff --git a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py index 303ea0ee20..96a70bc4a0 100644 --- a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py +++ b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py @@ -18,6 +18,7 @@ from agent_framework import ( FunctionCallContent, FunctionResultContent, HostedCodeInterpreterTool, + HostedFileContent, HostedMCPTool, HostedWebSearchTool, Role, @@ -122,6 +123,7 @@ class AnthropicClient(BaseChatClient): api_key: str | None = None, model_id: str | None = None, anthropic_client: AsyncAnthropic | None = None, + additional_beta_flags: list[str] | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, **kwargs: Any, @@ -134,6 +136,8 @@ class AnthropicClient(BaseChatClient): 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. + additional_beta_flags: Additional beta flags to enable on the client. + Default flags are: "mcp-client-2025-04-04", "code-execution-2025-08-25". 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. @@ -196,6 +200,7 @@ class AnthropicClient(BaseChatClient): # Initialize instance variables self.anthropic_client = anthropic_client + self.additional_beta_flags = additional_beta_flags or [] 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 @@ -246,12 +251,16 @@ class AnthropicClient(BaseChatClient): Returns: A dictionary of run options for the Anthropic client. """ + if chat_options.additional_properties and "additional_beta_flags" in chat_options.additional_properties: + betas = chat_options.additional_properties.pop("additional_beta_flags") + else: + betas = [] 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, + "betas": {*BETA_FLAGS, *self.additional_beta_flags, *betas}, } # Add any additional options from chat_options or kwargs @@ -396,7 +405,7 @@ class AnthropicClient(BaseChatClient): case HostedCodeInterpreterTool(): code_tool: dict[str, Any] = { "type": "code_execution_20250825", - "name": "code_interpreter", + "name": "code_execution", } tool_list.append(code_tool) case HostedMCPTool(): @@ -524,17 +533,7 @@ class AnthropicClient(BaseChatClient): 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": + case "tool_use" | "mcp_tool_use" | "server_tool_use": self._last_call_id_name = (content_block.id, content_block.name) contents.append( FunctionCallContent( @@ -572,6 +571,19 @@ class AnthropicClient(BaseChatClient): | "text_editor_code_execution_tool_result" ): call_id, name = self._last_call_id_name or (None, None) + if ( + content_block.content + and ( + content_block.content.type == "bash_code_execution_result" + or content_block.content.type == "code_execution_result" + ) + and content_block.content.content + ): + for result_content in content_block.content.content: + if hasattr(result_content, "file_id"): + contents.append( + HostedFileContent(file_id=result_content.file_id, raw_representation=result_content) + ) contents.append( FunctionResultContent( call_id=content_block.tool_use_id, diff --git a/python/packages/anthropic/pyproject.toml b/python/packages/anthropic/pyproject.toml index d7d8f9bd35..cad575a287 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.0b251112.post1" +version = "1.0.0b251114" 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/tests/test_anthropic_client.py b/python/packages/anthropic/tests/test_anthropic_client.py index 11498bfe59..fa6061a998 100644 --- a/python/packages/anthropic/tests/test_anthropic_client.py +++ b/python/packages/anthropic/tests/test_anthropic_client.py @@ -50,7 +50,9 @@ def create_test_anthropic_client( ) -> 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") + anthropic_settings = AnthropicSettings( + api_key="test-api-key-12345", chat_model_id="claude-3-5-sonnet-20241022", env_file_path="test.env" + ) # Create client instance directly client = object.__new__(AnthropicClient) @@ -61,6 +63,7 @@ def create_test_anthropic_client( client._last_call_id_name = None client.additional_properties = {} client.middleware = None + client.additional_beta_flags = [] return client @@ -70,7 +73,7 @@ def create_test_anthropic_client( def test_anthropic_settings_init(anthropic_unit_test_env: dict[str, str]) -> None: """Test AnthropicSettings initialization.""" - settings = AnthropicSettings() + settings = AnthropicSettings(env_file_path="test.env") assert settings.api_key is not None assert settings.api_key.get_secret_value() == anthropic_unit_test_env["ANTHROPIC_API_KEY"] @@ -80,8 +83,7 @@ def test_anthropic_settings_init(anthropic_unit_test_env: dict[str, str]) -> Non 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", + api_key="custom-api-key", chat_model_id="claude-3-opus-20240229", env_file_path="test.env" ) assert settings.api_key is not None @@ -114,6 +116,7 @@ def test_anthropic_client_init_auto_create_client(anthropic_unit_test_env: dict[ client = AnthropicClient( api_key=anthropic_unit_test_env["ANTHROPIC_API_KEY"], model_id=anthropic_unit_test_env["ANTHROPIC_CHAT_MODEL_ID"], + env_file_path="test.env", ) assert client.anthropic_client is not None @@ -307,7 +310,7 @@ def test_convert_tools_to_anthropic_format_code_interpreter(mock_anthropic_clien assert "tools" in result assert len(result["tools"]) == 1 assert result["tools"][0]["type"] == "code_execution_20250825" - assert result["tools"][0]["name"] == "code_interpreter" + assert result["tools"][0]["name"] == "code_execution" def test_convert_tools_to_anthropic_format_mcp_tool(mock_anthropic_client: MagicMock) -> None: @@ -725,6 +728,32 @@ async def test_anthropic_client_integration_function_calling() -> None: assert has_function_call +@pytest.mark.flaky +@skip_if_anthropic_integration_tests_disabled +async def test_anthropic_client_integration_hosted_tools() -> None: + """Integration test for hosted tools.""" + client = AnthropicClient() + + messages = [ChatMessage(role=Role.USER, text="What tools do you have available?")] + tools = [ + HostedWebSearchTool(), + HostedCodeInterpreterTool(), + HostedMCPTool( + name="example-mcp", + url="https://learn.microsoft.com/api/mcp", + approval_mode="never_require", + ), + ] + + response = await client.get_response( + messages=messages, + chat_options=ChatOptions(tools=tools, max_tokens=100), + ) + + assert response is not None + assert response.text is not None + + @pytest.mark.flaky @skip_if_anthropic_integration_tests_disabled async def test_anthropic_client_integration_with_system_message() -> None: diff --git a/python/packages/azure-ai/pyproject.toml b/python/packages/azure-ai/pyproject.toml index b2b8db8f8b..6272e6fb41 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.0b251112.post1" +version = "1.0.0b251114" 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/azurefunctions/pyproject.toml b/python/packages/azurefunctions/pyproject.toml index a77fd4225c..07695c24d3 100644 --- a/python/packages/azurefunctions/pyproject.toml +++ b/python/packages/azurefunctions/pyproject.toml @@ -4,7 +4,7 @@ description = "Azure Functions integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b251112.post1" +version = "1.0.0b251114" 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/chatkit/pyproject.toml b/python/packages/chatkit/pyproject.toml index bec671e599..c743c81789 100644 --- a/python/packages/chatkit/pyproject.toml +++ b/python/packages/chatkit/pyproject.toml @@ -4,7 +4,7 @@ description = "OpenAI ChatKit integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b251112.post1" +version = "1.0.0b251114" 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 83902be5d7..c689a3b0d1 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.0b251112.post1" +version = "1.0.0b251114" 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 9a8b1051ad..8f19719c59 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.0b251112.post1" +version = "1.0.0b251114" 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 162aa5b0a3..f261dbef60 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.0b251112.post1" +version = "1.0.0b251114" 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 78244d7d35..7043ec1d87 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.0b251112.post1" +version = "1.0.0b251114" 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 71b6a9800e..2f9f91cad3 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.0b251112.post1" +version = "1.0.0b251114" 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 f0b75a1f6e..4566b6633e 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.0b251112.post1" +version = "1.0.0b251114" 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 299548a29d..0a57bd8b22 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.0b251112.post1" +version = "1.0.0b251114" 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 8e35ff288d..92a7d525ed 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.0b251112.post1" +version = "1.0.0b251114" 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/samples/demos/hosted_agents/agent_with_hosted_mcp/.env.example b/python/samples/demos/hosted_agents/agent_with_hosted_mcp/.env.example deleted file mode 100644 index 4a1424ba25..0000000000 --- a/python/samples/demos/hosted_agents/agent_with_hosted_mcp/.env.example +++ /dev/null @@ -1,2 +0,0 @@ -OPENAI_CHAT_MODEL_ID="gpt-4o-2024-08-06" -OPENAI_API_KEY="your-openai-api-key" diff --git a/python/samples/demos/hosted_agents/agent_with_hosted_mcp/README.md b/python/samples/demos/hosted_agents/agent_with_hosted_mcp/README.md deleted file mode 100644 index 6c7bff0e2b..0000000000 --- a/python/samples/demos/hosted_agents/agent_with_hosted_mcp/README.md +++ /dev/null @@ -1,47 +0,0 @@ -# Hosted Agents with Hosted MCP Demo - -This demo showcases an agent that has access to a MCP tool that can talk to the Microsoft Learn documentation platform, hosted as an agent endpoint running locally in a Docker container. - -## What the Project Does - -This project demonstrates how to: - -- Create an agent with a hosted MCP tool using the Agent Framework -- Host the agent as an agent endpoint running in a Docker container - -## Prerequisites - -- OpenAI API access and credentials -- Required environment variables (see Configuration section) - -## Configuration - -Follow the `.env.example` file to set up the necessary environment variables for OpenAI. - -## Docker Deployment - -Build and run using Docker: - -```bash -# Build the Docker image -docker build -t hosted-agent-mcp . - -# Run the container -docker run -p 8088:8088 hosted-agent-mcp -``` - -> If you update the environment variables in the `.env` file or change the code or the dockerfile, make sure to rebuild the Docker image to apply the changes. - -## Testing the Agent - -Once the agent is running, you can test it by sending queries that contain the trigger keywords. For example: - -```bash -curl -sS -H "Content-Type: application/json" -X POST http://localhost:8088/responses -d '{"input": "How to create an Azure storage account using az cli?","stream":false}' -``` - -Expected response: - -```bash -{"object":"response","metadata":{},"agent":null,"conversation":{"id":"conv_6Y7osWAQ1ASyUZ7Ze0LL6dgPubmQv52jHb7G9QDqpV5yakc3ay"},"type":"message","role":"assistant","temperature":1.0,"top_p":1.0,"user":"","id":"resp_Vfd6mdmnmTZ2RNirwfldfqldWLhaxD6fO2UkXsVUg1jYJgftL9","created_at":1763075575,"output":[{"id":"msg_6Y7osWAQ1ASyUZ7Ze0PwiK2V4Bb7NOPaaEpQoBvFRZ5h6OfW4u","type":"message","status":"completed","role":"assistant","content":[{"type":"output_text","text":"To create an Azure Storage account using the Azure CLI, you'll need to follow these steps:\n\n1. **Install Azure CLI**: Make sure the Azure CLI is installed on your machine. You can download it from [here](https://docs.microsoft.com/en-us/cli/azure/install-azure-cli).\n\n2. **Log in to Azure**: Open your terminal or command prompt and use the following command to log in to your Azure account:\n\n ```bash\n az login\n ```\n\n This command will open a web browser where you can log in with your Azure account credentials. If you're using a service principal, you would use `az login --service-principal ...` with the appropriate parameters.\n\n3. **Select the Subscription**: If you have multiple Azure subscriptions, set the default subscription that you want to use:\n\n ```bash\n az account set --subscription \"Your Subscription Name\"\n ```\n\n4. **Create a Resource Group**: If you don’t already have a resource group, create one using:\n\n ```bash\n az group create --name myResourceGroup --location eastus\n ```\n\n Replace `myResourceGroup` and `eastus` with your desired resource group name and location.\n\n5. **Create the Storage Account**: Use the following command to create the storage account:\n\n ```bash\n az storage account create --name mystorageaccount --resource-group myResourceGroup --location eastus --sku Standard_LRS\n ```\n\n Replace `mystorageaccount` with a unique name for your storage account. The storage account name must be between 3 and 24 characters in length, and may contain numbers and lowercase letters only. You can also choose other `--sku` options like `Standard_GRS`, `Standard_RAGRS`, `Standard_ZRS`, `Premium_LRS`, based on your redundancy and performance needs.\n\nBy following these steps, you'll create a new Azure Storage account in the specified resource group and location with the specified SKU.","annotations":[],"logprobs":[]}]}],"parallel_tool_calls":true,"status":"completed"} -``` diff --git a/python/samples/demos/hosted_agents/agent_with_hosted_mcp/agent.yaml b/python/samples/demos/hosted_agents/agent_with_hosted_mcp/agent.yaml new file mode 100644 index 0000000000..5a0f58554d --- /dev/null +++ b/python/samples/demos/hosted_agents/agent_with_hosted_mcp/agent.yaml @@ -0,0 +1,30 @@ +# Unique identifier/name for this agent +name: agent-with-hosted-mcp +# Brief description of what this agent does +description: > + An AI agent that uses Azure OpenAI with a Hosted Model Context Protocol (MCP) server. + The agent answers questions by searching Microsoft Learn documentation using MCP tools. +metadata: + # Categorization tags for organizing and discovering agents + authors: + - Microsoft Agent Framework Team + tags: + - Azure AI AgentServer + - Microsoft Agent Framework + - Model Context Protocol + - MCP +template: + name: agent-with-hosted-mcp + # The type of agent - "hosted" for HOBO, "container" for COBO + kind: hosted + protocols: + - protocol: responses + environment_variables: + - name: AZURE_OPENAI_ENDPOINT + value: ${AZURE_OPENAI_ENDPOINT} + - name: AZURE_OPENAI_CHAT_DEPLOYMENT_NAME + value: "{{chat}}" +resources: + - kind: model + id: gpt-4o-mini + name: chat diff --git a/python/samples/demos/hosted_agents/agent_with_hosted_mcp/main.py b/python/samples/demos/hosted_agents/agent_with_hosted_mcp/main.py index cb8c626d32..f64a128e77 100644 --- a/python/samples/demos/hosted_agents/agent_with_hosted_mcp/main.py +++ b/python/samples/demos/hosted_agents/agent_with_hosted_mcp/main.py @@ -1,14 +1,14 @@ # Copyright (c) Microsoft. All rights reserved. - from agent_framework import HostedMCPTool -from agent_framework.openai import OpenAIChatClient +from agent_framework.azure import AzureOpenAIChatClient from azure.ai.agentserver.agentframework import from_agent_framework # pyright: ignore[reportUnknownVariableType] +from azure.identity import DefaultAzureCredential def main(): - # Create an Agent using the OpenAI Chat Client with a MCP Tool that connects to Microsoft Learn MCP - agent = OpenAIChatClient().create_agent( + # Create an Agent using the Azure OpenAI Chat Client with a MCP Tool that connects to Microsoft Learn MCP + agent = AzureOpenAIChatClient(credential=DefaultAzureCredential()).create_agent( name="DocsAgent", instructions="You are a helpful assistant that can help with microsoft documentation questions.", tools=HostedMCPTool( diff --git a/python/samples/demos/hosted_agents/agent_with_text_search_rag/.env.example b/python/samples/demos/hosted_agents/agent_with_text_search_rag/.env.example deleted file mode 100644 index 4a1424ba25..0000000000 --- a/python/samples/demos/hosted_agents/agent_with_text_search_rag/.env.example +++ /dev/null @@ -1,2 +0,0 @@ -OPENAI_CHAT_MODEL_ID="gpt-4o-2024-08-06" -OPENAI_API_KEY="your-openai-api-key" diff --git a/python/samples/demos/hosted_agents/agent_with_text_search_rag/README.md b/python/samples/demos/hosted_agents/agent_with_text_search_rag/README.md deleted file mode 100644 index fde8d2c729..0000000000 --- a/python/samples/demos/hosted_agents/agent_with_text_search_rag/README.md +++ /dev/null @@ -1,54 +0,0 @@ -# Hosted Agents with Text Search RAG Demo - -This demo showcases an agent that uses Retrieval-Augmented Generation (RAG) with text search capabilities that will be hosted as an agent endpoint running locally in a Docker container. - -## What the Project Does - -This project demonstrates how to: - -- Build a customer support agent using the Agent Framework -- Implement a custom `TextSearchContextProvider` that simulates document retrieval -- Host the agent as an agent endpoint running in a Docker container - -The agent responds to customer inquiries about: - -- **Return & Refund Policies** - Triggered by keywords: "return", "refund" -- **Shipping Information** - Triggered by keyword: "shipping" -- **Product Care Instructions** - Triggered by keywords: "tent", "fabric" - -## Prerequisites - -- OpenAI API access and credentials -- Required environment variables (see Configuration section) - -## Configuration - -Follow the `.env.example` file to set up the necessary environment variables for OpenAI. - -## Docker Deployment - -Build and run using Docker: - -```bash -# Build the Docker image -docker build -t hosted-agent-rag . - -# Run the container -docker run -p 8088:8088 hosted-agent-rag -``` - -> If you update the environment variables in the `.env` file or change the code or the dockerfile, make sure to rebuild the Docker image to apply the changes. - -## Testing the Agent - -Once the agent is running, you can test it by sending queries that contain the trigger keywords. For example: - -```bash -curl -sS -H "Content-Type: application/json" -X POST http://localhost:8088/responses -d '{"input": "What is the return policy","stream":false}' -``` - -Expected response: - -```bash -{"object":"response","metadata":{},"agent":null,"conversation":{"id":"conv_2GbSxDpJJ89B6N4FQkKhrHaz78Hjtxy9b30JEPuY9YFjJM0uw3"},"type":"message","role":"assistant","temperature":1.0,"top_p":1.0,"user":"","id":"resp_Bvffxq0iIzlVkx2I8x7hV4fglm9RBPWfMCpNtEpDT6ciV2IG6z","created_at":1763071467,"output":[{"id":"msg_2GbSxDpJJ89B6N4FQknLsnxkwwFS2FULJqRV9jMey2BOXljqUz","type":"message","status":"completed","role":"assistant","content":[{"type":"output_text","text":"As of the most recent update, Contoso Outdoors' return policy allows customers to return products within 30 days of purchase for a full refund or exchange, provided the items are in their original condition and packaging. However, make sure to check your purchase receipt or the company's website for the most updated and specific details, as policies can vary by location and may change over time.","annotations":[],"logprobs":[]}]}],"parallel_tool_calls":true,"status":"completed"} -``` diff --git a/python/samples/demos/hosted_agents/agent_with_text_search_rag/agent.yaml b/python/samples/demos/hosted_agents/agent_with_text_search_rag/agent.yaml new file mode 100644 index 0000000000..1e23818b0f --- /dev/null +++ b/python/samples/demos/hosted_agents/agent_with_text_search_rag/agent.yaml @@ -0,0 +1,33 @@ +# Unique identifier/name for this agent +name: agent-with-text-search-rag +# Brief description of what this agent does +description: > + An AI agent that uses a ContextProvider for retrieval augmented generation (RAG) capabilities. + The agent runs searches against an external knowledge base before each model invocation and + injects the results into the model context. It can answer questions about Contoso Outdoors + policies and products, including return policies, refunds, shipping options, and product care + instructions such as tent maintenance. +metadata: + # Categorization tags for organizing and discovering agents + authors: + - Microsoft Agent Framework Team + tags: + - Azure AI AgentServer + - Microsoft Agent Framework + - Retrieval-Augmented Generation + - RAG +template: + name: agent-with-text-search-rag + # The type of agent - "hosted" for HOBO, "container" for COBO + kind: hosted + protocols: + - protocol: responses + environment_variables: + - name: AZURE_OPENAI_ENDPOINT + value: ${AZURE_OPENAI_ENDPOINT} + - name: AZURE_OPENAI_CHAT_DEPLOYMENT_NAME + value: "{{chat}}" +resources: + - kind: model + id: gpt-4o-mini + name: chat diff --git a/python/samples/demos/hosted_agents/agent_with_text_search_rag/main.py b/python/samples/demos/hosted_agents/agent_with_text_search_rag/main.py index 14b418361e..143a3226b6 100644 --- a/python/samples/demos/hosted_agents/agent_with_text_search_rag/main.py +++ b/python/samples/demos/hosted_agents/agent_with_text_search_rag/main.py @@ -7,8 +7,9 @@ from dataclasses import dataclass from typing import Any from agent_framework import ChatMessage, Context, ContextProvider, Role -from agent_framework.openai import OpenAIChatClient +from agent_framework.azure import AzureOpenAIChatClient from azure.ai.agentserver.agentframework import from_agent_framework # pyright: ignore[reportUnknownVariableType] +from azure.identity import DefaultAzureCredential if sys.version_info >= (3, 12): from typing import override @@ -91,8 +92,8 @@ class TextSearchContextProvider(ContextProvider): def main(): - # Create an Agent using the OpenAI Chat Client - agent = OpenAIChatClient().create_agent( + # Create an Agent using the Azure OpenAI Chat Client + agent = AzureOpenAIChatClient(credential=DefaultAzureCredential()).create_agent( name="SupportSpecialist", instructions=( "You are a helpful support specialist for Contoso Outdoors. " diff --git a/python/samples/demos/hosted_agents/agents_in_workflow/.env.example b/python/samples/demos/hosted_agents/agents_in_workflow/.env.example deleted file mode 100644 index 4a1424ba25..0000000000 --- a/python/samples/demos/hosted_agents/agents_in_workflow/.env.example +++ /dev/null @@ -1,2 +0,0 @@ -OPENAI_CHAT_MODEL_ID="gpt-4o-2024-08-06" -OPENAI_API_KEY="your-openai-api-key" diff --git a/python/samples/demos/hosted_agents/agents_in_workflow/README.md b/python/samples/demos/hosted_agents/agents_in_workflow/README.md deleted file mode 100644 index 5f8cc8e993..0000000000 --- a/python/samples/demos/hosted_agents/agents_in_workflow/README.md +++ /dev/null @@ -1,49 +0,0 @@ -# Hosted Workflow Agents Demo - -This demo showcases an agent that is backed by a workflow of multiple agents running concurrently, hosted as an agent endpoint in a Docker container. - -## What the Project Does - -This project demonstrates how to: - -- Build a workflow of agents using the Agent Framework -- Host the workflow agent as an agent endpoint running in a Docker container - -The agent responds to product launch strategy inquiries by concurrently leveraging insights from three specialized agents: - -- **Researcher Agent** - Provides market research insights -- **Marketer Agent** - Crafts marketing value propositions and messaging -- **Legal Agent** - Reviews for compliance and legal considerations - -## Prerequisites - -- OpenAI API access and credentials -- Required environment variables (see Configuration section) - -## Configuration - -Follow the `.env.example` file to set up the necessary environment variables for OpenAI. - -## Docker Deployment - -Build and run using Docker: - -```bash -# Build the Docker image -docker build -t hosted-agent-workflow . - -# Run the container -docker run -p 8088:8088 hosted-agent-workflow -``` - -> If you update the environment variables in the `.env` file or change the code or the dockerfile, make sure to rebuild the Docker image to apply the changes. - -## Testing the Agent - -Once the agent is running, you can test it by sending queries that contain the trigger keywords. For example: - -```bash -curl -sS -H "Content-Type: application/json" -X POST http://localhost:8088/responses -d '{"input": "We are launching a new budget-friendly electric bike for urban commuters.","stream":false}' -``` - -> Expected response is not shown here for brevity. The response will include insights from the researcher, marketer, and legal agents based on the input prompt. diff --git a/python/samples/demos/hosted_agents/agents_in_workflow/agent.yaml b/python/samples/demos/hosted_agents/agents_in_workflow/agent.yaml new file mode 100644 index 0000000000..584b462a40 --- /dev/null +++ b/python/samples/demos/hosted_agents/agents_in_workflow/agent.yaml @@ -0,0 +1,28 @@ +# Unique identifier/name for this agent +name: agents-in-workflow +# Brief description of what this agent does +description: > + A workflow agent that responds to product launch strategy inquiries by concurrently leveraging insights from three specialized agents. +metadata: + # Categorization tags for organizing and discovering agents + authors: + - Microsoft Agent Framework Team + tags: + - Azure AI AgentServer + - Microsoft Agent Framework + - Workflows +template: + name: agents-in-workflow + # The type of agent - "hosted" for HOBO, "container" for COBO + kind: hosted + protocols: + - protocol: responses + environment_variables: + - name: AZURE_OPENAI_ENDPOINT + value: ${AZURE_OPENAI_ENDPOINT} + - name: AZURE_OPENAI_CHAT_DEPLOYMENT_NAME + value: "{{chat}}" +resources: + - kind: model + id: gpt-4o-mini + name: chat diff --git a/python/samples/demos/hosted_agents/agents_in_workflow/main.py b/python/samples/demos/hosted_agents/agents_in_workflow/main.py index 57622a0eaf..2f6bd55ab5 100644 --- a/python/samples/demos/hosted_agents/agents_in_workflow/main.py +++ b/python/samples/demos/hosted_agents/agents_in_workflow/main.py @@ -1,27 +1,28 @@ # Copyright (c) Microsoft. All rights reserved. from agent_framework import ConcurrentBuilder -from agent_framework.openai import OpenAIChatClient -from azure.ai.agentserver.agentframework import from_agent_framework # pyright: ignore[reportUnknownVariableType] +from agent_framework.azure import AzureOpenAIChatClient +from azure.ai.agentserver.agentframework import from_agent_framework +from azure.identity import DefaultAzureCredential # pyright: ignore[reportUnknownVariableType] def main(): # Create agents - researcher = OpenAIChatClient().create_agent( + researcher = AzureOpenAIChatClient(credential=DefaultAzureCredential()).create_agent( instructions=( "You're an expert market and product researcher. " "Given a prompt, provide concise, factual insights, opportunities, and risks." ), name="researcher", ) - marketer = OpenAIChatClient().create_agent( + marketer = AzureOpenAIChatClient(credential=DefaultAzureCredential()).create_agent( instructions=( "You're a creative marketing strategist. " "Craft compelling value propositions and target messaging aligned to the prompt." ), name="marketer", ) - legal = OpenAIChatClient().create_agent( + legal = AzureOpenAIChatClient(credential=DefaultAzureCredential()).create_agent( instructions=( "You're a cautious legal/compliance reviewer. " "Highlight constraints, disclaimers, and policy concerns based on the prompt." diff --git a/python/samples/getting_started/agents/anthropic/README.md b/python/samples/getting_started/agents/anthropic/README.md index c0d15c3e02..a4f76d7225 100644 --- a/python/samples/getting_started/agents/anthropic/README.md +++ b/python/samples/getting_started/agents/anthropic/README.md @@ -8,6 +8,7 @@ This folder contains examples demonstrating how to use Anthropic's Claude models |------|-------------| | [`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`. | +| [`anthropic_skills.py`](anthropic_skills.py) | Illustrates how to use Anthropic-managed Skills with an agent, including the Code Interpreter tool and file generation and saving. | ## Environment Variables diff --git a/python/samples/getting_started/agents/anthropic/anthropic_skills.py b/python/samples/getting_started/agents/anthropic/anthropic_skills.py new file mode 100644 index 0000000000..331b6405fb --- /dev/null +++ b/python/samples/getting_started/agents/anthropic/anthropic_skills.py @@ -0,0 +1,88 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +import logging +from pathlib import Path + +from agent_framework import HostedCodeInterpreterTool, HostedFileContent +from agent_framework.anthropic import AnthropicClient + +logger = logging.getLogger(__name__) +""" +Anthropic Skills Agent Example + +This sample demonstrates using Anthropic with: +- Listing and using Anthropic-managed Skills. +- One approach to add additional beta flags. + You can also set additonal_chat_options with "additional_beta_flags" per request. +- Creating an agent with the Code Interpreter tool and a Skill. +- Catching and downloading generated files from the agent. +""" + + +async def main() -> None: + """Example of streaming response (get results as they are generated).""" + client = AnthropicClient(additional_beta_flags=["skills-2025-10-02"]) + + # List Anthropic-managed Skills + skills = await client.anthropic_client.beta.skills.list(source="anthropic", betas=["skills-2025-10-02"]) + for skill in skills.data: + print(f"{skill.source}: {skill.id} (version: {skill.latest_version})") + + # Create a agent with the pptx skill enabled + # Skills also need the code interpreter tool to function + agent = client.create_agent( + name="DocsAgent", + instructions="You are a helpful agent for creating powerpoint presentations.", + tools=HostedCodeInterpreterTool(), + max_tokens=20000, + additional_chat_options={ + "thinking": {"type": "enabled", "budget_tokens": 10000}, + "container": {"skills": [{"type": "anthropic", "skill_id": "pptx", "version": "latest"}]}, + }, + ) + + print( + "The agent output will use the following colors:\n" + "\033[0mUser: (default)\033[0m\n" + "\033[0mAgent: (default)\033[0m\n" + "\033[32mAgent Reasoning: (green)\033[0m\n" + "\033[34mUsage: (blue)\033[0m\n" + ) + query = "Create a presentation about renewable energy with 5 slides" + print(f"User: {query}") + print("Agent: ", end="", flush=True) + files: list[HostedFileContent] = [] + async for chunk in agent.run_stream(query): + for content in chunk.contents: + match content.type: + case "text": + print(content.text, end="", flush=True) + case "text_reasoning": + print(f"\033[32m{content.text}\033[0m", end="", flush=True) + case "usage": + print(f"\n\033[34m[Usage so far: {content.details}]\033[0m\n", end="", flush=True) + case "hosted_file": + # Catch generated files + files.append(content) + case _: + logger.debug("Unhandled content type: %s", content.type) + pass + + print("\n") + if files: + # Save to a new file (will be in the folder where you are running this script) + # When running this sample multiple times, the files will be overritten + # Since I'm using the pptx skill, the files will be PowerPoint presentations + print("Generated files:") + for idx, file in enumerate(files): + file_content = await client.anthropic_client.beta.files.download( + file_id=file.file_id, betas=["files-api-2025-04-14"] + ) + with open(Path(__file__).parent / f"renewable_energy-{idx}.pptx", "wb") as f: + await file_content.write_to_file(f.name) + print(f"File {idx}: renewable_energy-{idx}.pptx saved to disk.") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/uv.lock b/python/uv.lock index 8855f2a583..f0657d5f68 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -82,7 +82,7 @@ wheels = [ [[package]] name = "agent-framework" -version = "1.0.0b251112.post1" +version = "1.0.0b251114" source = { virtual = "." } dependencies = [ { name = "agent-framework-a2a", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -175,7 +175,7 @@ docs = [ [[package]] name = "agent-framework-a2a" -version = "1.0.0b251112.post1" +version = "1.0.0b251114" source = { editable = "packages/a2a" } dependencies = [ { name = "a2a-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -190,7 +190,7 @@ requires-dist = [ [[package]] name = "agent-framework-ag-ui" -version = "1.0.0b251112.post1" +version = "1.0.0b251114" source = { editable = "packages/ag-ui" } dependencies = [ { name = "ag-ui-protocol", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -220,7 +220,7 @@ provides-extras = ["dev"] [[package]] name = "agent-framework-anthropic" -version = "1.0.0b251112.post1" +version = "1.0.0b251114" source = { editable = "packages/anthropic" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -235,7 +235,7 @@ requires-dist = [ [[package]] name = "agent-framework-azure-ai" -version = "1.0.0b251112.post1" +version = "1.0.0b251114" source = { editable = "packages/azure-ai" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -254,7 +254,7 @@ requires-dist = [ [[package]] name = "agent-framework-azurefunctions" -version = "1.0.0b251112.post1" +version = "1.0.0b251114" source = { editable = "packages/azurefunctions" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -271,7 +271,7 @@ requires-dist = [ [[package]] name = "agent-framework-chatkit" -version = "1.0.0b251112.post1" +version = "1.0.0b251114" source = { editable = "packages/chatkit" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -286,7 +286,7 @@ requires-dist = [ [[package]] name = "agent-framework-copilotstudio" -version = "1.0.0b251112.post1" +version = "1.0.0b251114" source = { editable = "packages/copilotstudio" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -301,7 +301,7 @@ requires-dist = [ [[package]] name = "agent-framework-core" -version = "1.0.0b251112.post1" +version = "1.0.0b251114" source = { editable = "packages/core" } dependencies = [ { name = "azure-identity", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -357,7 +357,7 @@ provides-extras = ["all"] [[package]] name = "agent-framework-devui" -version = "1.0.0b251112.post1" +version = "1.0.0b251114" source = { editable = "packages/devui" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -391,7 +391,7 @@ provides-extras = ["dev", "all"] [[package]] name = "agent-framework-lab" -version = "1.0.0b251112.post1" +version = "1.0.0b251114" source = { editable = "packages/lab" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -482,7 +482,7 @@ dev = [ [[package]] name = "agent-framework-mem0" -version = "1.0.0b251112.post1" +version = "1.0.0b251114" source = { editable = "packages/mem0" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -497,7 +497,7 @@ requires-dist = [ [[package]] name = "agent-framework-purview" -version = "1.0.0b251112.post1" +version = "1.0.0b251114" source = { editable = "packages/purview" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -514,7 +514,7 @@ requires-dist = [ [[package]] name = "agent-framework-redis" -version = "1.0.0b251112.post1" +version = "1.0.0b251114" source = { editable = "packages/redis" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -1729,7 +1729,7 @@ name = "exceptiongroup" version = "1.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform == 'win32')" }, + { name = "typing-extensions", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } wheels = [ diff --git a/workflow-samples/CustomerSupport.yaml b/workflow-samples/CustomerSupport.yaml new file mode 100644 index 0000000000..62ce67c651 --- /dev/null +++ b/workflow-samples/CustomerSupport.yaml @@ -0,0 +1,164 @@ +# +# This workflow demonstrates using multiple agents to provide automated +# troubleshooting steps to resolve common issues with escalation options. +# +# Example input: +# My PC keeps rebooting and I can't use it. +# +kind: Workflow +trigger: + + kind: OnConversationStart + id: workflow_demo + actions: + + # Interact with user until the issue has been resolved or + # a determination is made that a ticket is required. + - kind: InvokeAzureAgent + id: service_agent + conversationId: =System.ConversationId + agent: + name: SelfServiceAgent + input: + externalLoop: + when: |- + =Not(Local.ServiceParameters.IsResolved) + And + Not(Local.ServiceParameters.NeedsTicket) + output: + responseObject: Local.ServiceParameters + + # All done if issue is resolved. + - kind: ConditionGroup + id: check_if_resolved + conditions: + + - condition: =Local.ServiceParameters.IsResolved + id: test_if_resolved + actions: + - kind: GotoAction + id: end_when_resolved + actionId: all_done + + # Create the ticket. + - kind: InvokeAzureAgent + id: ticket_agent + agent: + name: TicketingAgent + input: + arguments: + IssueDescription: =Local.ServiceParameters.IssueDescription + AttemptedResolutionSteps: =Local.ServiceParameters.AttemptedResolutionSteps + output: + responseObject: Local.TicketParameters + + # Capture the attempted resolution steps. + - kind: SetVariable + id: capture_attempted_resolution + variable: Local.ResolutionSteps + value: =Local.ServiceParameters.AttemptedResolutionSteps + + # Notify user of ticket identifier. + - kind: SendActivity + id: log_ticket + activity: "Created ticket #{Local.TicketParameters.TicketId}" + + # Determine which team for which route the ticket. + - kind: InvokeAzureAgent + id: routing_agent + agent: + name: TicketRoutingAgent + input: + messages: =UserMessage(Local.ServiceParameters.IssueDescription) + output: + responseObject: Local.RoutingParameters + + # Notify user of routing decision. + - kind: SendActivity + id: log_route + activity: Routing to {Local.RoutingParameters.TeamName} + + - kind: ConditionGroup + id: check_routing + conditions: + + - condition: =Local.RoutingParameters.TeamName = "Windows Support" + id: route_to_support + actions: + + # Invoke the support agent to attempt to resolve the issue. + - kind: CreateConversation + id: conversation_support + conversationId: Local.SupportConversationId + + - kind: InvokeAzureAgent + id: support_agent + conversationId: =Local.SupportConversationId + agent: + name: WindowsSupportAgent + input: + arguments: + IssueDescription: =Local.ServiceParameters.IssueDescription + AttemptedResolutionSteps: =Local.ServiceParameters.AttemptedResolutionSteps + externalLoop: + when: |- + =Not(Local.SupportParameters.IsResolved) + And + Not(Local.SupportParameters.NeedsEscalation) + output: + autoSend: true + responseObject: Local.SupportParameters + + # Capture the attempted resolution steps. + - kind: SetVariable + id: capture_support_resolution + variable: Local.ResolutionSteps + value: =Local.SupportParameters.ResolutionSummary + + # Check if the issue was resolved by support. + - kind: ConditionGroup + id: check_resolved + conditions: + + # Resolve ticket + - condition: =Local.SupportParameters.IsResolved + id: handle_if_resolved + actions: + + - kind: InvokeAzureAgent + id: resolution_agent + agent: + name: TicketResolutionAgent + input: + arguments: + TicketId: =Local.TicketParameters.TicketId + ResolutionSummary: =Local.SupportParameters.ResolutionSummary + + - kind: GotoAction + id: end_when_solved + actionId: all_done + + # Escalate the ticket by sending an email notification. + - kind: CreateConversation + id: conversation_escalate + conversationId: Local.EscalationConversationId + + - kind: InvokeAzureAgent + id: escalate_agent + conversationId: =Local.EscalationConversationId + agent: + name: TicketEscalationAgent + input: + arguments: + TicketId: =Local.TicketParameters.TicketId + IssueDescription: =Local.ServiceParameters.IssueDescription + ResolutionSummary: =Local.ResolutionSteps + externalLoop: + when: =Not(Local.EscalationParameters.IsComplete) + output: + autoSend: true + responseObject: Local.EscalationParameters + + # All done + - kind: EndWorkflow + id: all_done diff --git a/workflow-samples/DeepResearch.yaml b/workflow-samples/DeepResearch.yaml index be84e92bfb..4949680c56 100644 --- a/workflow-samples/DeepResearch.yaml +++ b/workflow-samples/DeepResearch.yaml @@ -2,33 +2,18 @@ # This workflow coordinates multiple agents in order to address complex user requests # according to the "Magentic" orchestration pattern introduced by AutoGen. # -# For this workflow, several agents used, each with a prompt specific to their role: +# For this workflow, several agents used, each with specific roles. # -# 1. Analyst Agent: Able to analyze the current task. -# Enable "Bing Grounding Tool" in the agent settings. -# See: ./setup/AnalystAgent.yaml -# -# 2. Manager Agent: Able to create plans and delegate tasks to other agents. -# See: ./setup/ManagerAgent.yaml -# -# 3. Research Agent: -# Enable "Bing Grounding" in the agent settings. -# See: ./setup/WebAgent.yaml +# The following agents are responsible for overseeing and coordinating the workflow: +# - Research Agent: Analyze the current task and correlate relevant facts. +# - Planner Agent: Analyze the current task and devise an overall plan. +# - Manager Agent: Evaluates status and delegate tasks to other agents. +# - Summary Agent: Evaluates status and delegate tasks to other agents. # -# With instructions: -# -# Only provide requested information in a way that is throughfully organized and formatted. -# Never include any analysis or code. -# Never generate a file. -# Avoid repeating yourself. -# -# 4. Coder Agent: -# Enable "Code Interpreter" in the agent settings. -# See: ./setup/CoderAgent.yaml -# -# 5. Weather Agent: Able to retrieve factual information from the web. -# Enable "Open API" in the agent settings using the wttr.json schema. -# See: ./setup/WeatherAgent.yaml +# The following agents have capabilities that are utilized to address the input task: +# - Knowledge Agent: Performs generic web searches. +# - Coder Agent: Able to write and execute code. +# - Weather Agent: Provides weather information. # kind: Workflow trigger: @@ -45,18 +30,15 @@ trigger: =[ { name: "WeatherAgent", - description: "Able to retrieve weather information", - agentid: Env.FOUNDRY_AGENT_RESEARCHWEATHER + description: "Able to retrieve weather information" }, { name: "CoderAgent", - description: "Able to write and execute Python code", - agentid: Env.FOUNDRY_AGENT_RESEARCHCODER + description: "Able to write and execute Python code" }, { - name: "WebAgent", - description: "Able to perform generic websearches", - agentid: Env.FOUNDRY_AGENT_RESEARCHWEB + name: "KnowledgeAgent", + description: "Able to perform generic websearches" } ] @@ -84,38 +66,22 @@ trigger: - kind: CreateConversation id: conversation_1a2b3c - conversationId: Local.InternalConversationId + conversationId: Local.StatusConversationId + + - kind: CreateConversation + id: conversation_1x2y3z + conversationId: Local.TaskConversationId - kind: InvokeAzureAgent id: question_UDoMUw displayName: Get Facts - conversationId: =Local.InternalConversationId + conversationId: =Local.StatusConversationId agent: - name: =Env.FOUNDRY_AGENT_RESEARCHANALYST + name: ResearchAgent output: - autoSend: false messages: Local.TaskFacts input: messages: =UserMessage(Local.InputTask) - additionalInstructions: |- - In order to help begin addressing the user request, please answer the following pre-survey to the best of your ability. - Keep in mind that you are Ken Jennings-level with trivia, and Mensa-level with puzzles, so there should be a deep well to draw from. - - Here is the pre-survey: - - 1. Please list any specific facts or figures that are GIVEN in the request itself. It is possible that there are none. - 2. Please list any facts that may need to be looked up, and WHERE SPECIFICALLY they might be found. In some cases, authoritative sources are mentioned in the request itself. - 3. Please list any facts that may need to be derived (e.g., via logical deduction, simulation, or computation) - 4. Please list any facts that are recalled from memory, hunches, well-reasoned guesses, etc. - - When answering this survey, keep in mind that 'facts' will typically be specific names, dates, statistics, etc. Your answer must only use the headings: - - 1. GIVEN OR VERIFIED FACTS - 2. FACTS TO LOOK UP - 3. FACTS TO DERIVE - 4. EDUCATED GUESSES - - DO NOT include any other headings or sections in your response. DO NOT list next steps or plans until asked to do so. - kind: SendActivity id: sendActivity_yFsbRz @@ -124,52 +90,42 @@ trigger: - kind: InvokeAzureAgent id: question_DsBaJU displayName: Create a Plan - conversationId: =Local.InternalConversationId + conversationId: =Local.StatusConversationId agent: - name: =Env.FOUNDRY_AGENT_RESEARCHMANAGER + name: PlannerAgent + inputs: + arguments: + team: =Local.TeamDescription output: - autoSend: false messages: Local.Plan - input: - messages: =UserMessage(Local.InputTask) - additionalInstructions: |- - Your only job is to devise an efficient plan that identifies (by name) how a team member may contribute to addressing the user request. - Only select the following team which is listed as "- [Name]: [Description]" - - {Local.TeamDescription} - - The plan must be a bullet point list must be in the form "- [AgentName]: [Specific action or task for that agent to perform]" - - Remember, there is no requirement to involve the entire team -- only select team member's whose particular expertise is required for this task. - - - kind: SetVariable + - kind: SetTextVariable id: setVariable_Kk2LDL displayName: Define instructions variable: Local.TaskInstructions value: |- - ="# TASK + # TASK Address the following user request: - " & Local.InputTask & " + {Local.InputTask} # TEAM Use the following team to answer this request: - " & Local.TeamDescription & " + {Local.TeamDescription} # FACTS Consider this initial fact sheet: - " & Trim(Last(Local.TaskFacts).Text) & " + {MessageText(Local.TaskFacts)} # PLAN Here is the plan to follow as best as possible: - " & Last(Local.Plan).Text + {MessageText(Local.Plan)} - kind: SendActivity id: sendActivity_bwNZiM @@ -178,131 +134,41 @@ trigger: - kind: InvokeAzureAgent id: question_o3BQkf displayName: Progress Ledger Prompt - conversationId: =Local.InternalConversationId + conversationId: =Local.StatusConversationId agent: - name: =Env.FOUNDRY_AGENT_RESEARCHMANAGER - output: - autoSend: false - messages: Local.ProgressLedgerUpdate + name: ManagerAgent input: messages: =UserMessage(Local.AgentResponseText) - additionalInstructions: |- - Recall we are working on the following request: - - {Local.InputTask} - - And we have assembled the following team: - - {Local.TeamDescription} - - To make progress on the request, please answer the following questions, including necessary reasoning: - - - Is the request fully satisfied? (True if complete, or False if the original request has yet to be SUCCESSFULLY and FULLY addressed) - - Are we in a loop where we are repeating the same requests and / or getting the same responses from an agent multiple times? Loops can span multiple turns, and can include repeated actions like scrolling up or down more than a handful of times. - - Are we making forward progress? (True if just starting, or recent messages are adding value. False if recent messages show evidence of being stuck in a loop or if there is evidence of significant barriers to success such as the inability to read from a required file) - - Who should speak next? (select from: {Concat(Local.AvailableAgents, name, ",")}) - - What instruction or question would you give this team member? (Phrase as if speaking directly to them, and include any specific information they may need) - - Please output an answer in pure JSON format according to the following schema. The JSON object must be parsable as-is. DO NOT OUTPUT ANYTHING OTHER THAN JSON, AND DO NOT DEVIATE FROM THIS SCHEMA: - - {{ - "is_request_satisfied": {{ - "reason": string, - "answer": boolean - }}, - "is_in_loop": {{ - "reason": string, - "answer": boolean - }}, - "is_progress_being_made": {{ - "reason": string, - "answer": boolean - }}, - "next_speaker": {{ - "reason": string, - "answer": string (select from: {Concat(Local.AvailableAgents, name, ",")}) - }}, - "instruction_or_question": {{ - "reason": string, - "answer": string - }} - }} - - - kind: ParseValue - id: parse_rNZtlV - displayName: Parse ledger response - variable: Local.TypedProgressLedger - value: =Last(Local.ProgressLedgerUpdate).Text - valueType: - kind: Record - properties: - instruction_or_question: - type: - kind: Record - properties: - answer: String - reason: String - - is_in_loop: - type: - kind: Record - properties: - answer: Boolean - reason: String - - is_progress_being_made: - type: - kind: Record - properties: - answer: Boolean - reason: String - - is_request_satisfied: - type: - kind: Record - properties: - answer: Boolean - reason: String - - next_speaker: - type: - kind: Record - properties: - answer: String - reason: String + output: + responseObject: Local.ProgressLedger - kind: ConditionGroup id: conditionGroup_mVIecC conditions: - id: conditionItem_fj432c - condition: =Local.TypedProgressLedger.is_request_satisfied.answer + condition: =Local.ProgressLedger.is_request_satisfied.answer displayName: If Done actions: - kind: SendActivity id: sendActivity_kdl3mC - activity: Completed! {Local.TypedProgressLedger.is_request_satisfied.reason} + activity: Completed! {Local.ProgressLedger.is_request_satisfied.reason} - kind: InvokeAzureAgent id: question_Ke3l1d displayName: Generate Response - conversationId: =System.ConversationId + conversationId: =Local.TaskConversationId agent: - name: =Env.FOUNDRY_AGENT_RESEARCHMANAGER + name: SummaryAgent output: + autoSend: true messages: Local.FinalResponse - input: - messages: =Local.SeedTask - additionalInstructions: |- - We have completed the task. - Based only on the conversation and without adding any new information, synthesize the result of the conversation as a complete response to the user task. - The user will only every see this last response and not the entire conversation, so please ensure it is complete and self-contained. - kind: EndConversation id: end_SVoNSV - id: conditionItem_yiqund - condition: =Local.TypedProgressLedger.is_in_loop.answer || Not(Local.TypedProgressLedger.is_progress_being_made.answer) + condition: =Local.ProgressLedger.is_in_loop.answer || Not(Local.ProgressLedger.is_progress_being_made.answer) displayName: If Stalling actions: @@ -312,26 +178,25 @@ trigger: variable: Local.StallCount value: =Local.StallCount + 1 - - kind: ConditionGroup id: conditionGroup_vBTQd3 conditions: - id: conditionItem_fpaNL9 - condition: =Local.TypedProgressLedger.is_in_loop.answer + condition: =Local.ProgressLedger.is_in_loop.answer displayName: Is Loop actions: - kind: SendActivity id: sendActivity_fpaNL9 - activity: {Local.TypedProgressLedger.is_in_loop.reason} + activity: {Local.ProgressLedger.is_in_loop.reason} - id: conditionItem_NnqvXh - condition: =Not(Local.TypedProgressLedger.is_progress_being_made.answer) + condition: =Not(Local.ProgressLedger.is_progress_being_made.answer) displayName: Is No Progress actions: - kind: SendActivity id: sendActivity_NnqvXh - activity: {Local.TypedProgressLedger.is_progress_being_made.reason} + activity: {Local.ProgressLedger.is_progress_being_made.reason} - kind: ConditionGroup @@ -366,28 +231,23 @@ trigger: - kind: InvokeAzureAgent id: question_wFJ123 displayName: Get New Facts Prompt - conversationId: =Local.InternalConversationId + conversationId: =Local.StatusConversationId agent: - name: =Env.FOUNDRY_AGENT_RESEARCHANALYST + name: ResearchAgent output: - autoSend: false messages: Local.TaskFacts input: messages: |- =UserMessage( - "As a reminder, we are working to solve the following task: + "It's clear we aren't making as much progress as we would like, but we may have learned something new. + Please rewrite the following fact sheet, updating it to include anything new we have learned that may be helpful. + Example edits can include (but are not limited to) adding new guesses, moving educated guesses to verified facts if appropriate, etc. + Updates may be made to any section of the fact sheet, and more than one section of the fact sheet can be edited. + This is an especially good time to update educated guesses, so please at least add or update one educated guess or hunch, and explain your reasoning. - " & Local.InputTask) - additionalInstructions: |- - It's clear we aren't making as much progress as we would like, but we may have learned something new. - Please rewrite the following fact sheet, updating it to include anything new we have learned that may be helpful. - Example edits can include (but are not limited to) adding new guesses, moving educated guesses to verified facts if appropriate, etc. - Updates may be made to any section of the fact sheet, and more than one section of the fact sheet can be edited. - This is an especially good time to update educated guesses, so please at least add or update one educated guess or hunch, and explain your reasoning. + Here is the old fact sheet: - Here is the old fact sheet: - - {Local.TaskFacts} + {MessageText(Local.TaskFacts)}" - kind: SendActivity id: sendActivity_dsBaJU @@ -396,48 +256,48 @@ trigger: - kind: InvokeAzureAgent id: question_uEJ456 displayName: Create new Plan Prompt - conversationId: =Local.InternalConversationId + conversationId: =Local.StatusConversationId agent: - name: =Env.FOUNDRY_AGENT_RESEARCHMANAGER + name: PlannerAgent output: - autoSend: false messages: Local.Plan input: - additionalInstructions: |- - Please briefly explain what went wrong on this last run (the root cause of the failure), - and then come up with a new plan that takes steps and/or includes hints to overcome prior challenges and especially avoids repeating the same mistakes. - As before, the new plan should be concise, be expressed in bullet-point form, and consider the following team composition - (do not involve any other outside people since we cannot contact anyone else): + messages: |- + =UserMessage( + "Please briefly explain what went wrong on this last run (the root cause of the failure), + and then come up with a new plan that takes steps and/or includes hints to overcome prior challenges and especially avoids repeating the same mistakes. + As before, the new plan should be concise, be expressed in bullet-point form, and consider the following team composition + (do not involve any other outside people since we cannot contact anyone else): - {Local.TeamDescription} + {Local.TeamDescription}") - - kind: SetVariable + - kind: SetTextVariable id: setVariable_jW7tmM displayName: Set Plan as Context variable: Local.TaskInstructions value: |- - ="# TASK + # TASK Address the following user request: - " & Local.InputTask & " + {Local.InputTask} # TEAM Use the following team to answer this request: - " & Local.TeamDescription & " + {Local.TeamDescription} # FACTS Consider this initial fact sheet: - " & Local.TaskFacts.Text & " + {MessageText(Local.TaskFacts)} # PLAN Here is the plan to follow as best as possible: - - " & Local.Plan.Text + + {MessageText(Local.Plan)} - kind: SetVariable id: setVariable_6J2snP @@ -459,19 +319,14 @@ trigger: - kind: SendActivity id: sendActivity_L7ooQO activity: |- - ({Local.TypedProgressLedger.next_speaker.reason}) + ({Local.ProgressLedger.next_speaker.reason}) - {Local.TypedProgressLedger.next_speaker.answer} - {Local.TypedProgressLedger.instruction_or_question.answer} - - - kind: SetVariable - id: setVariable_L7ooQO - variable: Local.StallCount - value: 0 + {Local.ProgressLedger.next_speaker.answer} - {Local.ProgressLedger.instruction_or_question.answer} - kind: SetVariable id: setVariable_nxN1mE variable: Local.NextSpeaker - value: =Search(Local.AvailableAgents, Local.TypedProgressLedger.next_speaker.answer, name) + value: =Search(Local.AvailableAgents, Local.ProgressLedger.next_speaker.answer, name) - kind: ConditionGroup id: conditionGroup_QFPiF5 @@ -480,24 +335,28 @@ trigger: condition: =CountRows(Local.NextSpeaker) = 1 displayName: If next Agent tool Exists actions: + + - kind: SetVariable + id: setVariable_L7ooQO + variable: Local.StallCount + value: 0 - kind: InvokeAzureAgent id: question_orsBf06 displayName: Progress Ledger Prompt - conversationId: =System.ConversationId + conversationId: =Local.TaskConversationId agent: - name: =First(Local.NextSpeaker).agentid + name: =First(Local.NextSpeaker).name output: + autoSend: true messages: Local.AgentResponse input: - messages: =Local.SeedTask - additionalInstructions: |- - {Local.TypedProgressLedger.instruction_or_question.answer} + messages: =UserMessage(Local.ProgressLedger.instruction_or_question.answer) - kind: SetVariable id: setVariable_XzNrdM variable: Local.AgentResponseText - value: =Last(Local.AgentResponse).Text + value: =MessageText(Local.AgentResponse) - kind: ResetVariable id: setVariable_8eIx2A diff --git a/workflow-samples/Marketing.yaml b/workflow-samples/Marketing.yaml index 2bdd9f3c4a..9fcafa717d 100644 --- a/workflow-samples/Marketing.yaml +++ b/workflow-samples/Marketing.yaml @@ -4,9 +4,6 @@ # Example input: # An eco-friendly stainless steel water bottle that keeps drinks cold for 24 hours. # -# Any Foundry Agent may be used to provide the response. -# See: ./setup/QuestionAgent.yaml -# kind: Workflow trigger: @@ -18,31 +15,16 @@ trigger: id: invoke_analyst conversationId: =System.ConversationId agent: - name: =Env.FOUNDRY_AGENT_ANSWER - input: - additionalInstructions: |- - You are a marketing analyst. Given a product description, identify: - - Key features - - Target audience - - Unique selling points + name: AnalystAgent - kind: InvokeAzureAgent id: invoke_writer conversationId: =System.ConversationId agent: - name: =Env.FOUNDRY_AGENT_ANSWER - input: - additionalInstructions: |- - You are a marketing copywriter. Given a block of text describing features, audience, and USPs, - compose a compelling marketing copy (like a newsletter section) that highlights these points. - Output should be short (around 150 words), output just the copy as a single text block. + name: WriterAgent - kind: InvokeAzureAgent id: invoke_editor conversationId: =System.ConversationId agent: - name: =Env.FOUNDRY_AGENT_ANSWER - input: - additionalInstructions: |- - You are an editor. Given the draft copy, correct grammar, improve clarity, ensure consistent tone, - give format and make it polished. Output the final improved copy as a single text block. \ No newline at end of file + name: EditorAgent diff --git a/workflow-samples/MathChat.yaml b/workflow-samples/MathChat.yaml index b3673e9baa..363256efc3 100644 --- a/workflow-samples/MathChat.yaml +++ b/workflow-samples/MathChat.yaml @@ -2,27 +2,8 @@ # This workflow demonstrates conversation between two agents: a student and a teacher. # The student attempts to solve the input problem and the teacher provides guidance. # -# For this workflow, two agents are used, each with a prompt specific to their role. -# -# Student: -# See: ./setup/StudentAgent.yaml -# -# With instructions: -# -# Your job is help a math teacher practice teaching by making intentional mistakes. -# You Attempt to solve the given math problem, but with intentional mistakes so the teacher can help. -# Always incorporate the teacher's advice to fix your next response. -# You have the math-skills of a 6th grader. - -# Teacher: -# See: ./setup/TeacherAgent.yaml -# -# With instructions: -# -# Review and coach the student's approach to solving the given math problem. -# Don't repeat the solution or try and solve it. -# If the student has demonstrated comprehension and responded to all of your feedback, -# give the student your congratulations by using the word "congratulations". +# Example input: +# How would you compute the value of PI? # kind: Workflow trigger: @@ -35,13 +16,13 @@ trigger: id: question_student conversationId: =System.ConversationId agent: - name: =Env.FOUNDRY_AGENT_STUDENT + name: StudentAgent - kind: InvokeAzureAgent id: question_teacher conversationId: =System.ConversationId agent: - name: =Env.FOUNDRY_AGENT_TEACHER + name: TeacherAgent output: messages: Local.TeacherResponse @@ -54,7 +35,7 @@ trigger: id: check_completion conditions: - - condition: =!IsBlank(Find("CONGRATULATIONS", Upper(Last(Local.TeacherResponse).Text))) + - condition: =!IsBlank(Find("CONGRATULATIONS", Upper(MessageText(Local.TeacherResponse)))) id: check_turn_done actions: diff --git a/workflow-samples/README.md b/workflow-samples/README.md index 438561f0a8..a7bed697e5 100644 --- a/workflow-samples/README.md +++ b/workflow-samples/README.md @@ -1,28 +1,17 @@ # Declarative Workflows -This folder contains sample workflow definitions than be ran using the -[Declarative Workflow](../dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteWorkflow) demo. +A _Declarative Workflow_ is defined as a single YAML file and +may be executed locally no different from any regular `Workflow` that is defined by code. -Each workflow is defined in a single YAML file and contains -comments with additional information specific to that workflow. - -A _Declarative Workflow_ may be executed locally no different from any `Workflow` defined by code. -The difference is that the workflow definition is loaded from a YAML file instead of being defined in code. +The difference is that the workflow definition is loaded from a YAML file instead of being defined in code: ```c# -Workflow workflow = DeclarativeWorkflowBuilder.Build("Marketing.yaml", options); +Workflow workflow = DeclarativeWorkflowBuilder.Build("Marketing.yaml", options); ``` -Workflows may also be hosted in your _Azure Foundry Project_. +These example workflows may be executed by the workflow +[Samples](../dotnet/samples/GettingStarted/Workflows/Declarative) +that are present in this repository. -> _Python_ support in the works! - -#### Agents - -The sample workflows rely on agents defined in your Azure Foundry Project. - -To create agents, run the [`Create.ps1`](./setup) script. -This will create the agents used in the sample workflows in your Azure Foundry Project and format a script you can copy and use to configure your environment. - -> Note: `Create.ps1` relies upon the `FOUNDRY_PROJECT_ENDPOINT`, `FOUNDRY_MODEL_DEPLOYMENT_NAME`, and `FOUNDRY_CONNECTION_GROUNDING_TOOL` settings. -See [README.md](../dotnet/samples/GettingStarted/Workflows/Declarative/README.md) from the demo for configuration details. +> See the [README.md](../dotnet/samples/GettingStarted/Workflows/Declarative/README.md) + associated with the samples for configuration details. diff --git a/workflow-samples/setup/.gitignore b/workflow-samples/setup/.gitignore deleted file mode 100644 index ce1409abe9..0000000000 --- a/workflow-samples/setup/.gitignore +++ /dev/null @@ -1,405 +0,0 @@ -## Ignore Visual Studio temporary files, build results, and -## files generated by popular Visual Studio add-ons. -## -## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore - -# User-specific files -*.rsuser -*.suo -*.user -*.userosscache -*.sln.docstates - -# User-specific files (MonoDevelop/Xamarin Studio) -*.userprefs - -# Mono auto generated files -mono_crash.* - -# Build results -[Dd]ebug/ -[Dd]ebugPublic/ -[Rr]elease/ -[Rr]eleases/ -x64/ -x86/ -[Ww][Ii][Nn]32/ -[Aa][Rr][Mm]/ -[Aa][Rr][Mm]64/ -[Aa][Rr][Mm]64[Ee][Cc]/ -bld/ -[Bb]in/ -[Oo]bj/ -[Ll]og/ -[Ll]ogs/ - -# Visual Studio 2015/2017 cache/options directory -.vs/ -# Uncomment if you have tasks that create the project's static files in wwwroot -#wwwroot/ - -# Visual Studio 2017 auto generated files -Generated\ Files/ - -# MSTest test Results -[Tt]est[Rr]esult*/ -[Bb]uild[Ll]og.* - -# NUnit -*.VisualState.xml -TestResult.xml -nunit-*.xml - -# Build Results of an ATL Project -[Dd]ebugPS/ -[Rr]eleasePS/ -dlldata.c - -# Benchmark Results -BenchmarkDotNet.Artifacts/ - -# .NET Core -project.lock.json -project.fragment.lock.json -artifacts/ - -# ASP.NET Scaffolding -ScaffoldingReadMe.txt - -# StyleCop -StyleCopReport.xml - -# Files built by Visual Studio -*_i.c -*_p.c -*_h.h -*.ilk -*.meta -*.obj -*.iobj -*.pch -*.pdb -*.ipdb -*.pgc -*.pgd -*.rsp -# but not Directory.Build.rsp, as it configures directory-level build defaults -!Directory.Build.rsp -*.sbr -*.tlb -*.tli -*.tlh -*.tmp -*.tmp_proj -*_wpftmp.csproj -*.log -*.tlog -*.vspscc -*.vssscc -.builds -*.pidb -*.svclog -*.scc - -# Chutzpah Test files -_Chutzpah* - -# Visual C++ cache files -ipch/ -*.aps -*.ncb -*.opendb -*.opensdf -*.sdf -*.cachefile -*.VC.db -*.VC.VC.opendb - -# Visual Studio profiler -*.psess -*.vsp -*.vspx -*.sap - -# Visual Studio Trace Files -*.e2e - -# TFS 2012 Local Workspace -$tf/ - -# Guidance Automation Toolkit -*.gpState - -# ReSharper is a .NET coding add-in -_ReSharper*/ -*.[Rr]e[Ss]harper -*.DotSettings.user - -# TeamCity is a build add-in -_TeamCity* - -# DotCover is a Code Coverage Tool -*.dotCover - -# AxoCover is a Code Coverage Tool -.axoCover/* -!.axoCover/settings.json - -# Coverlet is a free, cross platform Code Coverage Tool -coverage*.json -coverage*.xml -coverage*.info - -# Visual Studio code coverage results -*.coverage -*.coveragexml - -# NCrunch -_NCrunch_* -.NCrunch_* -.*crunch*.local.xml -nCrunchTemp_* - -# MightyMoose -*.mm.* -AutoTest.Net/ - -# Web workbench (sass) -.sass-cache/ - -# Installshield output folder -[Ee]xpress/ - -# DocProject is a documentation generator add-in -DocProject/buildhelp/ -DocProject/Help/*.HxT -DocProject/Help/*.HxC -DocProject/Help/*.hhc -DocProject/Help/*.hhk -DocProject/Help/*.hhp -DocProject/Help/Html2 -DocProject/Help/html - -# Click-Once directory -publish/ - -# Publish Web Output -*.[Pp]ublish.xml -*.azurePubxml -# Note: Comment the next line if you want to checkin your web deploy settings, -# but database connection strings (with potential passwords) will be unencrypted -*.pubxml -*.publishproj - -# Microsoft Azure Web App publish settings. Comment the next line if you want to -# checkin your Azure Web App publish settings, but sensitive information contained -# in these scripts will be unencrypted -PublishScripts/ - -# NuGet Packages -*.nupkg -# NuGet Symbol Packages -*.snupkg -# The packages folder can be ignored because of Package Restore -**/[Pp]ackages/* -# except build/, which is used as an MSBuild target. -!**/[Pp]ackages/build/ -# Uncomment if necessary however generally it will be regenerated when needed -#!**/[Pp]ackages/repositories.config -# NuGet v3's project.json files produces more ignorable files -*.nuget.props -*.nuget.targets - -# Microsoft Azure Build Output -csx/ -*.build.csdef - -# Microsoft Azure Emulator -ecf/ -rcf/ - -# Windows Store app package directories and files -AppPackages/ -BundleArtifacts/ -Package.StoreAssociation.xml -_pkginfo.txt -*.appx -*.appxbundle -*.appxupload - -# Visual Studio cache files -# files ending in .cache can be ignored -*.[Cc]ache -# but keep track of directories ending in .cache -!?*.[Cc]ache/ - -# Others -ClientBin/ -~$* -*~ -*.dbmdl -*.dbproj.schemaview -*.jfm -*.pfx -*.publishsettings -orleans.codegen.cs - -# Including strong name files can present a security risk -# (https://github.com/github/gitignore/pull/2483#issue-259490424) -#*.snk - -# Since there are multiple workflows, uncomment next line to ignore bower_components -# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) -#bower_components/ - -# RIA/Silverlight projects -Generated_Code/ - -# Backup & report files from converting an old project file -# to a newer Visual Studio version. Backup files are not needed, -# because we have git ;-) -_UpgradeReport_Files/ -Backup*/ -UpgradeLog*.XML -UpgradeLog*.htm -ServiceFabricBackup/ -*.rptproj.bak - -# SQL Server files -*.mdf -*.ldf -*.ndf - -# Business Intelligence projects -*.rdl.data -*.bim.layout -*.bim_*.settings -*.rptproj.rsuser -*- [Bb]ackup.rdl -*- [Bb]ackup ([0-9]).rdl -*- [Bb]ackup ([0-9][0-9]).rdl - -# Microsoft Fakes -FakesAssemblies/ - -# GhostDoc plugin setting file -*.GhostDoc.xml - -# Node.js Tools for Visual Studio -.ntvs_analysis.dat -node_modules/ - -# Visual Studio 6 build log -*.plg - -# Visual Studio 6 workspace options file -*.opt - -# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) -*.vbw - -# Visual Studio 6 auto-generated project file (contains which files were open etc.) -*.vbp - -# Visual Studio 6 workspace and project file (working project files containing files to include in project) -*.dsw -*.dsp - -# Visual Studio 6 technical files -*.ncb -*.aps - -# Visual Studio LightSwitch build output -**/*.HTMLClient/GeneratedArtifacts -**/*.DesktopClient/GeneratedArtifacts -**/*.DesktopClient/ModelManifest.xml -**/*.Server/GeneratedArtifacts -**/*.Server/ModelManifest.xml -_Pvt_Extensions - -# Paket dependency manager -.paket/paket.exe -paket-files/ - -# FAKE - F# Make -.fake/ - -# CodeRush personal settings -.cr/personal - -# Python Tools for Visual Studio (PTVS) -__pycache__/ -*.pyc - -# Cake - Uncomment if you are using it -# tools/** -# !tools/packages.config - -# Tabs Studio -*.tss - -# Telerik's JustMock configuration file -*.jmconfig - -# BizTalk build output -*.btp.cs -*.btm.cs -*.odx.cs -*.xsd.cs - -# OpenCover UI analysis results -OpenCover/ - -# Azure Stream Analytics local run output -ASALocalRun/ - -# MSBuild Binary and Structured Log -*.binlog - -# AWS SAM Build and Temporary Artifacts folder -.aws-sam - -# NVidia Nsight GPU debugger configuration file -*.nvuser - -# MFractors (Xamarin productivity tool) working folder -.mfractor/ - -# Local History for Visual Studio -.localhistory/ - -# Visual Studio History (VSHistory) files -.vshistory/ - -# BeatPulse healthcheck temp database -healthchecksdb - -# Backup folder for Package Reference Convert tool in Visual Studio 2017 -MigrationBackup/ - -# Ionide (cross platform F# VS Code tools) working folder -.ionide/ - -# Fody - auto-generated XML schema -FodyWeavers.xsd - -# VS Code files for those working on multiple tools -.vscode/* -!.vscode/settings.json -!.vscode/tasks.json -!.vscode/launch.json -!.vscode/extensions.json -*.code-workspace - -# Local History for Visual Studio Code -.history/ - -# Windows Installer files from build outputs -*.cab -*.msi -*.msix -*.msm -*.msp - -# JetBrains Rider -*.sln.iml \ No newline at end of file diff --git a/workflow-samples/setup/AnalystAgent.yaml b/workflow-samples/setup/AnalystAgent.yaml deleted file mode 100644 index dbe6ba4b7a..0000000000 --- a/workflow-samples/setup/AnalystAgent.yaml +++ /dev/null @@ -1,10 +0,0 @@ -type: foundry_agent -name: ResearchAnalyst -description: Demo agent for DeepResearch workflow -model: - id: ${FOUNDRY_MODEL_DEPLOYMENT_NAME} -tools: - - type: bing_grounding - options: - tool_connections: - - ${FOUNDRY_CONNECTION_GROUNDING_TOOL} \ No newline at end of file diff --git a/workflow-samples/setup/CoderAgent.yaml b/workflow-samples/setup/CoderAgent.yaml deleted file mode 100644 index 4d6e06b34c..0000000000 --- a/workflow-samples/setup/CoderAgent.yaml +++ /dev/null @@ -1,7 +0,0 @@ -type: foundry_agent -name: ResearchCoder -description: Demo agent for DeepResearch workflow -model: - id: ${FOUNDRY_MODEL_DEPLOYMENT_NAME} -tools: - - type: code_interpreter diff --git a/workflow-samples/setup/Create.ps1 b/workflow-samples/setup/Create.ps1 deleted file mode 100644 index 17c7f27fd0..0000000000 --- a/workflow-samples/setup/Create.ps1 +++ /dev/null @@ -1,3 +0,0 @@ -pushd ./CreateAgents -dotnet run -popd \ No newline at end of file diff --git a/workflow-samples/setup/CreateAgents/CreateAgents.csproj b/workflow-samples/setup/CreateAgents/CreateAgents.csproj deleted file mode 100644 index 4b00b2279f..0000000000 --- a/workflow-samples/setup/CreateAgents/CreateAgents.csproj +++ /dev/null @@ -1,20 +0,0 @@ - - - - Exe - net9.0 - enable - enable - 5ee045b0-aea3-4f08-8d31-32d1a6f8fed0 - SKEXP0110 - - - - - - - - - - - diff --git a/workflow-samples/setup/CreateAgents/CreateAgents.slnx b/workflow-samples/setup/CreateAgents/CreateAgents.slnx deleted file mode 100644 index 7ff049246b..0000000000 --- a/workflow-samples/setup/CreateAgents/CreateAgents.slnx +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/workflow-samples/setup/CreateAgents/Program.cs b/workflow-samples/setup/CreateAgents/Program.cs deleted file mode 100644 index ebd9243c50..0000000000 --- a/workflow-samples/setup/CreateAgents/Program.cs +++ /dev/null @@ -1,68 +0,0 @@ -using Azure.AI.Agents.Persistent; -using Azure.AI.Projects; -using Azure.Identity; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.SemanticKernel; -using Microsoft.SemanticKernel.Agents; -using Microsoft.SemanticKernel.Agents.AzureAI; -using System.Reflection; -using System.Text; - -// Define FOUNDRY_PROJECT_ENDPOINT as a user-secret or environment variable that -// points to your Foundry project endpoint. - -IConfigurationRoot config = - new ConfigurationBuilder() - .AddUserSecrets(Assembly.GetExecutingAssembly()) - .AddEnvironmentVariables() - .Build(); - -string projectEndpoint = config["FOUNDRY_PROJECT_ENDPOINT"] ?? throw new InvalidOperationException("Undefined configuration: FOUNDRY_PROJECT_ENDPOINT"); -Console.WriteLine($"{Environment.NewLine}Foundry: {projectEndpoint}"); - -StringBuilder scriptBuilder = new(); -StringBuilder secretBuilder = new(); -string[] files = args.Length > 0 ? args : Directory.GetFiles(@"..\", "*.yaml"); -foreach (string file in files) -{ - string agentText = await File.ReadAllTextAsync(file); - - PersistentAgentsClient clientAgents = new(projectEndpoint, new AzureCliCredential()); - - AIProjectClient clientProject = new(new Uri(projectEndpoint), new AzureCliCredential()); - - IKernelBuilder kernelBuilder = Kernel.CreateBuilder(); - kernelBuilder.Services.AddSingleton(clientAgents); - kernelBuilder.Services.AddSingleton(clientProject); - Kernel kernel = kernelBuilder.Build(); - - AzureAIAgentFactory factory = new(); - Agent? agent = await factory.CreateAgentFromYamlAsync(agentText, new AgentCreationOptions() { Kernel = kernel }, config); - if (agent is null) - { - Console.WriteLine("Unexpected failure creating agent..."); - continue; - } - - Console.WriteLine(); - Console.WriteLine(Path.GetFileName(file)); - Console.WriteLine($" Id: {agent?.Id ?? "???"}"); - Console.WriteLine($" Name: {agent?.Name ?? agent?.Id}"); - Console.WriteLine($" Note: {agent?.Description}"); - - scriptBuilder.AppendLine($"$env:FOUNDRY_AGENT_{agent?.Name?.ToUpperInvariant()} = '{agent?.Id}'"); - secretBuilder.AppendLine($"dotnet user-secrets set FOUNDRY_AGENT_{agent?.Name?.ToUpperInvariant()} {agent?.Id}"); -} - -Console.WriteLine(); -Console.WriteLine(); -Console.WriteLine("To set these environment variables in your shell, run:"); -Console.WriteLine(); -Console.WriteLine(scriptBuilder); -Console.WriteLine(); -Console.WriteLine(); -Console.WriteLine("To define user secrets, run:"); -Console.WriteLine(); -Console.WriteLine(secretBuilder); -Console.WriteLine(); diff --git a/workflow-samples/setup/CreateAgents/nuget.config b/workflow-samples/setup/CreateAgents/nuget.config deleted file mode 100644 index d4475cea1b..0000000000 --- a/workflow-samples/setup/CreateAgents/nuget.config +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/workflow-samples/setup/ManagerAgent.yaml b/workflow-samples/setup/ManagerAgent.yaml deleted file mode 100644 index 9402c4b922..0000000000 --- a/workflow-samples/setup/ManagerAgent.yaml +++ /dev/null @@ -1,5 +0,0 @@ -type: foundry_agent -name: ResearchManager -description: Demo agent for DeepResearch workflow -model: - id: ${FOUNDRY_MODEL_DEPLOYMENT_NAME} diff --git a/workflow-samples/setup/QuestionAgent.yaml b/workflow-samples/setup/QuestionAgent.yaml deleted file mode 100644 index 767a656138..0000000000 --- a/workflow-samples/setup/QuestionAgent.yaml +++ /dev/null @@ -1,10 +0,0 @@ -type: foundry_agent -name: Answer -description: Demo agent for Question workflow -model: - id: ${FOUNDRY_MODEL_DEPLOYMENT_NAME} -tools: - - type: bing_grounding - options: - tool_connections: - - ${FOUNDRY_CONNECTION_GROUNDING_TOOL} \ No newline at end of file diff --git a/workflow-samples/setup/README.md b/workflow-samples/setup/README.md deleted file mode 100644 index 2a43e776bb..0000000000 --- a/workflow-samples/setup/README.md +++ /dev/null @@ -1,14 +0,0 @@ -# Agent Definitions - -The sample workflows rely on agents defined in your Azure Foundry Project. - -These agent definitions are based on _Semantic Kernel_'s _Declarative Agent_ feature: - -- [Semantic Kernel Agents](https://github.com/microsoft/semantic-kernel/tree/main/dotnet/src/Agents) -- [Declarative Agent Extensions](https://github.com/microsoft/semantic-kernel/tree/main/dotnet/src/Agents/Yaml) -- [Sample](https://github.com/microsoft/semantic-kernel/blob/main/dotnet/samples/GettingStartedWithAgents/AzureAIAgent/Step08_AzureAIAgent_Declarative.cs) - -To create agents, run the [`Create.ps1`](./Create.ps1) script. -This will create the agents for the sample workflows in your Azure Foundry Project and format a script you can copy and use to configure your environment. - -> Note: `Create.ps1` relies upon the `FOUNDRY_PROJECT_ENDPOINT` setting. See [README.md](../../dotnet/samples/GettingStarted/Workflows/Declarative/README.md) from the demo for configuration details. diff --git a/workflow-samples/setup/StudentAgent.yaml b/workflow-samples/setup/StudentAgent.yaml deleted file mode 100644 index 990befba79..0000000000 --- a/workflow-samples/setup/StudentAgent.yaml +++ /dev/null @@ -1,10 +0,0 @@ -type: foundry_agent -name: Student -description: Student agent for MathChat workflow -instructions: |- - Your job is help a math teacher practice teaching by making intentional mistakes. - You Attempt to solve the given math problem, but with intentional mistakes so the teacher can help. - Always incorporate the teacher's advice to fix your next response. - You have the math-skills of a 6th grader. -model: - id: ${FOUNDRY_MODEL_DEPLOYMENT_NAME} diff --git a/workflow-samples/setup/TeacherAgent.yaml b/workflow-samples/setup/TeacherAgent.yaml deleted file mode 100644 index d120c9cf3c..0000000000 --- a/workflow-samples/setup/TeacherAgent.yaml +++ /dev/null @@ -1,10 +0,0 @@ -type: foundry_agent -name: Teacher -description: Teacher agent for MathChat workflow -instructions: |- - Review and coach the student's approach to solving the given math problem. - Don't repeat the solution or try and solve it. - If the student has demonstrated comprehension and responded to all of your feedback, - give the student your congraluations by using the word "congratulations". -model: - id: ${FOUNDRY_MODEL_DEPLOYMENT_NAME} diff --git a/workflow-samples/setup/WeatherAgent.yaml b/workflow-samples/setup/WeatherAgent.yaml deleted file mode 100644 index ac6b413163..0000000000 --- a/workflow-samples/setup/WeatherAgent.yaml +++ /dev/null @@ -1,62 +0,0 @@ -type: foundry_agent -name: ResearchWeather -description: Demo agent for DeepResearch workflow -model: - id: ${FOUNDRY_MODEL_DEPLOYMENT_NAME} -tools: - - type: openapi - id: GetCurrentWeather - description: Retrieves current weather data for a location based on wttr.in. - options: - specification: | - { - "openapi": "3.1.0", - "info": { - "title": "Get weather data", - "description": "Retrieves current weather data for a location based on wttr.in.", - "version": "v1.0.0" - }, - "servers": [ - { - "url": "https://wttr.in" - } - ], - "paths": { - "/{location}": { - "get": { - "description": "Get weather information for a specific location", - "operationId": "GetCurrentWeather", - "parameters": [ - { - "name": "location", - "in": "path", - "description": "City or location to retrieve the weather for", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Successful response", - "content": { - "text/plain": { - "schema": { - "type": "string" - } - } - } - }, - "404": { - "description": "Location not found" - } - }, - "deprecated": false - } - } - }, - "components": { - "schemas": {} - } - } diff --git a/workflow-samples/setup/WebAgent.yaml b/workflow-samples/setup/WebAgent.yaml deleted file mode 100644 index 283fd3c26c..0000000000 --- a/workflow-samples/setup/WebAgent.yaml +++ /dev/null @@ -1,15 +0,0 @@ -type: foundry_agent -name: ResearchWeb -description: Demo agent for DeepResearch workflow -instructions: |- - Only provide requested information in a way that is throughfully organized and formatted. - Never include any analysis or code. - Never generate a file. - Avoid repeating yourself. -model: - id: ${FOUNDRY_MODEL_DEPLOYMENT_NAME} -tools: - - type: bing_grounding - options: - tool_connections: - - ${FOUNDRY_CONNECTION_GROUNDING_TOOL} \ No newline at end of file