.NET Workflows - WIP Declarative action update (#1761)

* WIP

* Fixed build errors (#1638)

Comment and nullable type alignment

* Sync to SDK update

* Checkpoint

* Checkpoint: Tests passing

* Checkpoint: EndWorkflow

* Add trace

* .NET: Azure.AI.Agents Package Split + Initial Extensions (#1657)

* Move packages

* Update nuget.config

* Address Xmldoc

* Remove format from branches checks

* Address Xmldocs

* Add more details to the implementation

* Moving Agent logic to ChatClient

* Adding Name and Id overrides to AzureAIAgent

* Updating extensions

* Add GetAiAgent extensions

* Adding support for version as name can conflict 409 using the Agents API with same name

* Addressing more updates to the extensions

* More improvements

* Remove debugging code from sample

* Address copilot feedback

* Apply suggestions from co-pilot code review

* Checkpoint

* Update Directory.Packages.props

Fix package version rollback:

Azure.AI.Agents.Persistent (beta-6 => beta-7)

* Add project reference

* .NET: Add comprehensive unit tests for Microsoft.Agents.AI.AzureAIAgents extension methods (#1786)

* Initial plan

* Add comprehensive unit test project for Microsoft.Agents.AI.AzureAIAgents

Co-authored-by: rogerbarreto <19890735+rogerbarreto@users.noreply.github.com>

* Add README documenting test project and package dependency requirements

Co-authored-by: rogerbarreto <19890735+rogerbarreto@users.noreply.github.com>

* Fix documentation URL to use learn.microsoft.com

Co-authored-by: rogerbarreto <19890735+rogerbarreto@users.noreply.github.com>

* Bump back AAAP 1.2.0-beta.7

* Address AI generated UT's

* Remove UT Readme

* Apply suggestions from code review

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: rogerbarreto <19890735+rogerbarreto@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* .NET: Change model to be required just for prompt agent definition specific extensions (#1812)

* Remove unneeded model from extensions

* Add noop justification

* Update Package Nameing: V1 -> AzureAI.Persistent / V2 -> AzureAI (#1829)

* Checkpoint for merge

* No build errors

* .NET: Update Extensions for Strict Agent Definitions + Improvements (#1892)

* Update Package Nameing: V1 -> AzureAI.Persistent / V2 -> AzureAI

* Update agents and extensions to comply with strict agent definitions

* More static updates

* Address UT, and ResponseTool support

* Improving reusability extensions

* Addressing ResponseTools Unit Tests and extension setup

* Adapted workaround on breaking AAA with OpenAI 2.6.0

* Small updates

* Remove strictness when retrieving agents, improved XmlDocs

* Improve sample comments

* Update dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/AgentsClientExtensionsTests.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Apply suggestion from @Copilot

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Apply suggestion from @Copilot

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Address PR comments

* Address UT failing

* Address Copilot feedback

* Address Copilot feedback

* Address comment typo

* Address PR feedback

* Address typo

* Add missing Extensions with ChatClientAgentOptions

* Address comments

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Updated package version (#1897)

* Version update (#1901)

* Checkpoint

* Updated package version (#1906)

* Checkpoint

* Checkpoint

* Checkpoint

* Align with azure ai agent

* Update dotnet/samples/GettingStarted/Workflows/Declarative/StudentTeacher/Program.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update dotnet/samples/GettingStarted/Workflows/Declarative/MCPToolApproval/Program.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update dotnet/samples/GettingStarted/Workflows/Declarative/DeepResearch/Program.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Refactored external input

* Update dotnet/samples/GettingStarted/Workflows/Declarative/MCPToolApproval/Program.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Agent tools patch

* Demos validated

* Checkpoint

* Hygiene

* Checkpoint - Samples

* Update dotnet/samples/GettingStarted/Workflows/Declarative/StudentTeacher/Program.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update dotnet/samples/GettingStarted/Workflows/Declarative/StudentTeacher/Program.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Checkpoint

* Checkpoint - Deep Research

* Update baseline

* Update

* Typo

* Checkpoint

* Typos

* Sample cleanup

* Update dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/AzureAgentProvider.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update dotnet/src/Microsoft.Agents.AI.AzureAI/AgentsClientExtensions.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update dotnet/samples/GettingStarted/Workflows/Declarative/FunctionTools/Program.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update dotnet/samples/GettingStarted/Workflows/Declarative/StudentTeacher/Program.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update dotnet/samples/GettingStarted/Workflows/Declarative/ToolApproval/Program.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update dotnet/samples/GettingStarted/Workflows/Declarative/DeepResearch/Program.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Typo

* Typo

* Fix input loop

* Sample - Function Calling / External Input

* Typo

* Finessed

* Checkpoint

* Fix feed

* Checkpoint - so close

* Ding dong!

* "there" ***

* Fixup comments

* Fix sample

* Code analysis

* Header

* Typo (variableName)

* Remove dead code

* Skip test (agent api ratchet)

* Comment

* Update dotnet/samples/GettingStarted/Workflows/Declarative/StudentTeacher/Program.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Typo

---------

Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com>
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com>
This commit is contained in:
Chris
2025-11-07 11:37:21 -08:00
committed by GitHub
Unverified
parent 7c3d4fcf30
commit 2b869c2396
125 changed files with 3192 additions and 3712 deletions
+4 -6
View File
@@ -94,10 +94,10 @@
<!-- Identity -->
<PackageVersion Include="Microsoft.Identity.Client.Extensions.Msal" Version="4.78.0" />
<!-- Workflows -->
<PackageVersion Include="Microsoft.Bot.ObjectModel" Version="1.2025.1003.2" />
<PackageVersion Include="Microsoft.Bot.ObjectModel.Json" Version="1.2025.1003.2" />
<PackageVersion Include="Microsoft.Bot.ObjectModel.PowerFx" Version="1.2025.1003.2" />
<PackageVersion Include="Microsoft.PowerFx.Interpreter" Version="1.4.0" />
<PackageVersion Include="Microsoft.Bot.ObjectModel" Version="1.2025.1106.1" />
<PackageVersion Include="Microsoft.Bot.ObjectModel.Json" Version="1.2025.1106.1" />
<PackageVersion Include="Microsoft.Bot.ObjectModel.PowerFx" Version="1.2025.1106.1" />
<PackageVersion Include="Microsoft.PowerFx.Interpreter" Version="1.5.0-build.20251008-1002" />
<!-- Community -->
<PackageVersion Include="System.Linq.Async" Version="6.0.3" />
<!-- Test -->
@@ -105,8 +105,6 @@
<PackageVersion Include="Microsoft.AspNetCore.TestHost" Version="9.0.10" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.0.0" />
<PackageVersion Include="Moq" Version="[4.18.4]" />
<PackageVersion Include="Microsoft.SemanticKernel.Agents.Abstractions" Version="1.66.0" />
<PackageVersion Include="Microsoft.SemanticKernel.Agents.Yaml" Version="1.66.0-beta" />
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.abstractions" Version="2.0.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.3" />
+8 -2
View File
@@ -107,13 +107,19 @@
</Folder>
<Folder Name="/Samples/GettingStarted/Workflows/Declarative/">
<File Path="samples/GettingStarted/Workflows/Declarative/README.md" />
<Project Path="samples/GettingStarted/Workflows/Declarative/ConfirmInput/ConfirmInput.csproj" />
<Project Path="samples/GettingStarted/Workflows/Declarative/DeepResearch/DeepResearch.csproj" />
<Project Path="samples/GettingStarted/Workflows/Declarative/ExecuteCode/ExecuteCode.csproj" />
<Project Path="samples/GettingStarted/Workflows/Declarative/ExecuteWorkflow/ExecuteWorkflow.csproj" />
<Project Path="samples/GettingStarted/Workflows/Declarative/FunctionTools/FunctionTools.csproj" />
<Project Path="samples/GettingStarted/Workflows/Declarative/GenerateCode/GenerateCode.csproj" />
<Project Path="samples/GettingStarted/Workflows/Declarative/Marketing/Marketing.csproj" />
<Project Path="samples/GettingStarted/Workflows/Declarative/StudentTeacher/StudentTeacher.csproj" />
<Project Path="samples/GettingStarted/Workflows/Declarative/ToolApproval/ToolApproval.csproj" />
</Folder>
<Folder Name="/Samples/GettingStarted/Workflows/Declarative/Examples/">
<File Path="../workflow-samples/ConfirmInput.yaml" />
<File Path="../workflow-samples/DeepResearch.yaml" />
<File Path="../workflow-samples/HumanInLoop.yaml" />
<File Path="../workflow-samples/Marketing.yaml" />
<File Path="../workflow-samples/MathChat.yaml" />
<File Path="../workflow-samples/README.md" />
@@ -284,8 +290,8 @@
<Project Path="src/Microsoft.Agents.AI.A2A/Microsoft.Agents.AI.A2A.csproj" />
<Project Path="src/Microsoft.Agents.AI.Abstractions/Microsoft.Agents.AI.Abstractions.csproj" />
<Project Path="src/Microsoft.Agents.AI.AGUI/Microsoft.Agents.AI.AGUI.csproj" />
<Project Path="src/Microsoft.Agents.AI.AzureAI/Microsoft.Agents.AI.AzureAI.csproj" />
<Project Path="src/Microsoft.Agents.AI.AzureAI.Persistent/Microsoft.Agents.AI.AzureAI.Persistent.csproj" />
<Project Path="src/Microsoft.Agents.AI.AzureAI/Microsoft.Agents.AI.AzureAI.csproj" />
<Project Path="src/Microsoft.Agents.AI.CopilotStudio/Microsoft.Agents.AI.CopilotStudio.csproj" />
<Project Path="src/Microsoft.Agents.AI.DevUI/Microsoft.Agents.AI.DevUI.csproj" />
<Project Path="src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj" />
+9
View File
@@ -11,4 +11,13 @@
<ItemGroup Condition="'$(InjectSharedBuildTestCode)' == 'true'">
<Compile Include="$(MSBuildThisFileDirectory)\..\..\src\Shared\CodeTests\*.cs" LinkBase="Shared\CodeTests" />
</ItemGroup>
<ItemGroup Condition="'$(InjectSharedWorkflowsExecution)' == 'true'">
<Compile Include="$(MSBuildThisFileDirectory)\..\..\src\Shared\Workflows\Execution\*.cs" LinkBase="Shared\Workflows" />
</ItemGroup>
<ItemGroup Condition="'$(InjectSharedWorkflowsSettings)' == 'true'">
<Compile Include="$(MSBuildThisFileDirectory)\..\..\src\Shared\Workflows\Settings\*.cs" LinkBase="Shared\Workflows" />
</ItemGroup>
<ItemGroup Condition="'$(InjectSharedFoundryAgents)' == 'true'">
<Compile Include="$(MSBuildThisFileDirectory)\..\..\src\Shared\Foundry\Agents\*.cs" LinkBase="Shared\Foundry" />
</ItemGroup>
</Project>
@@ -0,0 +1,39 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ProjectsDebugTargetFrameworks>net9.0</ProjectsDebugTargetFrameworks>
<TargetFrameworks Condition="'$(Configuration)' == 'Debug'">$(ProjectsDebugTargetFrameworks)</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<PropertyGroup>
<InjectIsExternalInitOnLegacy>true</InjectIsExternalInitOnLegacy>
<InjectSharedFoundryAgents>true</InjectSharedFoundryAgents>
<InjectSharedWorkflowsExecution>true</InjectSharedWorkflowsExecution>
<InjectSharedWorkflowsSettings>true</InjectSharedWorkflowsSettings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.Extensions.Logging" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Workflows.Declarative\Microsoft.Agents.AI.Workflows.Declarative.csproj" />
</ItemGroup>
<ItemGroup>
<None Include="$(MSBuildThisFileDirectory)..\..\..\..\..\..\workflow-samples\ConfirmInput.yaml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
@@ -0,0 +1,39 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Extensions.Configuration;
using Shared.Workflows;
namespace Demo.Workflows.Declarative.ConfirmInput;
/// <summary>
/// Demonstrate how to use the question action to request user input
/// and confirm it matches the original input.
/// </summary>
/// <remarks>
/// See the README.md file in the parent folder (../Declarative/README.md) for detailed
/// information the configuration required to run this sample.
/// </remarks>
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);
}
}
@@ -0,0 +1,42 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ProjectsDebugTargetFrameworks>net9.0</ProjectsDebugTargetFrameworks>
<TargetFrameworks Condition="'$(Configuration)' == 'Debug'">$(ProjectsDebugTargetFrameworks)</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<PropertyGroup>
<InjectIsExternalInitOnLegacy>true</InjectIsExternalInitOnLegacy>
<InjectSharedFoundryAgents>true</InjectSharedFoundryAgents>
<InjectSharedWorkflowsExecution>true</InjectSharedWorkflowsExecution>
<InjectSharedWorkflowsSettings>true</InjectSharedWorkflowsSettings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.Extensions.Logging" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Workflows.Declarative\Microsoft.Agents.AI.Workflows.Declarative.csproj" />
</ItemGroup>
<ItemGroup>
<None Include="$(MSBuildThisFileDirectory)..\..\..\..\..\..\workflow-samples\DeepResearch.yaml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Include="wttr.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
@@ -0,0 +1,280 @@
// Copyright (c) Microsoft. All rights reserved.
using Azure.AI.Agents;
using Azure.Identity;
using Microsoft.Extensions.Configuration;
using OpenAI.Responses;
using Shared.Foundry;
using Shared.Workflows;
namespace Demo.Workflows.Declarative.DeepResearch;
/// <summary>
/// Demonstrate a declarative workflow that accomplishes a task
/// using the Magentic orchestration pattern developed by AutoGen.
/// </summary>
/// <remarks>
/// See the README.md file in the parent folder (../Declarative/README.md) for detailed
/// information the configuration required to run this sample.
/// </remarks>
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)
{
AgentsClient agentsClient = new(foundryEndpoint, new AzureCliCredential());
await agentsClient.CreateAgentAsync(
agentName: "ResearchAgent",
agentDefinition: DefineResearchAgent(configuration),
agentDescription: "Planner agent for DeepResearch workflow");
await agentsClient.CreateAgentAsync(
agentName: "PlannerAgent",
agentDefinition: DefinePlannerAgent(configuration),
agentDescription: "Planner agent for DeepResearch workflow");
await agentsClient.CreateAgentAsync(
agentName: "ManagerAgent",
agentDefinition: DefineManagerAgent(configuration),
agentDescription: "Manager agent for DeepResearch workflow");
await agentsClient.CreateAgentAsync(
agentName: "SummaryAgent",
agentDefinition: DefineSummaryAgent(configuration),
agentDescription: "Summary agent for DeepResearch workflow");
await agentsClient.CreateAgentAsync(
agentName: "KnowledgeAgent",
agentDefinition: DefineKnowledgeAgent(configuration),
agentDescription: "Research agent for DeepResearch workflow");
await agentsClient.CreateAgentAsync(
agentName: "CoderAgent",
agentDefinition: DefineCoderAgent(configuration),
agentDescription: "Coder agent for DeepResearch workflow");
await agentsClient.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 OpenApiAnonymousAuthDetails()))
}
};
}
@@ -12,7 +12,9 @@
</PropertyGroup>
<PropertyGroup>
<InjectSharedThrow>true</InjectSharedThrow>
<InjectIsExternalInitOnLegacy>true</InjectIsExternalInitOnLegacy>
<InjectSharedWorkflowsExecution>true</InjectSharedWorkflowsExecution>
<InjectSharedWorkflowsSettings>true</InjectSharedWorkflowsSettings>
</PropertyGroup>
<ItemGroup>
File diff suppressed because it is too large Load Diff
@@ -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<string>(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<string>(options);
}
private const string ConfigKeyFoundryEndpoint = "FOUNDRY_PROJECT_ENDPOINT";
private static readonly Dictionary<string, string> s_nameCache = [];
private static readonly HashSet<string> 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}"
});
}
}
@@ -11,7 +11,9 @@
</PropertyGroup>
<PropertyGroup>
<InjectSharedThrow>true</InjectSharedThrow>
<InjectIsExternalInitOnLegacy>true</InjectIsExternalInitOnLegacy>
<InjectSharedWorkflowsExecution>true</InjectSharedWorkflowsExecution>
<InjectSharedWorkflowsSettings>true</InjectSharedWorkflowsSettings>
</PropertyGroup>
<ItemGroup>
@@ -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<StreamingRun> 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
/// <see cref="DeclarativeWorkflowOptions" /> and the associated <see cref="WorkflowAgentProvider"/>.
/// </summary>
/// <remarks>
/// The value assigned to <see cref="IncludeFunctions" /> controls on whether the function
/// tools (<see cref="AIFunction"/>) initialized in the constructor are included for auto-invocation.
/// </remarks>
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<string>(this.WorkflowFile, options);
}
/// <summary>
/// Configuration key used to identify the Foundry project endpoint.
/// </summary>
private const string ConfigKeyFoundryEndpoint = "FOUNDRY_PROJECT_ENDPOINT";
/// <summary>
/// Controls on whether the function tools (<see cref="AIFunction"/>) initialized
/// in the constructor are included for auto-invocation.
/// NOTE: By default, no functions exist as part of this sample.
/// </summary>
private const bool IncludeFunctions = true;
private static Dictionary<string, string> NameCache { get; } = [];
private static HashSet<string> 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<string, AIFunction> FunctionMap { get; }
private WorkflowRunner Runner { get; }
private IList<AIFunction> 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<AIFunction> 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<ExternalRequest?> MonitorAndDisposeWorkflowRunAsync(Checkpointed<StreamingRun> 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
}
/// <summary>
/// Handle request for external input, either from a human or a function tool invocation.
/// </summary>
private async ValueTask<object> HandleExternalRequestAsync(ExternalRequest request) =>
request.Data.TypeId.TypeName switch
{
// Request for human input
_ when request.Data.TypeId.IsMatch<AnswerRequest>() => HandleUserMessageRequest(request.DataAs<AnswerRequest>()!),
// Request for function tool invocation. (Only active when functions are defined and IncludeFunctions is true.)
_ when request.Data.TypeId.IsMatch<AgentFunctionToolRequest>() => await this.HandleToolRequestAsync(request.DataAs<AgentFunctionToolRequest>()!),
// Request for user input, such as function or mcp tool approval
_ when request.Data.TypeId.IsMatch<UserInputRequest>() => HandleUserInputRequest(request.DataAs<UserInputRequest>()!),
// Unknown request type.
_ => throw new InvalidOperationException($"Unsupported external request type: {request.GetType().Name}."),
};
/// <summary>
/// Handle request for human input.
/// </summary>
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);
}
/// <summary>
/// Handle a function tool request by invoking the specified tools and returning the results.
/// </summary>
/// <remarks>
/// This handler is only active when <see cref="IncludeFunctions"/> is set to true and
/// one or more <see cref="AIFunction"/> instances are defined in the constructor.
/// </remarks>
private async ValueTask<AgentFunctionToolResponse> HandleToolRequestAsync(AgentFunctionToolRequest request)
{
Task<FunctionResultContent>[] functionTasks = request.FunctionCalls.Select(functionCall => InvokesToolAsync(functionCall)).ToArray();
await Task.WhenAll(functionTasks);
return AgentFunctionToolResponse.Create(request, functionTasks.Select(task => task.Result));
async Task<FunctionResultContent> 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));
}
}
/// <summary>
/// Handle request for user input for mcp and function tool approval.
/// </summary>
private static UserInputResponse HandleUserInputRequest(UserInputRequest request)
{
return UserInputResponse.Create(request, ProcessRequests());
IEnumerable<UserInputResponseContent> 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}"
});
}
}
@@ -0,0 +1,39 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ProjectsDebugTargetFrameworks>net9.0</ProjectsDebugTargetFrameworks>
<TargetFrameworks Condition="'$(Configuration)' == 'Debug'">$(ProjectsDebugTargetFrameworks)</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<PropertyGroup>
<InjectIsExternalInitOnLegacy>true</InjectIsExternalInitOnLegacy>
<InjectSharedFoundryAgents>true</InjectSharedFoundryAgents>
<InjectSharedWorkflowsExecution>true</InjectSharedWorkflowsExecution>
<InjectSharedWorkflowsSettings>true</InjectSharedWorkflowsSettings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.Extensions.Logging" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Workflows.Declarative\Microsoft.Agents.AI.Workflows.Declarative.csproj" />
</ItemGroup>
<ItemGroup>
<None Include="FunctionTools.yaml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
@@ -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"
@@ -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; }
}
}
@@ -0,0 +1,86 @@
// Copyright (c) Microsoft. All rights reserved.
using Azure.AI.Agents;
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;
/// <summary>
/// Demonstrate a workflow that responds to user input using an agent who
/// with function tools assigned. Exits the loop when the user enters "exit".
/// </summary>
/// <remarks>
/// See the README.md file in the parent folder (../Declarative/README.md) for detailed
/// information the configuration required to run this sample.
/// </remarks>
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)
{
AgentsClient agentsClient = new(foundryEndpoint, new AzureCliCredential());
await agentsClient.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;
}
}
@@ -2,7 +2,7 @@
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net9.0</TargetFrameworks>
<TargetFramework>net9.0</TargetFramework>
<ProjectsDebugTargetFrameworks>net9.0</ProjectsDebugTargetFrameworks>
<TargetFrameworks Condition="'$(Configuration)' == 'Debug'">$(ProjectsDebugTargetFrameworks)</TargetFrameworks>
<Nullable>enable</Nullable>
@@ -0,0 +1,39 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ProjectsDebugTargetFrameworks>net9.0</ProjectsDebugTargetFrameworks>
<TargetFrameworks Condition="'$(Configuration)' == 'Debug'">$(ProjectsDebugTargetFrameworks)</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<PropertyGroup>
<InjectIsExternalInitOnLegacy>true</InjectIsExternalInitOnLegacy>
<InjectSharedFoundryAgents>true</InjectSharedFoundryAgents>
<InjectSharedWorkflowsExecution>true</InjectSharedWorkflowsExecution>
<InjectSharedWorkflowsSettings>true</InjectSharedWorkflowsSettings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.Extensions.Logging" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Workflows.Declarative\Microsoft.Agents.AI.Workflows.Declarative.csproj" />
</ItemGroup>
<ItemGroup>
<None Include="$(MSBuildThisFileDirectory)..\..\..\..\..\..\workflow-samples\Marketing.yaml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
@@ -0,0 +1,104 @@
// Copyright (c) Microsoft. All rights reserved.
using Azure.AI.Agents;
using Azure.Identity;
using Microsoft.Extensions.Configuration;
using Shared.Foundry;
using Shared.Workflows;
namespace Demo.Workflows.Declarative.Marketing;
/// <summary>
/// Demonstrate a declarative workflow with three agents (Analyst, Writer, Editor)
/// sequentially engaging in a task.
/// </summary>
/// <remarks>
/// See the README.md file in the parent folder (../Declarative/README.md) for detailed
/// information the configuration required to run this sample.
/// </remarks>
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)
{
AgentsClient agentsClient = new(foundryEndpoint, new AzureCliCredential());
await agentsClient.CreateAgentAsync(
agentName: "AnalystAgent",
agentDefinition: DefineAnalystAgent(configuration),
agentDescription: "Analyst agent for Marketing workflow");
await agentsClient.CreateAgentAsync(
agentName: "WriterAgent",
agentDefinition: DefineWriterAgent(configuration),
agentDescription: "Writer agent for Marketing workflow");
await agentsClient.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.
"""
};
}
@@ -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,21 @@ 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
cd dotnet/samples/GettingStarted/Workflows/Declarative/StudentTeacher
```
2. Run the demo referencing a sample workflow by name:
2. Run the demo and optionally provided input:
```sh
dotnet run HelloWorld
```
3. Run the demo with a path to any workflow file:
```sh
dotnet run c:/myworkflows/HelloWorld.yaml
dotnet run "How would you compute the value of PI?"
```
> The sample will allow for interactive input in the absence of an input argument.
@@ -0,0 +1,85 @@
// Copyright (c) Microsoft. All rights reserved.
using Azure.AI.Agents;
using Azure.Identity;
using Microsoft.Extensions.Configuration;
using Shared.Foundry;
using Shared.Workflows;
namespace Demo.Workflows.Declarative.StudentTeacher;
/// <summary>
/// Demonstrate a declarative workflow with two agents (Student and Teacher)
/// in an iterative conversation.
/// </summary>
/// <remarks>
/// See the README.md file in the parent folder (../Declarative/README.md) for detailed
/// information the configuration required to run this sample.
/// </remarks>
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)
{
AgentsClient agentsClient = new(foundryEndpoint, new AzureCliCredential());
await agentsClient.CreateAgentAsync(
agentName: "StudentAgent",
agentDefinition: DefineStudentAgent(configuration),
agentDescription: "Student agent for MathChat workflow");
await agentsClient.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".
"""
};
}
@@ -0,0 +1,39 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ProjectsDebugTargetFrameworks>net9.0</ProjectsDebugTargetFrameworks>
<TargetFrameworks Condition="'$(Configuration)' == 'Debug'">$(ProjectsDebugTargetFrameworks)</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<PropertyGroup>
<InjectIsExternalInitOnLegacy>true</InjectIsExternalInitOnLegacy>
<InjectSharedFoundryAgents>true</InjectSharedFoundryAgents>
<InjectSharedWorkflowsExecution>true</InjectSharedWorkflowsExecution>
<InjectSharedWorkflowsSettings>true</InjectSharedWorkflowsSettings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.Extensions.Logging" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Workflows.Declarative\Microsoft.Agents.AI.Workflows.Declarative.csproj" />
</ItemGroup>
<ItemGroup>
<None Include="$(MSBuildThisFileDirectory)..\..\..\..\..\..\workflow-samples\MathChat.yaml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
@@ -0,0 +1,74 @@
// Copyright (c) Microsoft. All rights reserved.
using Azure.AI.Agents;
using Azure.Identity;
using Microsoft.Extensions.Configuration;
using OpenAI.Responses;
using Shared.Foundry;
using Shared.Workflows;
namespace Demo.Workflows.Declarative.ToolApproval;
/// <summary>
/// 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".
/// </summary>
/// <remarks>
/// See the README.md file in the parent folder (../Declarative/README.md) for detailed
/// information the configuration required to run this sample.
/// </remarks>
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)
{
AgentsClient agentsClient = new(foundryEndpoint, new AzureCliCredential());
await agentsClient.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))
}
};
}
@@ -0,0 +1,39 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ProjectsDebugTargetFrameworks>net9.0</ProjectsDebugTargetFrameworks>
<TargetFrameworks Condition="'$(Configuration)' == 'Debug'">$(ProjectsDebugTargetFrameworks)</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<PropertyGroup>
<InjectIsExternalInitOnLegacy>true</InjectIsExternalInitOnLegacy>
<InjectSharedFoundryAgents>true</InjectSharedFoundryAgents>
<InjectSharedWorkflowsExecution>true</InjectSharedWorkflowsExecution>
<InjectSharedWorkflowsSettings>true</InjectSharedWorkflowsSettings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.Extensions.Logging" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Workflows.Declarative\Microsoft.Agents.AI.Workflows.Declarative.csproj" />
</ItemGroup>
<ItemGroup>
<None Include="ToolApproval.yaml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
@@ -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
@@ -1,15 +1,18 @@
// 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.Threading;
using System.Threading.Tasks;
using Azure.AI.Agents.Persistent;
using Azure.AI.Agents;
using Azure.Core;
using Azure.Core.Pipeline;
using Microsoft.Extensions.AI;
using OpenAI.Responses;
namespace Microsoft.Agents.AI.Workflows.Declarative;
@@ -18,31 +21,23 @@ namespace Microsoft.Agents.AI.Workflows.Declarative;
/// </summary>
/// <remarks>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.</remarks>
/// <param name="projectEndpoint">The endpoint URL of the Foundry project. This must be a valid, non-null URI pointing to the project.</param>
/// <param name="projectEndpoint">A <see cref="Uri"/> instance representing the endpoint URL of the Foundry project. This must be a valid, non-null URI pointing to the project.</param>
/// <param name="projectCredentials">The credentials used to authenticate with the Foundry project. This must be a valid instance of <see cref="TokenCredential"/>.</param>
/// <param name="httpClient">An optional <see cref="HttpClient"/> instance to be used for making HTTP requests. If not provided, a default client will be used.</param>
public sealed class AzureAgentProvider(string projectEndpoint, TokenCredential projectCredentials, HttpClient? httpClient = null) : WorkflowAgentProvider
public sealed class AzureAgentProvider(Uri projectEndpoint, TokenCredential projectCredentials, HttpClient? httpClient = null) : WorkflowAgentProvider
{
private static readonly Dictionary<string, MessageRole> 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 readonly Dictionary<string, AgentVersion> _versionCache = [];
private readonly Dictionary<string, AIAgent> _agentCache = [];
private PersistentAgentsClient? _agentsClient;
private AgentsClient? _agentsClient;
private ConversationClient? _conversationClient;
/// <inheritdoc/>
public override async Task<string> CreateConversationAsync(CancellationToken cancellationToken = default)
{
PersistentAgentThread conversation =
await this.GetAgentsClient().Threads.CreateThreadAsync(
messages: null,
toolResources: null,
metadata: null,
cancellationToken).ConfigureAwait(false);
AgentConversation conversation =
await this.GetConversationClient()
.CreateConversationAsync(options: null, cancellationToken).ConfigureAwait(false);
return conversation.Id;
}
@@ -50,61 +45,117 @@ public sealed class AzureAgentProvider(string projectEndpoint, TokenCredential p
/// <inheritdoc/>
public override async Task<ChatMessage> CreateMessageAsync(string conversationId, ChatMessage conversationMessage, CancellationToken cancellationToken = default)
{
PersistentThreadMessage newMessage =
await this.GetAgentsClient().Messages.CreateMessageAsync(
ReadOnlyCollection<ResponseItem> newItems =
await this.GetConversationClient().CreateConversationItemsAsync(
conversationId,
role: s_roleMap[conversationMessage.Role.Value.ToUpperInvariant()],
contentBlocks: GetContent(),
attachments: null,
metadata: GetMetadata(),
items: GetResponseItems(),
include: null,
cancellationToken).ConfigureAwait(false);
return ToChatMessage(newMessage);
return newItems.AsChatMessages().Single();
Dictionary<string, string>? GetMetadata()
IEnumerable<ResponseItem> GetResponseItems()
{
if (conversationMessage.AdditionalProperties is null)
IEnumerable<ChatMessage> messages = [conversationMessage];
foreach (ResponseItem item in messages.AsOpenAIResponseItems())
{
return null;
}
return conversationMessage.AdditionalProperties.ToDictionary(prop => prop.Key, prop => prop.Value?.ToString() ?? string.Empty);
}
IEnumerable<MessageInputContentBlock> 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)
if (string.IsNullOrEmpty(item.Id))
{
yield return contentBlock;
yield return item;
}
else
{
yield return new ReferenceResponseItem(item.Id);
}
}
}
}
/// <inheritdoc/>
public override async Task<AIAgent> GetAgentAsync(string agentId, CancellationToken cancellationToken = default)
public override async IAsyncEnumerable<AgentRunResponseUpdate> InvokeAgentAsync(
string agentId,
string? agentVersion,
string? conversationId,
IEnumerable<ChatMessage>? messages,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
ChatClientAgent agent =
await this.GetAgentsClient().GetAIAgentAsync(
agentId,
new ChatOptions()
{
AllowMultipleToolCalls = this.AllowMultipleToolCalls,
},
clientFactory: null,
cancellationToken).ConfigureAwait(false);
AgentVersion agentDefinition = await this.QueryAgentAsync(agentId, agentVersion, cancellationToken).ConfigureAwait(false);
AIAgent agent = await this.GetAgentAsync(agentDefinition, cancellationToken).ConfigureAwait(false);
ChatOptions chatOptions =
new()
{
ConversationId = conversationId,
AllowMultipleToolCalls = this.AllowMultipleToolCalls,
};
ChatClientAgentRunOptions runOptions = new(chatOptions);
IAsyncEnumerable<AgentRunResponseUpdate> 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 = agentDefinition.Name;
yield return update;
}
}
private async Task<AgentVersion> QueryAgentAsync(string agentName, string? agentVersion, CancellationToken cancellationToken = default)
{
string agentKey = $"{agentName}:{agentVersion}";
if (this._versionCache.TryGetValue(agentKey, out AgentVersion? targetAgent))
{
return targetAgent;
}
AgentsClient client = this.GetAgentsClient();
if (string.IsNullOrEmpty(agentVersion))
{
AgentRecord agentRecord =
await client.GetAgentAsync(
agentName,
cancellationToken).ConfigureAwait(false);
targetAgent = agentRecord.Versions.Latest;
}
else
{
targetAgent =
await client.GetAgentVersionAsync(
agentName,
agentVersion,
cancellationToken).ConfigureAwait(false);
}
this._versionCache[agentKey] = targetAgent;
return targetAgent;
}
private async Task<AIAgent> GetAgentAsync(AgentVersion agentDefinition, CancellationToken cancellationToken = default)
{
if (this._agentCache.TryGetValue(agentDefinition.Id, out AIAgent? agent))
{
return agent;
}
AgentsClient client = this.GetAgentsClient();
IList<AITool>? tools = null;
if (agentDefinition.Definition is PromptAgentDefinition promptAgent)
{
tools =
promptAgent.Tools
.Select(tool => tool.AsAITool())
.ToArray();
}
agent = client.GetAIAgent(agentDefinition, tools, clientFactory: null, openAIClientOptions: null, requireInvocableTools: false, cancellationToken);
FunctionInvokingChatClient? functionInvokingClient = agent.GetService<FunctionInvokingChatClient>();
if (functionInvokingClient is not null)
@@ -127,14 +178,17 @@ public sealed class AzureAgentProvider(string projectEndpoint, TokenCredential p
}
}
this._agentCache[agentDefinition.Id] = agent;
return agent;
}
/// <inheritdoc/>
public override async Task<ChatMessage> GetMessageAsync(string conversationId, string messageId, CancellationToken cancellationToken = default)
{
PersistentThreadMessage message = await this.GetAgentsClient().Messages.GetMessageAsync(conversationId, messageId, cancellationToken).ConfigureAwait(false);
return ToChatMessage(message);
AgentResponseItem responseItem = await this.GetConversationClient().GetConversationItemAsync(conversationId, messageId, cancellationToken).ConfigureAwait(false);
ResponseItem[] items = [responseItem.AsOpenAIResponseItem()];
return items.AsChatMessages().Single();
}
/// <inheritdoc/>
@@ -146,25 +200,29 @@ public sealed class AzureAgentProvider(string projectEndpoint, TokenCredential p
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))
AgentsListOrder order = newestFirst ? AgentsListOrder.Asc : AgentsListOrder.Desc;
await foreach (AgentResponseItem responseItem in this.GetConversationClient().GetConversationItemsAsync(conversationId, limit, order, after, before, itemType: null, cancellationToken).ConfigureAwait(false))
{
yield return ToChatMessage(message);
ResponseItem[] items = [responseItem.AsOpenAIResponseItem()];
foreach (ChatMessage message in items.AsChatMessages())
{
yield return message;
}
}
}
private PersistentAgentsClient GetAgentsClient()
private AgentsClient GetAgentsClient()
{
if (this._agentsClient is null)
{
PersistentAgentsAdministrationClientOptions clientOptions = new();
AgentsClientOptions clientOptions = new();
if (httpClient is not null)
{
clientOptions.Transport = new HttpClientTransport(httpClient);
clientOptions.Transport = new HttpClientPipelineTransport(httpClient);
}
PersistentAgentsClient newClient = new(projectEndpoint, projectCredentials, clientOptions);
AgentsClient newClient = new(projectEndpoint, projectCredentials, clientOptions);
Interlocked.CompareExchange(ref this._agentsClient, newClient, null);
}
@@ -172,43 +230,15 @@ public sealed class AzureAgentProvider(string projectEndpoint, TokenCredential p
return this._agentsClient;
}
private static ChatMessage ToChatMessage(PersistentThreadMessage message)
private ConversationClient GetConversationClient()
{
return
new ChatMessage(new ChatRole(message.Role.ToString()), [.. GetContent()])
{
MessageId = message.Id,
CreatedAt = message.CreatedAt,
AdditionalProperties = GetMetadata()
};
IEnumerable<AIContent> GetContent()
if (this._conversationClient is null)
{
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
};
ConversationClient conversationClient = this.GetAgentsClient().GetConversationClient();
if (content is not null)
{
yield return content;
}
}
Interlocked.CompareExchange(ref this._conversationClient, conversationClient, null);
}
AdditionalPropertiesDictionary? GetMetadata()
{
if (message.Metadata is null)
{
return null;
}
return new AdditionalPropertiesDictionary(message.Metadata.Select(m => new KeyValuePair<string, object?>(m.Key, m.Value)));
}
return this._conversationClient;
}
}
@@ -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<ChatMessage>(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);
@@ -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<ChatMessage>(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);
@@ -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;
/// <summary>
/// Represents one or more function tool requests.
/// </summary>
public sealed class AgentFunctionToolRequest
{
/// <summary>
/// The name of the agent associated with the tool request.
/// </summary>
public string AgentName { get; }
/// <summary>
/// A list of function tool requests.
/// </summary>
public IList<FunctionCallContent> FunctionCalls { get; }
[JsonConstructor]
internal AgentFunctionToolRequest(string agentName, IList<FunctionCallContent> functionCalls)
{
this.AgentName = agentName;
this.FunctionCalls = functionCalls;
}
}
@@ -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;
/// <summary>
/// Represents one or more function tool responses.
/// </summary>
public sealed class AgentFunctionToolResponse
{
/// <summary>
/// The name of the agent associated with the tool response.
/// </summary>
public string AgentName { get; }
/// <summary>
/// A list of tool responses.
/// </summary>
public IList<FunctionResultContent> FunctionResults { get; }
[JsonConstructor]
internal AgentFunctionToolResponse(string agentName, IList<FunctionResultContent> functionResults)
{
this.AgentName = agentName;
this.FunctionResults = functionResults;
}
/// <summary>
/// Factory method to create an <see cref="AgentFunctionToolResponse"/> from an <see cref="AgentFunctionToolRequest"/>
/// Ensures that all function calls in the request have a corresponding result.
/// </summary>
/// <param name="toolRequest">The tool request.</param>
/// <param name="functionResults">One or more function results</param>
/// <returns>An <see cref="AgentFunctionToolResponse"/> that can be provided to the workflow.</returns>
/// <exception cref="DeclarativeActionException">Not all <see cref="AgentFunctionToolRequest.FunctionCalls"/> have a corresponding <see cref="FunctionResultContent"/>.</exception>
public static AgentFunctionToolResponse Create(AgentFunctionToolRequest toolRequest, params IEnumerable<FunctionResultContent> functionResults)
{
HashSet<string> callIds = [.. toolRequest.FunctionCalls.Select(call => call.CallId)];
HashSet<string> 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]);
}
}
@@ -1,27 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Workflows.Declarative.Events;
/// <summary>
/// Represents a request for user input in response to a `Question` action.
/// </summary>
public sealed class AnswerRequest
{
/// <summary>
/// An optional prompt for the user.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public string? Prompt { get; }
[JsonConstructor]
internal AnswerRequest(string? prompt = null)
{
this.Prompt = prompt;
}
}
@@ -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;
/// <summary>
/// Represents a user input response.
/// </summary>
public sealed class AnswerResponse
{
/// <summary>
/// The response value.
/// </summary>
public ChatMessage Value { get; }
/// <summary>
/// Initializes a new instance of the <see cref="AnswerResponse"/> class.
/// </summary>
/// <param name="value">The response value.</param>
[JsonConstructor]
public AnswerResponse(ChatMessage value)
{
this.Value = value;
}
/// <summary>
/// Initializes a new instance of the <see cref="AnswerResponse"/> class.
/// </summary>
/// <param name="value">The response value.</param>
public AnswerResponse(string value)
{
this.Value = new ChatMessage(ChatRole.User, value);
}
}
@@ -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;
/// <summary>
/// Represents a request for external input.
/// </summary>
public sealed class ExternalInputRequest
{
/// <summary>
/// The source message that triggered the request for external input.
/// </summary>
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));
}
}
@@ -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;
/// <summary>
/// Represents the response to a <see cref="ExternalInputRequest"/>.
/// </summary>
public sealed class ExternalInputResponse
{
/// <summary>
/// The message being provided as external input to the workflow.
/// </summary>
public IList<ChatMessage> Messages { get; }
internal bool HasMessages => this.Messages?.Count > 0;
/// <summary>
/// Initializes a new instance of <see cref="ExternalInputResponse"/>.
/// </summary>
/// <param name="message">The external input message being provided to the workflow.</param>
public ExternalInputResponse(ChatMessage message)
{
this.Messages = [message];
}
/// <summary>
/// Initializes a new instance of <see cref="ExternalInputResponse"/>.
/// </summary>
/// <param name="messages">The external input messages being provided to the workflow.</param>
[JsonConstructor]
public ExternalInputResponse(IList<ChatMessage> messages)
{
this.Messages = messages;
}
}
@@ -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;
/// <summary>
/// Represents one or more user-input requests.
/// </summary>
public sealed class UserInputRequest
{
/// <summary>
/// The name of the agent associated with the tool request.
/// </summary>
public string AgentName { get; }
/// <summary>
/// A list of user input requests.
/// </summary>
public IList<AIContent> InputRequests { get; }
[JsonConstructor]
internal UserInputRequest(string agentName, IList<AIContent> inputRequests)
{
this.AgentName = agentName;
this.InputRequests = inputRequests;
}
}
@@ -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;
/// <summary>
/// Represents one or more user-input responses.
/// </summary>
public sealed class UserInputResponse
{
/// <summary>
/// The name of the agent associated with the tool request.
/// </summary>
public string AgentName { get; }
/// <summary>
/// A list of approval responses.
/// </summary>
public IList<AIContent> InputResponses { get; }
[JsonConstructor]
internal UserInputResponse(string agentName, IList<AIContent> inputResponses)
{
this.AgentName = agentName;
this.InputResponses = inputResponses;
}
/// <summary>
/// Factory method to create an <see cref="UserInputResponse"/> from a <see cref="UserInputRequest"/>
/// Ensures that all requests have a corresponding result.
/// </summary>
/// <param name="inputRequest">The input request.</param>
/// <param name="inputResponses">One or more responses</param>
/// <returns>An <see cref="UserInputResponse"/> that can be provided to the workflow.</returns>
/// <exception cref="DeclarativeActionException">Not all <see cref="AgentFunctionToolRequest.FunctionCalls"/> have a corresponding <see cref="FunctionResultContent"/>.</exception>
public static UserInputResponse Create(UserInputRequest inputRequest, params IEnumerable<UserInputResponseContent> inputResponses)
{
HashSet<string> callIds = [.. inputRequest.InputRequests.OfType<UserInputRequestContent>().Select(call => call.Id)];
HashSet<string> 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]);
}
}
@@ -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<Azure.AI.Agents.Persistent.RunStatus> 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<AgentRunResponse> InvokeAgentAsync(
this WorkflowAgentProvider agentProvider,
string executorId,
@@ -26,27 +16,10 @@ internal static class AgentProviderExtensions
string agentName,
string? conversationId,
bool autoSend,
string? additionalInstructions = null,
IEnumerable<ChatMessage>? inputMessages = 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<AgentRunResponseUpdate> agentUpdates =
inputMessages is not null ?
agent.RunStreamingAsync([.. inputMessages], null, options, cancellationToken) :
agent.RunStreamingAsync(null, options, cancellationToken);
IAsyncEnumerable<AgentRunResponseUpdate> agentUpdates = agentProvider.InvokeAgentAsync(agentName, null, conversationId, inputMessages, cancellationToken);
// Enable "autoSend" behavior if this is the workflow conversation.
bool isWorkflowConversation = context.IsWorkflowConversation(conversationId, out string? workflowConversationId);
@@ -60,13 +33,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 +46,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<ChatMessage> messages =
response.Messages.Where(
message =>
!string.IsNullOrEmpty(message.Text) &&
!message.Contents.OfType<FunctionCallContent>().Any() &&
!message.Contents.OfType<FunctionResultContent>().Any());
foreach (ChatMessage message in messages)
foreach (ChatMessage message in response.Messages)
{
await agentProvider.CreateMessageAsync(workflowConversationId, message, cancellationToken).ConfigureAwait(false);
}
@@ -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<string, object?> ParseRecord(this JsonElement currentElement, VariableType targetType)
{
if (targetType.Schema is null)
{
throw new DeclarativeActionException($"Object schema not defined for. {targetType.Type.Name}.");
}
IEnumerable<KeyValuePair<string, object?>> 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<KeyValuePair<string, object?>> ParseValues()
{
foreach (KeyValuePair<string, VariableType> 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<string, object?>(objectProperty.Name, parsedValue);
}
}
IEnumerable<KeyValuePair<string, object?>> ParseSchema(FrozenDictionary<string, VariableType> schema)
{
foreach (KeyValuePair<string, VariableType> 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
@@ -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);
/// <inheritdoc/>
public async ValueTask<TValue?> ReadStateAsync<TValue>(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<TValue>(key, scopeName, cancellationToken).ConfigureAwait(false),
_ when !IsManagedScope(scopeName) => await this.Source.ReadStateAsync<TValue>(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.
@@ -239,6 +239,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 +250,13 @@ internal sealed class WorkflowActionVisitor : DialogActionVisitor
// Define input action
string inputId = QuestionExecutor.Steps.Input(action.Id);
RequestPortAction inputPort = new(RequestPort.Create<AnswerRequest, AnswerResponse>(inputId));
RequestPortAction inputPort = new(RequestPort.Create<ExternalInputRequest, ExternalInputResponse>(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<AnswerResponse>(captureId, this._workflowState, action.CaptureResponseAsync, emitResult: false), action.ParentId);
this.ContinueWith(new DelegateActionExecutor<ExternalInputResponse>(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 +264,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<ExternalInputRequest, ExternalInputResponse>(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<ExternalInputResponse>(captureId, this._workflowState, action.CaptureResponseAsync), action.ParentId);
}
protected override void Visit(EndDialog item)
{
this.Trace(item);
@@ -285,6 +304,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 +359,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<AgentFunctionToolRequest, AgentFunctionToolResponse>(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<UserInputRequest, UserInputResponse>(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<ExternalInputRequest, ExternalInputResponse>(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<AgentFunctionToolResponse>(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<ExternalInputResponse>(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 +499,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);
@@ -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);
@@ -23,7 +23,6 @@ public abstract class AgentExecutor(string id, FormulaSession session, WorkflowA
/// <param name="agentName">The name or identifier of the agent.</param>
/// <param name="conversationId">The identifier of the conversation.</param>
/// <param name="autoSend">Send the agent's response as workflow output. (default: true).</param>
/// <param name="additionalInstructions">Optional additional instructions to the agent.</param>
/// <param name="inputMessages">Optional messages to add to the conversation prior to invocation.</param>
/// <param name="cancellationToken">A token that can be used to observe cancellation.</param>
/// <returns></returns>
@@ -32,8 +31,7 @@ public abstract class AgentExecutor(string id, FormulaSession session, WorkflowA
string agentName,
string? conversationId,
bool autoSend,
string? additionalInstructions = null,
IEnumerable<ChatMessage>? 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, cancellationToken);
}
@@ -4,7 +4,7 @@
<TargetFrameworks>$(ProjectsTargetFrameworks)</TargetFrameworks>
<TargetFrameworks Condition="'$(Configuration)' == 'Debug'">$(ProjectsDebugTargetFrameworks)</TargetFrameworks>
<VersionSuffix>preview</VersionSuffix>
<NoWarn>$(NoWarn);MEAI001</NoWarn>
<NoWarn>$(NoWarn);MEAI001;OPENAI001</NoWarn>
</PropertyGroup>
<PropertyGroup>
@@ -34,8 +34,8 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
<ProjectReference Include="..\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
<ProjectReference Include="..\Microsoft.Agents.AI.AzureAI.Persistent\Microsoft.Agents.AI.AzureAI.Persistent.csproj" />
</ItemGroup>
<ItemGroup>
@@ -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,52 @@ internal sealed class InvokeAzureAgentExecutor(InvokeAzureAgent model, WorkflowA
{
string? conversationId = this.GetConversationId();
string agentName = this.GetAgentName();
string? additionalInstructions = this.GetAdditionalInstructions();
bool autoSend = this.GetAutoSendValue();
bool isComplete = true;
AgentRunResponse agentResponse = await agentProvider.InvokeAgentAsync(this.Id, context, agentName, conversationId, autoSend, messages, cancellationToken).ConfigureAwait(false);
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<UserInputRequestContent> inputRequests = GetUserInputRequests(agentResponse);
if (inputRequests.Count > 0)
{
isComplete = false;
UserInputRequest approvalRequest = new(agentName, inputRequests.OfType<AIContent>().ToArray());
await context.SendMessageAsync(approvalRequest, cancellationToken).ConfigureAwait(false);
}
// Identify function calls that have no associated result.
List<FunctionCallContent> 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<string, object?> 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 IEnumerable<ChatMessage>? GetInputMessages()
@@ -107,7 +120,7 @@ internal sealed class InvokeAzureAgentExecutor(InvokeAzureAgent model, WorkflowA
return userInput?.ToChatMessages();
}
private static List<FunctionCallContent> GetOrphanedFunctionCalls(AgentRunResponse agentResponse)
private static IEnumerable<ChatMessage> FilterActionableContent(AgentRunResponse agentResponse)
{
HashSet<string> functionResultIds =
[.. agentResponse.Messages
@@ -117,21 +130,21 @@ internal sealed class InvokeAzureAgentExecutor(InvokeAzureAgent model, WorkflowA
.OfType<FunctionResultContent>()
.Select(functionCall => functionCall.CallId))];
List<FunctionCallContent> functionCalls = [];
foreach (FunctionCallContent functionCall in agentResponse.Messages.SelectMany(m => m.Contents.OfType<FunctionCallContent>()))
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<FunctionCallContent>().Any(functionCall => !functionResultIds.Contains(functionCall.CallId)))
{
yield return responseMessage;
}
}
return functionCalls;
}
private static List<UserInputRequestContent> GetUserInputRequests(AgentRunResponse agentResponse) =>
agentResponse.Messages.SelectMany(m => m.Contents.OfType<UserInputRequestContent>()).ToList();
private string? GetConversationId()
{
if (this.Model.ConversationId is null)
@@ -149,18 +162,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)
@@ -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);
}
@@ -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<RequestExternalInput>(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<object?> 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);
}
}
@@ -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.
@@ -16,8 +16,8 @@ public abstract class WorkflowAgentProvider
/// <summary>
/// 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 <see cref="RequestPort"/> is executed
/// that provides an <see cref="AgentFunctionToolRequest"/> that describes the function calls requested. The caller may
/// then respond with a corrsponding <see cref="AgentFunctionToolResponse"/> that includes the results of the function calls.
/// that provides an <see cref="ExternalInputRequest"/> that describes the function calls requested. The caller may
/// then respond with a corrsponding <see cref="ExternalInputResponse"/> that includes the results of the function calls.
/// </summary>
/// <remarks>
/// These will not impact the requests sent to the model by the <see cref="FunctionInvokingChatClient"/>.
@@ -56,14 +56,6 @@ public abstract class WorkflowAgentProvider
/// </remarks>
public bool AllowMultipleToolCalls { get; init; }
/// <summary>
/// Asynchronously retrieves an AI agent by its unique identifier.
/// </summary>
/// <param name="agentId">The unique identifier of the AI agent to retrieve. Cannot be null or empty.</param>
/// <param name="cancellationToken">A token that propagates notification when operation should be canceled.</param>
/// <returns>The task result contains the <see cref="AIAgent"/> associated.</returns>
public abstract Task<AIAgent> GetAgentAsync(string agentId, CancellationToken cancellationToken = default);
/// <summary>
/// Asynchronously creates a new conversation and returns its unique identifier.
/// </summary>
@@ -88,6 +80,17 @@ public abstract class WorkflowAgentProvider
/// <returns>The requested message</returns>
public abstract Task<ChatMessage> GetMessageAsync(string conversationId, string messageId, CancellationToken cancellationToken = default);
/// <summary>
/// Asynchronously retrieves an AI agent by its unique identifier.
/// </summary>
/// <param name="agentId">The unique identifier of the AI agent to retrieve. Cannot be null or empty.</param>
/// <param name="agentVersion">An optional agent version.</param>
/// <param name="conversationId">Optional identifier of the target conversation.</param>
/// <param name="messages">The messages to include in the invocation.</param>
/// <param name="cancellationToken">A token that propagates notification when operation should be canceled.</param>
/// <returns>Asynchronous set of <see cref="AgentRunResponseUpdate"/>.</returns>
public abstract IAsyncEnumerable<AgentRunResponseUpdate> InvokeAgentAsync(string agentId, string? agentVersion, string? conversationId, IEnumerable<ChatMessage>? messages, CancellationToken cancellationToken = default);
/// <summary>
/// Retrieves a set of messages from a conversation.
/// </summary>
+11
View File
@@ -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
<PropertyGroup>
<InjectSharedBuildTestCode>true</InjectSharedBuildTestCode>
</PropertyGroup>
```
@@ -0,0 +1,44 @@
// Copyright (c) Microsoft. All rights reserved.
#pragma warning disable IDE0005
using System;
using System.Threading.Tasks;
using Azure.AI.Agents;
namespace Shared.Foundry;
internal static class AgentFactory
{
public static async ValueTask<AgentVersion> CreateAgentAsync(
this AgentsClient agentsClient,
string agentName,
PromptAgentDefinition agentDefinition,
string agentDescription)
{
AgentVersionCreationOptions options =
new()
{
Description = agentDescription,
Metadata =
{
{ "deleteme", bool.TrueString },
{ "test", bool.TrueString },
},
};
AgentVersion agentVersion = await agentsClient.CreateAgentVersionAsync(agentName, agentDefinition, options).ConfigureAwait(false);
Console.ForegroundColor = ConsoleColor.Cyan;
try
{
Console.WriteLine($"PROMPT AGENT: {agentVersion.Name}:{agentVersion.Version}");
}
finally
{
Console.ResetColor();
}
return agentVersion;
}
}
@@ -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
<PropertyGroup>
<InjectSharedFoundryAgents>true</InjectSharedFoundryAgents>
</PropertyGroup>
```
@@ -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
<PropertyGroup>
<InjectSharedWorkflowsExecution>true</InjectSharedWorkflowsExecution>
</PropertyGroup>
```
@@ -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<AIFunction> 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;
/// <summary>
/// Create the workflow from the declarative YAML. Includes definition of the
/// <see cref="DeclarativeWorkflowOptions" /> and the associated <see cref="WorkflowAgentProvider"/>.
/// </summary>
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<string>(workflowPath, options);
}
}
@@ -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<string, AIFunction> 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();
}
}
/// <summary>
/// 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.
/// </summary>
public bool UseJsonCheckpoints { get; init; }
public WorkflowRunner(params IEnumerable<AIFunction> functions)
{
this.FunctionMap = functions.ToDictionary(f => f.Name);
}
public async Task ExecuteAsync(Func<Workflow> 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<StreamingRun> 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<ExternalRequest?> MonitorAndDisposeWorkflowRunAsync(Checkpointed<StreamingRun> 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;
}
/// <summary>
/// Handle request for external input.
/// </summary>
private async ValueTask<ExternalInputResponse> HandleExternalRequestAsync(ExternalRequest request)
{
ExternalInputRequest inputRequest =
request.DataAs<ExternalInputRequest>() ??
throw new InvalidOperationException($"Expected external request type: {request.GetType().Name}.");
List<ChatMessage> 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<ChatMessage> 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<ChatMessage> 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}"
});
}
}
@@ -0,0 +1,80 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Reflection;
using Microsoft.Extensions.Configuration;
namespace Shared.Workflows;
internal static class Application
{
/// <summary>
/// Configuration key used to identify the Foundry project endpoint.
/// </summary>
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}");
/// <summary>
/// Initialize configuration and environment
/// </summary>
public static IConfigurationRoot InitializeConfig() =>
new ConfigurationBuilder()
.AddUserSecrets(Assembly.GetExecutingAssembly())
.AddEnvironmentVariables()
.Build();
}
@@ -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
<PropertyGroup>
<InjectSharedWorkflowsSettings>true</InjectSharedWorkflowsSettings>
</PropertyGroup>
```
@@ -0,0 +1,52 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Azure.AI.Agents;
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 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),
_ => 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<AgentVersion> CreateAgentsAsync(Uri foundryEndpoint);
protected string GetSetting(string settingName) =>
configuration[settingName] ??
throw new InvalidOperationException($"Undefined configuration setting: {settingName}");
}
@@ -0,0 +1,55 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using Azure.AI.Agents;
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<AgentVersion> CreateAgentsAsync(Uri foundryEndpoint)
{
MenuPlugin menuPlugin = new();
AIFunction[] functions =
[
AIFunctionFactory.Create(menuPlugin.GetMenu),
AIFunctionFactory.Create(menuPlugin.GetSpecials),
AIFunctionFactory.Create(menuPlugin.GetItemPrice),
];
AgentsClient agentsClient = new(foundryEndpoint, new AzureCliCredential());
yield return
await agentsClient.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;
}
}
@@ -0,0 +1,75 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using Azure.AI.Agents;
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<AgentVersion> CreateAgentsAsync(Uri foundryEndpoint)
{
AgentsClient agentsClient = new(foundryEndpoint, new AzureCliCredential());
yield return
await agentsClient.CreateAgentAsync(
agentName: "AnalystAgent",
agentDefinition: this.DefineAnalystAgent(),
agentDescription: "Analyst agent for Marketing workflow");
yield return
await agentsClient.CreateAgentAsync(
agentName: "WriterAgent",
agentDefinition: this.DefineWriterAgent(),
agentDescription: "Writer agent for Marketing workflow");
yield return
await agentsClient.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.
"""
};
}
@@ -0,0 +1,54 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using Azure.AI.Agents;
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<AgentVersion> CreateAgentsAsync(Uri foundryEndpoint)
{
AgentsClient agentsClient = new(foundryEndpoint, new AzureCliCredential());
yield return
await agentsClient.CreateAgentAsync(
agentName: "StudentAgent",
agentDefinition: this.DefineStudentAgent(),
agentDescription: "Student agent for MathChat workflow");
yield return
await agentsClient.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".
"""
};
}
@@ -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<AIFunction> 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)
@@ -1,5 +0,0 @@
type: foundry_agent
name: BasicAgent
description: Basic agent for integration tests
model:
id: ${FOUNDRY_MEDIA_DEPLOYMENT_NAME}
@@ -0,0 +1,27 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using Azure.AI.Agents;
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<AgentVersion> CreateAgentsAsync(Uri foundryEndpoint)
{
AgentsClient agentsClient = new(foundryEndpoint, new AzureCliCredential());
yield return
await agentsClient.CreateAgentAsync(
agentName: "TestAgent",
agentDefinition: this.DefineMenuAgent(),
agentDescription: "Provides information about the restaurant menu");
}
private PromptAgentDefinition DefineMenuAgent() =>
new(this.GetSetting(Settings.FoundryModelFull));
}
@@ -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.
@@ -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<RequestFailedException>(() => provider.GetAgentAsync(agentName));
}
private async ValueTask<string> 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;
}
}
@@ -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;
@@ -22,7 +23,7 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow
[InlineData("ConversationMessages.yaml", "ConversationMessages.json")]
[InlineData("ConversationMessages.yaml", "ConversationMessages.json", true)]
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 +31,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", true)]
[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<TInput>(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<TInput>(workflowPath, workflowOptions);
WorkflowHarness harness = new(workflow, runId: Path.GetFileNameWithoutExtension(workflowPath));
@@ -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<string, string> 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<string, string?>? s_agentMap;
public static async Task<FrozenDictionary<string, string?>> 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<Agents.MenuPlugin>();
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;
}
}
}
}
@@ -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<AzureAIConfiguration>();
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<DeclarativeWorkflowOptions> CreateOptionsAsync(bool externalConversation = false, params IEnumerable<AIFunction> functionTools)
{
FrozenDictionary<string, string?> 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();
@@ -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<WorkflowEvents> RunTestcaseAsync<TInput>(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<StreamingRun> run = await InProcessExecution.StreamAsync(workflow, input, this.GetCheckpointManager(useJson), runId);
IReadOnlyList<WorkflowEvent> workflowEvents = await MonitorAndDisposeWorkflowRunAsync(run).ToArrayAsync();
this.LastCheckpoint = workflowEvents.OfType<SuperStepCompletedEvent>().LastOrDefault()?.CompletionInfo?.Checkpoint;
this._lastCheckpoint = workflowEvents.OfType<SuperStepCompletedEvent>().LastOrDefault()?.CompletionInfo?.Checkpoint;
return new WorkflowEvents(workflowEvents);
}
public async Task<WorkflowEvents> ResumeAsync(object response)
public async Task<WorkflowEvents> ResumeAsync(ExternalResponse response)
{
Console.WriteLine("\nRESUMING WORKFLOW...");
Assert.NotNull(this.LastCheckpoint);
Checkpointed<StreamingRun> run = await InProcessExecution.ResumeStreamAsync(workflow, this.LastCheckpoint, this.GetCheckpointManager(), runId);
Assert.NotNull(this._lastCheckpoint);
Checkpointed<StreamingRun> run = await InProcessExecution.ResumeStreamAsync(workflow, this._lastCheckpoint, this.GetCheckpointManager(), runId);
IReadOnlyList<WorkflowEvent> 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<WorkflowEvent> MonitorAndDisposeWorkflowRunAsync(Checkpointed<StreamingRun> run, object? response = null)
private static async IAsyncEnumerable<WorkflowEvent> MonitorAndDisposeWorkflowRunAsync(Checkpointed<StreamingRun> 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}");
@@ -33,6 +33,9 @@ public sealed class FunctionCallingWorkflowTest(ITestOutputHelper output) : Inte
private async Task RunWorkflowAsync(bool autoInvoke, params IEnumerable<AIFunction> functionTools)
{
AgentProvider agentProvider = AgentProvider.Create(this.Configuration, AgentProvider.Names.FunctionTool);
await agentProvider.CreateAgentsAsync().ConfigureAwait(false);
string workflowPath = GetWorkflowPath("FunctionTool.yaml");
Dictionary<string, AIFunction> 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<AgentFunctionToolRequest>();
ExternalInputRequest? toolRequest = inputEvent.Request.Data.As<ExternalInputRequest>();
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<FunctionCallContent>())
{
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<FunctionResultContent> functionResults = await InvokeToolsAsync(functionCalls);
IList<AIContent> 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<IList<FunctionResultContent>> InvokeToolsAsync(IEnumerable<(FunctionCallContent, AIFunction)> functionCalls)
private static async ValueTask<IList<AIContent>> InvokeToolsAsync(IEnumerable<(FunctionCallContent, AIFunction)> functionCalls)
{
List<FunctionResultContent> results = [];
List<AIContent> results = [];
foreach ((FunctionCallContent functionCall, AIFunction functionTool) in functionCalls)
{
@@ -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.Agents;
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();
AgentsClient 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.GetOpenAIClient().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<byte[]> DownloadImageAsync()
private static async Task<byte[]> 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");
@@ -5,8 +5,9 @@
</PropertyGroup>
<PropertyGroup>
<InjectSharedIntegrationTestCode>true</InjectSharedIntegrationTestCode>
<InjectSharedBuildTestCode>true</InjectSharedBuildTestCode>
<InjectSharedFoundryAgents>true</InjectSharedFoundryAgents>
<InjectSharedIntegrationTestCode>true</InjectSharedIntegrationTestCode>
</PropertyGroup>
<ItemGroup>
@@ -21,9 +22,6 @@
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
<PackageReference Include="Microsoft.SemanticKernel.Agents.Abstractions" />
<PackageReference Include="Microsoft.SemanticKernel.Agents.AzureAI" />
<PackageReference Include="Microsoft.SemanticKernel.Agents.Yaml" />
<PackageReference Include="System.Linq.Async" />
</ItemGroup>
@@ -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"
]
}
}
}
@@ -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",
@@ -10,6 +10,7 @@
"conversation_count": 1,
"min_action_count": 3,
"min_response_count": 3,
"min_message_count": 6,
"actions": {
"start": [
"invoke_analyst",
@@ -12,6 +12,8 @@
"max_action_count": -1,
"min_response_count": 2,
"max_response_count": 8,
"min_message_count": 4,
"max_message_count": 17,
"actions": {
"start": [
],
@@ -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"
]
}
}
}
@@ -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:
@@ -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?")
@@ -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
@@ -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;
@@ -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}"
@@ -15,7 +15,7 @@ trigger:
- kind: SetVariable
id: set_user_name
variable: Global.UserName
value: =Env.USERNAME
value: TestAgent
# Respond with input
- kind: SendActivity
@@ -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")]
@@ -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
@@ -145,8 +145,9 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow
}
[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 +171,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 +230,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")]
@@ -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;
/// <summary>
/// Verify <see cref="AgentFunctionToolRequest"/> class
/// </summary>
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<string, object?>() { { "name", "Clam Chowder" } }),
]));
// Assert
Assert.Equal("agent", copy.AgentName);
Assert.Equal(2, copy.FunctionCalls.Count);
Assert.IsType<FunctionCallContent>(copy.FunctionCalls[0]);
Assert.Null(copy.FunctionCalls[0].Arguments);
Assert.IsType<FunctionCallContent>(copy.FunctionCalls[1]);
Assert.NotNull(copy.FunctionCalls[1].Arguments);
Assert.NotEmpty(copy.FunctionCalls[1].Arguments!);
}
}
@@ -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;
/// <summary>
/// Verify <see cref="AgentFunctionToolResponse"/> class
/// </summary>
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<FunctionResultContent>(copy.FunctionResults[0]);
Assert.Equal("call1", copy.FunctionResults[0].CallId);
Assert.IsType<FunctionResultContent>(copy.FunctionResults[1]);
Assert.Equal("call2", copy.FunctionResults[1].CallId);
}
}
@@ -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<TContent>(ChatMessage message) where TContent : AIContent
{
TContent[] contents = message.Contents.OfType<TContent>().ToArray();
return Assert.Single(contents);
}
}
@@ -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;
/// <summary>
/// Verify <see cref="ExternalInputRequest"/> class
/// </summary>
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<McpServerToolApprovalRequestContent>(messageCopy);
Assert.Equal("call1", mcpRequest.Id);
FunctionApprovalRequestContent functionRequest = AssertContent<FunctionApprovalRequestContent>(messageCopy);
Assert.Equal("call2", functionRequest.Id);
FunctionCallContent functionCall = AssertContent<FunctionCallContent>(messageCopy);
Assert.Equal("call3", functionCall.CallId);
TextContent textContent = AssertContent<TextContent>(messageCopy);
Assert.Equal("Heya", textContent.Text);
}
}
@@ -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;
/// <summary>
/// Verify <see cref="ExternalInputResponse"/> class
/// </summary>
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<McpServerToolApprovalResponseContent>(responseMessage);
Assert.Equal("call1", mcpApproval.Id);
FunctionApprovalResponseContent functionApproval = AssertContent<FunctionApprovalResponseContent>(responseMessage);
Assert.Equal("call2", functionApproval.Id);
FunctionResultContent functionResult = AssertContent<FunctionResultContent>(responseMessage);
Assert.Equal("call3", functionResult.CallId);
TextContent textContent = AssertContent<TextContent>(responseMessage);
Assert.Equal("Heya", textContent.Text);
}
}
@@ -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;
/// <summary>
/// Verify <see cref="UserInputRequest"/> class
/// </summary>
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<McpServerToolApprovalRequestContent>(copy.InputRequests[0]);
Assert.Equal("call1", mcpRequest.Id);
FunctionApprovalRequestContent functionRequest = Assert.IsType<FunctionApprovalRequestContent>(copy.InputRequests[1]);
Assert.Equal("call2", functionRequest.Id);
}
}
@@ -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;
/// <summary>
/// Verify <see cref="UserInputResponse"/> class
/// </summary>
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<McpServerToolApprovalResponseContent>(copy.InputResponses[0]);
Assert.Equal("call1", mcpResponse.Id);
FunctionApprovalResponseContent functionResponse = Assert.IsType<FunctionApprovalResponseContent>(copy.InputResponses[1]);
Assert.Equal("call2", functionResponse.Id);
}
}
@@ -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;
/// <summary>
/// Verify <see cref="AnswerRequest"/> class
/// </summary>
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);
}
}
@@ -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;
/// <summary>
/// Verify <see cref="AnswerResponse"/> class
/// </summary>
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);
}
}
@@ -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<string, object?> 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()
{
@@ -14,8 +14,8 @@ public sealed class TemplateExtensionsTests
{
// Arrange
RecalcEngine engine = new();
IEnumerable<TemplateLine> template = new List<TemplateLine>
{
IEnumerable<TemplateLine> 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<TemplateLine> template = new List<TemplateLine>
{
IEnumerable<TemplateLine> 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);
@@ -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!

Some files were not shown because too many files have changed in this diff Show More