diff --git a/dotnet/SK-dotnet.sln b/dotnet/SK-dotnet.sln index 76bb693a61b2..46a322eda95f 100644 --- a/dotnet/SK-dotnet.sln +++ b/dotnet/SK-dotnet.sln @@ -441,6 +441,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "kernel-functions-generator" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Agents.AzureAI", "src\Agents\AzureAI\Agents.AzureAI.csproj", "{EA35F1B5-9148-4189-BE34-5E00AED56D65}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Plugins.AI", "src\Plugins\Plugins.AI\Plugins.AI.csproj", "{0C64EC81-8116-4388-87AD-BA14D4B59974}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Plugins.AI.UnitTests", "src\Plugins\Plugins.AI.UnitTests\Plugins.AI.UnitTests.csproj", "{03ACF9DD-00C9-4F2B-80F1-537E2151AF5F}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -1180,6 +1184,18 @@ Global {EA35F1B5-9148-4189-BE34-5E00AED56D65}.Publish|Any CPU.Build.0 = Publish|Any CPU {EA35F1B5-9148-4189-BE34-5E00AED56D65}.Release|Any CPU.ActiveCfg = Release|Any CPU {EA35F1B5-9148-4189-BE34-5E00AED56D65}.Release|Any CPU.Build.0 = Release|Any CPU + {0C64EC81-8116-4388-87AD-BA14D4B59974}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0C64EC81-8116-4388-87AD-BA14D4B59974}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0C64EC81-8116-4388-87AD-BA14D4B59974}.Publish|Any CPU.ActiveCfg = Publish|Any CPU + {0C64EC81-8116-4388-87AD-BA14D4B59974}.Publish|Any CPU.Build.0 = Publish|Any CPU + {0C64EC81-8116-4388-87AD-BA14D4B59974}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0C64EC81-8116-4388-87AD-BA14D4B59974}.Release|Any CPU.Build.0 = Release|Any CPU + {03ACF9DD-00C9-4F2B-80F1-537E2151AF5F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {03ACF9DD-00C9-4F2B-80F1-537E2151AF5F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {03ACF9DD-00C9-4F2B-80F1-537E2151AF5F}.Publish|Any CPU.ActiveCfg = Debug|Any CPU + {03ACF9DD-00C9-4F2B-80F1-537E2151AF5F}.Publish|Any CPU.Build.0 = Debug|Any CPU + {03ACF9DD-00C9-4F2B-80F1-537E2151AF5F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {03ACF9DD-00C9-4F2B-80F1-537E2151AF5F}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -1342,6 +1358,8 @@ Global {2EB6E4C2-606D-B638-2E08-49EA2061C428} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} {78785CB1-66CF-4895-D7E5-A440DD84BE86} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} {EA35F1B5-9148-4189-BE34-5E00AED56D65} = {6823CD5E-2ABE-41EB-B865-F86EC13F0CF9} + {0C64EC81-8116-4388-87AD-BA14D4B59974} = {D6D598DF-C17C-46F4-B2B9-CDE82E2DE132} + {03ACF9DD-00C9-4F2B-80F1-537E2151AF5F} = {D6D598DF-C17C-46F4-B2B9-CDE82E2DE132} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {FBDC56A3-86AD-4323-AA0F-201E59123B83} diff --git a/dotnet/samples/Concepts/Concepts.csproj b/dotnet/samples/Concepts/Concepts.csproj index 82e20bd7d68e..17335b615a19 100644 --- a/dotnet/samples/Concepts/Concepts.csproj +++ b/dotnet/samples/Concepts/Concepts.csproj @@ -83,6 +83,7 @@ + diff --git a/dotnet/samples/Concepts/Plugins/CrewAI_Plugin.cs b/dotnet/samples/Concepts/Plugins/CrewAI_Plugin.cs new file mode 100644 index 000000000000..cf0de1188055 --- /dev/null +++ b/dotnet/samples/Concepts/Plugins/CrewAI_Plugin.cs @@ -0,0 +1,108 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Plugins.AI.CrewAI; + +namespace Plugins; + +/// +/// This example shows how to interact with an existing CrewAI Enterprise Crew directly or as a plugin. +/// These examples require a valid CrewAI Enterprise deployment with an endpoint, auth token, and known inputs. +/// +public class CrewAI_Plugin(ITestOutputHelper output) : BaseTest(output) +{ + /// + /// Shows how to kickoff an existing CrewAI Enterprise Crew and wait for it to complete. + /// + [Fact] + public async Task UsingCrewAIEnterpriseAsync() + { + string crewAIEndpoint = TestConfiguration.CrewAI.Endpoint; + string crewAIAuthToken = TestConfiguration.CrewAI.AuthToken; + + var crew = new CrewAIEnterprise( + endpoint: new Uri(crewAIEndpoint), + authTokenProvider: async () => crewAIAuthToken); + + // The required inputs for the Crew must be known in advance. This example is modeled after the + // Enterprise Content Marketing Crew Template and requires the following inputs: + var inputs = new + { + company = "CrewAI", + topic = "Agentic products for consumers", + }; + + // Invoke directly with our inputs + var kickoffId = await crew.KickoffAsync(inputs); + Console.WriteLine($"CrewAI Enterprise Crew kicked off with ID: {kickoffId}"); + + // Wait for completion + var result = await crew.WaitForCrewCompletionAsync(kickoffId); + Console.WriteLine("CrewAI Enterprise Crew completed with the following result:"); + Console.WriteLine(result); + } + + /// + /// Shows how to kickoff an existing CrewAI Enterprise Crew as a plugin. + /// + [Fact] + public async Task UsingCrewAIEnterpriseAsPluginAsync() + { + string crewAIEndpoint = TestConfiguration.CrewAI.Endpoint; + string crewAIAuthToken = TestConfiguration.CrewAI.AuthToken; + string openAIModelId = TestConfiguration.OpenAI.ChatModelId; + string openAIApiKey = TestConfiguration.OpenAI.ApiKey; + + if (openAIModelId is null || openAIApiKey is null) + { + Console.WriteLine("OpenAI credentials not found. Skipping example."); + return; + } + + // Setup the Kernel and AI Services + Kernel kernel = Kernel.CreateBuilder() + .AddOpenAIChatCompletion( + modelId: openAIModelId, + apiKey: openAIApiKey) + .Build(); + + var crew = new CrewAIEnterprise( + endpoint: new Uri(crewAIEndpoint), + authTokenProvider: async () => crewAIAuthToken); + + // The required inputs for the Crew must be known in advance. This example is modeled after the + // Enterprise Content Marketing Crew Template and requires string inputs for the company and topic. + // We need to describe the type and purpose of each input to allow the LLM to invoke the crew as expected. + var crewPluginDefinitions = new[] + { + new CrewAIInputMetadata(Name: "company", Description: "The name of the company that should be researched", Type: typeof(string)), + new CrewAIInputMetadata(Name: "topic", Description: "The topic that should be researched", Type: typeof(string)), + }; + + // Create the CrewAI Plugin. This builds a plugin that can be added to the Kernel and invoked like any other plugin. + // The plugin will contain the following functions: + // - Kickoff: Starts the Crew with the specified inputs and returns the Id of the scheduled kickoff. + // - KickoffAndWait: Starts the Crew with the specified inputs and waits for the Crew to complete before returning the result. + // - WaitForCrewCompletion: Waits for the specified Crew kickoff to complete and returns the result. + // - GetCrewKickoffStatus: Gets the status of the specified Crew kickoff. + var crewPlugin = crew.CreateKernelPlugin( + name: "EnterpriseContentMarketingCrew", + description: "Conducts thorough research on the specified company and topic to identify emerging trends, analyze competitor strategies, and gather data-driven insights.", + inputMetadata: crewPluginDefinitions); + + // Add the plugin to the Kernel + kernel.Plugins.Add(crewPlugin); + + // Invoke the CrewAI Plugin directly as shown below, or use automaic function calling with an LLM. + var kickoffAndWaitFunction = crewPlugin["KickoffAndWait"]; + var result = await kernel.InvokeAsync( + function: kickoffAndWaitFunction, + arguments: new() + { + ["company"] = "CrewAI", + ["topic"] = "Consumer AI Products" + }); + + Console.WriteLine(result); + } +} diff --git a/dotnet/samples/Concepts/README.md b/dotnet/samples/Concepts/README.md index 1fb0d0ffe9d6..4a91b29c1ead 100644 --- a/dotnet/samples/Concepts/README.md +++ b/dotnet/samples/Concepts/README.md @@ -166,6 +166,7 @@ dotnet test -l "console;verbosity=detailed" --filter "FullyQualifiedName=ChatCom - [CreatePluginFromOpenApiSpec_Jira](https://github.com/microsoft/semantic-kernel/blob/main/dotnet/samples/Concepts/Plugins/CreatePluginFromOpenApiSpec_Jira.cs) - [CreatePluginFromOpenApiSpec_Klarna](https://github.com/microsoft/semantic-kernel/blob/main/dotnet/samples/Concepts/Plugins/CreatePluginFromOpenApiSpec_Klarna.cs) - [CreatePluginFromOpenApiSpec_RepairService](https://github.com/microsoft/semantic-kernel/blob/main/dotnet/samples/Concepts/Plugins/CreatePluginFromOpenApiSpec_RepairService.cs) +- [CrewAI_Plugin](https://github.com/microsoft/semantic-kernel/blob/main/dotnet/samples/Concepts/Plugins/CrewAI_Plugin.cs) - [OpenApiPlugin_PayloadHandling](https://github.com/microsoft/semantic-kernel/blob/main/dotnet/samples/Concepts/Plugins/OpenApiPlugin_PayloadHandling.cs) - [OpenApiPlugin_CustomHttpContentReader](https://github.com/microsoft/semantic-kernel/blob/main/dotnet/samples/Concepts/Plugins/OpenApiPlugin_CustomHttpContentReader.cs) - [OpenApiPlugin_Customization](https://github.com/microsoft/semantic-kernel/blob/main/dotnet/samples/Concepts/Plugins/OpenApiPlugin_Customization.cs) diff --git a/dotnet/src/InternalUtilities/samples/InternalUtilities/TestConfiguration.cs b/dotnet/src/InternalUtilities/samples/InternalUtilities/TestConfiguration.cs index 6ca016248073..567469a8cefe 100644 --- a/dotnet/src/InternalUtilities/samples/InternalUtilities/TestConfiguration.cs +++ b/dotnet/src/InternalUtilities/samples/InternalUtilities/TestConfiguration.cs @@ -49,6 +49,8 @@ public static void Initialize(IConfigurationRoot configRoot) public static VertexAIConfig VertexAI => LoadSection(); public static AzureCosmosDbMongoDbConfig AzureCosmosDbMongoDb => LoadSection(); + public static CrewAIConfig CrewAI => LoadSection(); + private static T LoadSection([CallerMemberName] string? caller = null) { if (s_instance is null) @@ -309,4 +311,10 @@ public MsGraphConfiguration( this.RedirectUri = redirectUri; } } + + public class CrewAIConfig + { + public string Endpoint { get; set; } + public string AuthToken { get; set; } + } } diff --git a/dotnet/src/Plugins/Plugins.AI.UnitTests/CrewAI/CrewAIEnterpriseClientTests.cs b/dotnet/src/Plugins/Plugins.AI.UnitTests/CrewAI/CrewAIEnterpriseClientTests.cs new file mode 100644 index 000000000000..f49fa4ddce0d --- /dev/null +++ b/dotnet/src/Plugins/Plugins.AI.UnitTests/CrewAI/CrewAIEnterpriseClientTests.cs @@ -0,0 +1,151 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.SemanticKernel.Plugins.AI.CrewAI; +using Moq; +using Moq.Protected; +using Xunit; + +namespace SemanticKernel.Plugins.AI.UnitTests.CrewAI; + +/// +/// Tests for the class. +/// +public sealed partial class CrewAIEnterpriseClientTests +{ + private readonly Mock _httpMessageHandlerMock; + private readonly CrewAIEnterpriseClient _client; + + /// + /// Initializes a new instance of the class. + /// + public CrewAIEnterpriseClientTests() + { + this._httpMessageHandlerMock = new Mock(); + using var httpClientFactory = new MockHttpClientFactory(this._httpMessageHandlerMock); + this._client = new CrewAIEnterpriseClient( + endpoint: new Uri("http://example.com"), + authTokenProvider: () => Task.FromResult("token"), + httpClientFactory); + } + + /// + /// Tests that returns the required inputs from the CrewAI API. + /// + /// + [Fact] + public async Task GetInputsAsyncReturnsCrewAIRequiredInputsAsync() + { + // Arrange + var responseContent = "{\"inputs\": [\"input1\", \"input2\"]}"; + using var responseMessage = new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent(responseContent) + }; + + this._httpMessageHandlerMock.Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(responseMessage); + + // Act + var result = await this._client.GetInputsAsync(); + + // Assert + Assert.NotNull(result); + Assert.Equal(2, result.Inputs.Count); + Assert.Contains("input1", result.Inputs); + Assert.Contains("input2", result.Inputs); + } + + /// + /// Tests that returns the kickoff id from the CrewAI API. + /// + /// + [Fact] + public async Task KickoffAsyncReturnsCrewAIKickoffResponseAsync() + { + // Arrange + var responseContent = "{\"kickoff_id\": \"12345\"}"; + using var responseMessage = new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent(responseContent) + }; + + this._httpMessageHandlerMock.Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(responseMessage); + + // Act + var result = await this._client.KickoffAsync(new { key = "value" }); + + // Assert + Assert.NotNull(result); + Assert.Equal("12345", result.KickoffId); + } + + /// + /// Tests that returns the status of the CrewAI Crew. + /// + /// + /// + /// + [Theory] + [InlineData(CrewAIKickoffState.Pending)] + [InlineData(CrewAIKickoffState.Started)] + [InlineData(CrewAIKickoffState.Running)] + [InlineData(CrewAIKickoffState.Success)] + [InlineData(CrewAIKickoffState.Failed)] + [InlineData(CrewAIKickoffState.Failure)] + [InlineData(CrewAIKickoffState.NotFound)] + public async Task GetStatusAsyncReturnsCrewAIStatusResponseAsync(CrewAIKickoffState state) + { + var crewAIStatusState = state switch + { + CrewAIKickoffState.Pending => "PENDING", + CrewAIKickoffState.Started => "STARTED", + CrewAIKickoffState.Running => "RUNNING", + CrewAIKickoffState.Success => "SUCCESS", + CrewAIKickoffState.Failed => "FAILED", + CrewAIKickoffState.Failure => "FAILURE", + CrewAIKickoffState.NotFound => "NOT FOUND", + _ => throw new ArgumentOutOfRangeException(nameof(state), state, null) + }; + + // Arrange + var responseContent = $"{{\"state\": \"{crewAIStatusState}\", \"result\": \"The Result\", \"last_step\": {{\"step1\": \"value1\"}}}}"; + using var responseMessage = new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent(responseContent) + }; + + this._httpMessageHandlerMock.Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(responseMessage); + + // Act + var result = await this._client.GetStatusAsync("12345"); + + // Assert + Assert.NotNull(result); + Assert.Equal(state, result.State); + Assert.Equal("The Result", result.Result); + Assert.NotNull(result.LastStep); + Assert.Equal("value1", result.LastStep["step1"].ToString()); + } +} diff --git a/dotnet/src/Plugins/Plugins.AI.UnitTests/CrewAI/CrewAIEnterpriseTests.cs b/dotnet/src/Plugins/Plugins.AI.UnitTests/CrewAI/CrewAIEnterpriseTests.cs new file mode 100644 index 000000000000..635e8f63700a --- /dev/null +++ b/dotnet/src/Plugins/Plugins.AI.UnitTests/CrewAI/CrewAIEnterpriseTests.cs @@ -0,0 +1,150 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Plugins.AI.CrewAI; +using Moq; +using Xunit; + +namespace SemanticKernel.Plugins.UnitTests.AI.CrewAI; + +/// +/// Unit tests for the class. +/// +public sealed class CrewAIEnterpriseTests +{ + private readonly Mock _mockClient; + private readonly CrewAIEnterprise _crewAIEnterprise; + + /// + /// Initializes a new instance of the class. + /// + public CrewAIEnterpriseTests() + { + this._mockClient = new Mock(MockBehavior.Strict); + this._crewAIEnterprise = new CrewAIEnterprise(this._mockClient.Object, NullLoggerFactory.Instance); + } + + /// + /// Tests the successful kickoff of a CrewAI task. + /// + [Fact] + public async Task KickoffAsyncSuccessAsync() + { + // Arrange + var response = new CrewAIKickoffResponse { KickoffId = "12345" }; + this._mockClient.Setup(client => client.KickoffAsync(It.IsAny(), null, null, null, It.IsAny())) + .ReturnsAsync(response); + + // Act + var result = await this._crewAIEnterprise.KickoffAsync(new { }); + + // Assert + Assert.Equal("12345", result); + } + + /// + /// Tests the failure of a CrewAI task kickoff. + /// + [Fact] + public async Task KickoffAsyncFailureAsync() + { + // Arrange + this._mockClient.Setup(client => client.KickoffAsync(It.IsAny(), null, null, null, It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Kickoff failed")); + + // Act & Assert + await Assert.ThrowsAsync(() => this._crewAIEnterprise.KickoffAsync(new { })); + } + + /// + /// Tests the successful retrieval of CrewAI task status. + /// + [Fact] + public async Task GetCrewStatusAsyncSuccessAsync() + { + // Arrange + var response = new CrewAIStatusResponse { State = CrewAIKickoffState.Running }; + this._mockClient.Setup(client => client.GetStatusAsync("12345", It.IsAny())) + .ReturnsAsync(response); + + // Act + var result = await this._crewAIEnterprise.GetCrewKickoffStatusAsync("12345"); + + // Assert + Assert.Equal(CrewAIKickoffState.Running, result.State); + } + + /// + /// Tests the failure of CrewAI task status retrieval. + /// + [Fact] + public async Task GetCrewStatusAsyncFailureAsync() + { + // Arrange + this._mockClient.Setup(client => client.GetStatusAsync("12345", It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Status retrieval failed")); + + // Act & Assert + await Assert.ThrowsAsync(() => this._crewAIEnterprise.GetCrewKickoffStatusAsync("12345")); + } + + /// + /// Tests the successful completion of a CrewAI task. + /// + [Fact] + public async Task WaitForCrewCompletionAsyncSuccessAsync() + { + // Arrange + var response = new CrewAIStatusResponse { State = CrewAIKickoffState.Success, Result = "Completed" }; + this._mockClient.SetupSequence(client => client.GetStatusAsync("12345", It.IsAny())) + .ReturnsAsync(new CrewAIStatusResponse { State = CrewAIKickoffState.Running }) + .ReturnsAsync(response); + + // Act + var result = await this._crewAIEnterprise.WaitForCrewCompletionAsync("12345"); + + // Assert + Assert.Equal("Completed", result); + } + + /// + /// Tests the failure of a CrewAI task completion. + /// + [Fact] + public async Task WaitForCrewCompletionAsyncFailureAsync() + { + // Arrange + var response = new CrewAIStatusResponse { State = CrewAIKickoffState.Failed, Result = "Error" }; + this._mockClient.SetupSequence(client => client.GetStatusAsync("12345", It.IsAny())) + .ReturnsAsync(new CrewAIStatusResponse { State = CrewAIKickoffState.Running }) + .ReturnsAsync(response); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => this._crewAIEnterprise.WaitForCrewCompletionAsync("12345")); + } + + /// + /// Tests the successful creation of a Kernel plugin. + /// + [Fact] + public void CreateKernelPluginSuccess() + { + // Arrange + var inputDefinitions = new List + { + new("input1", "description1", typeof(string)) + }; + + // Act + var plugin = this._crewAIEnterprise.CreateKernelPlugin("TestPlugin", "Test Description", inputDefinitions); + + // Assert + Assert.NotNull(plugin); + Assert.Equal("TestPlugin", plugin.Name); + } +} diff --git a/dotnet/src/Plugins/Plugins.AI.UnitTests/CrewAI/MockHttpClientFactory.cs b/dotnet/src/Plugins/Plugins.AI.UnitTests/CrewAI/MockHttpClientFactory.cs new file mode 100644 index 000000000000..fb37715e604f --- /dev/null +++ b/dotnet/src/Plugins/Plugins.AI.UnitTests/CrewAI/MockHttpClientFactory.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Net.Http; +using Moq; + +namespace SemanticKernel.Plugins.AI.UnitTests.CrewAI; + +/// +/// Implementation of which uses the . +/// +internal sealed class MockHttpClientFactory(Mock mockHandler) : IHttpClientFactory, IDisposable +{ + public HttpClient CreateClient(string name) + { + return new(mockHandler.Object); + } + + public void Dispose() + { + mockHandler.Object.Dispose(); + GC.SuppressFinalize(this); + } +} diff --git a/dotnet/src/Plugins/Plugins.AI.UnitTests/Plugins.AI.UnitTests.csproj b/dotnet/src/Plugins/Plugins.AI.UnitTests/Plugins.AI.UnitTests.csproj new file mode 100644 index 000000000000..00d08ca13f1a --- /dev/null +++ b/dotnet/src/Plugins/Plugins.AI.UnitTests/Plugins.AI.UnitTests.csproj @@ -0,0 +1,37 @@ + + + + SemanticKernel.Plugins.AI.UnitTests + SemanticKernel.Plugins.AI.UnitTests + net8.0 + true + enable + disable + false + $(NoWarn);CA2007,VSTHRD111,SKEXP0001,SKEXP0050 + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + diff --git a/dotnet/src/Plugins/Plugins.AI/AssemblyInfo.cs b/dotnet/src/Plugins/Plugins.AI/AssemblyInfo.cs new file mode 100644 index 000000000000..0aef47e394f8 --- /dev/null +++ b/dotnet/src/Plugins/Plugins.AI/AssemblyInfo.cs @@ -0,0 +1,6 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; + +// This assembly is currently experimental. +[assembly: Experimental("SKEXP0050")] diff --git a/dotnet/src/Plugins/Plugins.AI/CrewAI/Client/CrewAIEnterpriseClient.cs b/dotnet/src/Plugins/Plugins.AI/CrewAI/Client/CrewAIEnterpriseClient.cs new file mode 100644 index 000000000000..be2822d3e85e --- /dev/null +++ b/dotnet/src/Plugins/Plugins.AI/CrewAI/Client/CrewAIEnterpriseClient.cs @@ -0,0 +1,164 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Net.Http; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.SemanticKernel.Http; + +namespace Microsoft.SemanticKernel.Plugins.AI.CrewAI; + +/// +/// Internal interface used for mocking and testing. +/// +internal interface ICrewAIEnterpriseClient +{ + Task GetInputsAsync(CancellationToken cancellationToken = default); + Task KickoffAsync( + object? inputs, + string? taskWebhookUrl = null, + string? stepWebhookUrl = null, + string? crewWebhookUrl = null, + CancellationToken cancellationToken = default); + Task GetStatusAsync(string taskId, CancellationToken cancellationToken = default); +} + +/// +/// A client for interacting with the CrewAI Enterprise API. +/// +internal class CrewAIEnterpriseClient : ICrewAIEnterpriseClient +{ + private readonly Uri _endpoint; + private readonly Func> _authTokenProvider; + private readonly IHttpClientFactory? _httpClientFactory; + + public CrewAIEnterpriseClient(Uri endpoint, Func> authTokenProvider, IHttpClientFactory? clientFactory = null) + { + Verify.NotNull(endpoint, nameof(endpoint)); + Verify.NotNull(authTokenProvider, nameof(authTokenProvider)); + + this._endpoint = endpoint; + this._authTokenProvider = authTokenProvider; + this._httpClientFactory = clientFactory; + } + + /// + /// Get the inputs required for the Crew to kickoff. + /// + /// A + /// Aninstance of describing the required inputs. + /// + public async Task GetInputsAsync(CancellationToken cancellationToken = default) + { + try + { + using var client = await this.CreateHttpClientAsync().ConfigureAwait(false); + using var requestMessage = HttpRequest.CreateGetRequest("/inputs"); + using var response = await client.SendWithSuccessCheckAsync(requestMessage, cancellationToken) + .ConfigureAwait(false); + + var body = await response.Content.ReadAsStringWithExceptionMappingAsync(cancellationToken) + .ConfigureAwait(false); + + var requirements = JsonSerializer.Deserialize(body); + + return requirements ?? throw new KernelException(message: $"Failed to deserialize requirements from CrewAI. Response: {body}"); + } + catch (Exception ex) when (ex is not KernelException) + { + throw new KernelException(message: "Failed to get required inputs for CrewAI Crew.", innerException: ex); + } + } + + /// + /// Kickoff the Crew. + /// + /// An object containing key value pairs matching the required inputs of the Crew. + /// The task level webhook Uri. + /// The step level webhook Uri. + /// The crew level webhook Uri. + /// A + /// A string containing the Id of the started Crew Task. + public async Task KickoffAsync( + object? inputs, + string? taskWebhookUrl = null, + string? stepWebhookUrl = null, + string? crewWebhookUrl = null, + CancellationToken cancellationToken = default) + { + try + { + var content = new + { + inputs, + taskWebhookUrl, + stepWebhookUrl, + crewWebhookUrl + }; + + using var client = await this.CreateHttpClientAsync().ConfigureAwait(false); + using var requestMessage = HttpRequest.CreatePostRequest("/kickoff", content); + using var response = await client.SendWithSuccessCheckAsync(requestMessage, cancellationToken) + .ConfigureAwait(false); + + var body = await response.Content.ReadAsStringWithExceptionMappingAsync(cancellationToken) + .ConfigureAwait(false); + + var kickoffResponse = JsonSerializer.Deserialize(body); + return kickoffResponse ?? throw new KernelException(message: $"Failed to deserialize kickoff response from CrewAI. Response: {body}"); + } + catch (Exception ex) when (ex is not KernelException) + { + throw new KernelException(message: "Failed to kickoff CrewAI Crew.", innerException: ex); + } + } + + /// + /// Get the status of the Crew Task. + /// + /// The Id of the task. + /// A + /// A string containing the status or final result of the Crew task. + /// + public async Task GetStatusAsync(string taskId, CancellationToken cancellationToken = default) + { + try + { + using var client = await this.CreateHttpClientAsync().ConfigureAwait(false); + using var requestMessage = HttpRequest.CreateGetRequest($"/status/{taskId}"); + using var response = await client.SendWithSuccessCheckAsync(requestMessage, cancellationToken) + .ConfigureAwait(false); + + var body = await response.Content.ReadAsStringWithExceptionMappingAsync(cancellationToken) + .ConfigureAwait(false); + + var statusResponse = JsonSerializer.Deserialize(body); + + return statusResponse ?? throw new KernelException(message: $"Failed to deserialize status response from CrewAI. Response: {body}"); + } + catch (Exception ex) when (ex is not KernelException) + { + throw new KernelException(message: "Failed to status of CrewAI Crew.", innerException: ex); + } + } + + #region Private Methods + + private async Task CreateHttpClientAsync() + { + var authToken = await this._authTokenProvider().ConfigureAwait(false); + + if (string.IsNullOrWhiteSpace(authToken)) + { + throw new KernelException(message: "Failed to get auth token for CrewAI."); + } + + var client = this._httpClientFactory?.CreateClient() ?? new(); + client.DefaultRequestHeaders.Add("Authorization", $"Bearer {authToken}"); + client.BaseAddress = this._endpoint; + return client; + } + + #endregion +} diff --git a/dotnet/src/Plugins/Plugins.AI/CrewAI/Client/CrewAIStateEnumConverter.cs b/dotnet/src/Plugins/Plugins.AI/CrewAI/Client/CrewAIStateEnumConverter.cs new file mode 100644 index 000000000000..93e65b166d21 --- /dev/null +++ b/dotnet/src/Plugins/Plugins.AI/CrewAI/Client/CrewAIStateEnumConverter.cs @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.SemanticKernel.Plugins.AI.CrewAI; + +#pragma warning disable CA1812 // Avoid uninstantiated internal classes +internal sealed class CrewAIStateEnumConverter : JsonConverter +{ + public override CrewAIKickoffState Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + string? stringValue = reader.GetString(); + return stringValue?.ToUpperInvariant() switch + { + "PENDING" => CrewAIKickoffState.Pending, + "STARTED" => CrewAIKickoffState.Started, + "RUNNING" => CrewAIKickoffState.Running, + "SUCCESS" => CrewAIKickoffState.Success, + "FAILED" => CrewAIKickoffState.Failed, + "FAILURE" => CrewAIKickoffState.Failure, + "NOT FOUND" => CrewAIKickoffState.NotFound, + _ => throw new KernelException("Failed to parse Crew AI kickoff state.") + }; + } + + public override void Write(Utf8JsonWriter writer, CrewAIKickoffState value, JsonSerializerOptions options) + { + string stringValue = value switch + { + CrewAIKickoffState.Pending => "PENDING", + CrewAIKickoffState.Started => "STARTED", + CrewAIKickoffState.Running => "RUNNING", + CrewAIKickoffState.Success => "SUCCESS", + CrewAIKickoffState.Failed => "FAILED", + CrewAIKickoffState.Failure => "FAILURE", + CrewAIKickoffState.NotFound => "NOT FOUND", + _ => throw new KernelException("Failed to parse Crew AI kickoff state.") + }; + writer.WriteStringValue(stringValue); + } +} +#pragma warning restore CA1812 // Avoid uninstantiated internal classes diff --git a/dotnet/src/Plugins/Plugins.AI/CrewAI/CrewAIEnterprise.cs b/dotnet/src/Plugins/Plugins.AI/CrewAI/CrewAIEnterprise.cs new file mode 100644 index 000000000000..615f6a14c832 --- /dev/null +++ b/dotnet/src/Plugins/Plugins.AI/CrewAI/CrewAIEnterprise.cs @@ -0,0 +1,282 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Net.Http; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Microsoft.SemanticKernel.Plugins.AI.CrewAI; + +/// +/// A plugin for interacting with the a CrewAI Crew via the Enterprise APIs. +/// +public class CrewAIEnterprise +{ + private readonly ICrewAIEnterpriseClient _crewClient; + private readonly ILogger _logger; + private readonly TimeSpan _pollingInterval; + + /// + /// The name of the kickoff function. + /// + public const string KickoffFunctionName = "KickoffCrew"; + + /// + /// The name of the kickoff and wait function. + /// + public const string KickoffAndWaitFunctionName = "KickoffAndWait"; + + /// + /// Initializes a new instance of the class. + /// + /// The base URI of the CrewAI Crew + /// Optional provider for auth token generation. + /// The HTTP client factory. + /// The logger factory. + /// Defines the delay time between status calls when pollin for a kickoff to complete. + public CrewAIEnterprise(Uri endpoint, Func> authTokenProvider, IHttpClientFactory? httpClientFactory = null, ILoggerFactory? loggerFactory = null, TimeSpan? pollingInterval = default) + { + Verify.NotNull(endpoint, nameof(endpoint)); + Verify.NotNull(authTokenProvider, nameof(authTokenProvider)); + + this._crewClient = new CrewAIEnterpriseClient(endpoint, authTokenProvider, httpClientFactory); + this._logger = loggerFactory?.CreateLogger(typeof(CrewAIEnterprise)) ?? NullLogger.Instance; + this._pollingInterval = pollingInterval ?? TimeSpan.FromSeconds(1); + } + + /// + /// Internal constructor used for testing purposes. + /// + internal CrewAIEnterprise(ICrewAIEnterpriseClient crewClient, ILoggerFactory? loggerFactory = null) + { + Verify.NotNull(crewClient, nameof(crewClient)); + this._crewClient = crewClient; + this._logger = loggerFactory?.CreateLogger(typeof(CrewAIEnterprise)) ?? NullLogger.Instance; + } + + /// + /// Kicks off (starts) a CrewAI Crew with the given inputs and callbacks. + /// + /// An object containing key value pairs matching the required inputs of the Crew. + /// The task level webhook Uri. + /// The step level webhook Uri. + /// The crew level webhook Uri. + /// The Id of the scheduled kickoff. + /// + public async Task KickoffAsync( + object? inputs, + Uri? taskWebhookUrl = null, + Uri? stepWebhookUrl = null, + Uri? crewWebhookUrl = null) + { + try + { + CrewAIKickoffResponse kickoffTask = await this._crewClient.KickoffAsync( + inputs: inputs, + taskWebhookUrl: taskWebhookUrl?.AbsoluteUri, + stepWebhookUrl: stepWebhookUrl?.AbsoluteUri, + crewWebhookUrl: crewWebhookUrl?.AbsoluteUri) + .ConfigureAwait(false); + + this._logger.LogInformation("CrewAI Crew kicked off with Id: {KickoffId}", kickoffTask.KickoffId); + return kickoffTask.KickoffId; + } + catch (Exception ex) + { + throw new KernelException(message: "Failed to kickoff CrewAI Crew.", innerException: ex); + } + } + + /// + /// Gets the current status of the CrewAI Crew kickoff. + /// + /// The Id of the Crew kickoff. + /// A + /// " + [KernelFunction] + [Description("Gets the current status of the CrewAI Crew kickoff.")] + public async Task GetCrewKickoffStatusAsync([Description("The Id of the kickoff")] string kickoffId) + { + Verify.NotNullOrWhiteSpace(kickoffId, nameof(kickoffId)); + + try + { + CrewAIStatusResponse statusResponse = await this._crewClient.GetStatusAsync(kickoffId).ConfigureAwait(false); + + this._logger.LogInformation("CrewAI Crew status for kickoff Id: {KickoffId} is {Status}", kickoffId, statusResponse.State); + return statusResponse; + } + catch (Exception ex) + { + throw new KernelException(message: $"Failed to get status of CrewAI Crew with kickoff Id: {kickoffId}.", innerException: ex); + } + } + + /// + /// Waits for the Crew kickoff to complete and returns the result. + /// + /// The Id of the crew kickoff. + /// The result of the Crew kickoff. + /// + [KernelFunction] + [Description("Waits for the Crew kickoff to complete and returns the result.")] + public async Task WaitForCrewCompletionAsync([Description("The Id of the kickoff")] string kickoffId) + { + Verify.NotNullOrWhiteSpace(kickoffId, nameof(kickoffId)); + + try + { + CrewAIStatusResponse? statusResponse = null; + var status = CrewAIKickoffState.Pending; + do + { + this._logger.LogInformation("Waiting for CrewAI Crew with kickoff Id: {KickoffId} to complete. Current state: {Status}", kickoffId, status); + await Task.Delay(TimeSpan.FromSeconds(1)).ConfigureAwait(false); + statusResponse = await this._crewClient.GetStatusAsync(kickoffId).ConfigureAwait(false); + status = statusResponse.State; + } + while (!this.IsTerminalState(status)); + + this._logger.LogInformation("CrewAI Crew with kickoff Id: {KickoffId} completed with status: {Status}", kickoffId, statusResponse.State); + + return status switch + { + CrewAIKickoffState.Failed => throw new KernelException(message: $"CrewAI Crew failed with error: {statusResponse.Result}"), + CrewAIKickoffState.Success => statusResponse.Result ?? string.Empty, + _ => throw new KernelException(message: "Failed to parse unexpected response from CrewAI status response."), + }; + } + catch (Exception ex) + { + throw new KernelException(message: $"Failed to wait for completion of CrewAI Crew with kickoff Id: {kickoffId}.", innerException: ex); + } + } + + /// + /// Creates a that can be used to invoke the CrewAI Crew. + /// + /// The name of the + /// The description of the + /// The definitions of the Crew's required inputs. + /// The task level webhook Uri + /// The step level webhook Uri + /// The crew level webhook Uri + /// A that can invoke the Crew. + /// + public KernelPlugin CreateKernelPlugin( + string name, + string description, + IEnumerable? inputMetadata, + Uri? taskWebhookUrl = null, + Uri? stepWebhookUrl = null, + Uri? crewWebhookUrl = null) + { + var options = new KernelFunctionFromMethodOptions() + { + Parameters = inputMetadata?.Select(i => new KernelParameterMetadata(i.Name) { Description = i.Description, IsRequired = true, ParameterType = i.Type }) ?? [], + ReturnParameter = new() { ParameterType = typeof(string) }, + }; + + // Define the kernel function implementation for kickoff + [KernelFunction(KickoffFunctionName)] + [Description("kicks off the CrewAI Crew and returns the Id of the scheduled kickoff.")] + async Task KickoffAsync(KernelArguments arguments) + { + Dictionary args = BuildArguments(inputMetadata, arguments); + + return await this.KickoffAsync( + inputs: args, + taskWebhookUrl: taskWebhookUrl, + stepWebhookUrl: stepWebhookUrl, + crewWebhookUrl: crewWebhookUrl) + .ConfigureAwait(false); + } + + // Define the kernel function implementation for kickoff and wait for result + [KernelFunction(KickoffAndWaitFunctionName)] + [Description("kicks off the CrewAI Crew, waits for it to complete, and returns the result.")] + async Task KickoffAndWaitAsync(KernelArguments arguments) + { + Dictionary args = BuildArguments(inputMetadata, arguments); + + var kickoffId = await this.KickoffAsync( + inputs: args, + taskWebhookUrl: taskWebhookUrl, + stepWebhookUrl: stepWebhookUrl, + crewWebhookUrl: crewWebhookUrl) + .ConfigureAwait(false); + + return await this.WaitForCrewCompletionAsync(kickoffId).ConfigureAwait(false); + } + + return KernelPluginFactory.CreateFromFunctions( + name, + description, + [ + KernelFunctionFactory.CreateFromMethod(KickoffAsync, new(), options), + KernelFunctionFactory.CreateFromMethod(KickoffAndWaitAsync, new(), options), + KernelFunctionFactory.CreateFromMethod(this.GetCrewKickoffStatusAsync), + KernelFunctionFactory.CreateFromMethod(this.WaitForCrewCompletionAsync) + ]); + } + + #region Private Methods + + /// + /// Determines if the Crew kikoff state is terminal. + /// + /// The state of the crew kickoff + /// A indicating if the state is a terminal state. + private bool IsTerminalState(CrewAIKickoffState state) + { + return state == CrewAIKickoffState.Failed || state == CrewAIKickoffState.Failure || state == CrewAIKickoffState.Success || state == CrewAIKickoffState.NotFound; + } + + private static Dictionary BuildArguments(IEnumerable? inputMetadata, KernelArguments arguments) + { + // Extract the required arguments from the KernelArguments by name + Dictionary args = []; + if (inputMetadata is not null) + { + foreach (var input in inputMetadata) + { + // If a required argument is missing, throw an exception + if (!arguments.TryGetValue(input.Name, out object? value) || value is null || value is not string strValue) + { + throw new KernelException(message: $"Missing required input '{input.Name}' for CrewAI."); + } + + // Since this KernelFunction does not have explicit parameters all the relevant inputs are passed as strings. + // We need to convert the inputs to the expected types. + if (input.Type == typeof(string)) + { + args.Add(input.Name, value); + } + else + { + // Try to get a converter for the input type + var converter = TypeConverterFactory.GetTypeConverter(input.Type); + if (converter is not null) + { + args.Add(input.Name, converter.ConvertFrom(value)); + } + else + { + // Try to deserialize the input as a JSON object + var objValue = JsonSerializer.Deserialize(strValue, input.Type); + args.Add(input.Name, objValue); + } + } + } + } + + return args; + } + + #endregion +} diff --git a/dotnet/src/Plugins/Plugins.AI/CrewAI/CrewAIInputMetadata.cs b/dotnet/src/Plugins/Plugins.AI/CrewAI/CrewAIInputMetadata.cs new file mode 100644 index 000000000000..dab170ceabf5 --- /dev/null +++ b/dotnet/src/Plugins/Plugins.AI/CrewAI/CrewAIInputMetadata.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.SemanticKernel.Plugins.AI.CrewAI; + +/// +/// The metadata associated with an input required by the CrewAI Crew. This metadata provides the information required to effectively describe the inputs to an LLM. +/// +/// The name of the input +/// The description of the input. This is used to help the LLM understand the correct usage of the input. +/// The of the input. +public record CrewAIInputMetadata(string Name, string Description, Type Type) +{ +} diff --git a/dotnet/src/Plugins/Plugins.AI/CrewAI/Models/CrewAIKickoffResponse.cs b/dotnet/src/Plugins/Plugins.AI/CrewAI/Models/CrewAIKickoffResponse.cs new file mode 100644 index 000000000000..949aea64a800 --- /dev/null +++ b/dotnet/src/Plugins/Plugins.AI/CrewAI/Models/CrewAIKickoffResponse.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.SemanticKernel.Plugins.AI.CrewAI; + +/// +/// Models the response object of a call to kickoff a CrewAI Crew. +/// +#pragma warning disable CA1812 // Avoid uninstantiated internal classes +internal sealed class CrewAIKickoffResponse +{ + [JsonPropertyName("kickoff_id")] + public string KickoffId { get; set; } = string.Empty; +} +#pragma warning restore CA1812 // Avoid uninstantiated internal classes diff --git a/dotnet/src/Plugins/Plugins.AI/CrewAI/Models/CrewAIKickoffState.cs b/dotnet/src/Plugins/Plugins.AI/CrewAI/Models/CrewAIKickoffState.cs new file mode 100644 index 000000000000..7ef9b9688928 --- /dev/null +++ b/dotnet/src/Plugins/Plugins.AI/CrewAI/Models/CrewAIKickoffState.cs @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.SemanticKernel.Plugins.AI.CrewAI; + +/// +/// Represents the state of a CrewAI Crew kickoff. +/// +public enum CrewAIKickoffState +{ + /// + /// The kickoff is pending and has not started yet. + /// + Pending, + + /// + /// The kickoff has started. + /// + Started, + + /// + /// The kickoff is currently running. + /// + Running, + + /// + /// The kickoff completed successfully. + /// + Success, + + /// + /// The kickoff failed. + /// + Failed, + + /// + /// The kickoff has failed. + /// + Failure, + + /// + /// The kickoff was not found. + /// + NotFound +} diff --git a/dotnet/src/Plugins/Plugins.AI/CrewAI/Models/CrewAIRequiredInputs.cs b/dotnet/src/Plugins/Plugins.AI/CrewAI/Models/CrewAIRequiredInputs.cs new file mode 100644 index 000000000000..b9154e8b334c --- /dev/null +++ b/dotnet/src/Plugins/Plugins.AI/CrewAI/Models/CrewAIRequiredInputs.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Microsoft.SemanticKernel.Plugins.AI.CrewAI; + +/// +/// Represents the requirements for kicking off a CrewAI Crew. +/// +public class CrewAIRequiredInputs +{ + /// + /// The inputs required for the Crew. + /// + [JsonPropertyName("inputs")] + public IList Inputs { get; set; } = []; +} diff --git a/dotnet/src/Plugins/Plugins.AI/CrewAI/Models/CrewAIStatusResponse.cs b/dotnet/src/Plugins/Plugins.AI/CrewAI/Models/CrewAIStatusResponse.cs new file mode 100644 index 000000000000..5d31a2740f09 --- /dev/null +++ b/dotnet/src/Plugins/Plugins.AI/CrewAI/Models/CrewAIStatusResponse.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Microsoft.SemanticKernel.Plugins.AI.CrewAI; + +/// +/// Models the response object of a call to get the state of a CrewAI Crew kickoff. +/// +public class CrewAIStatusResponse +{ + /// + /// The current state of the CrewAI Crew kickoff. + /// + [JsonPropertyName("state")] + [JsonConverter(typeof(CrewAIStateEnumConverter))] + public CrewAIKickoffState State { get; set; } + + /// + /// The result of the CrewAI Crew kickoff. + /// + [JsonPropertyName("result")] + public string? Result { get; set; } + + /// + /// The last step of the CrewAI Crew kickoff. + /// + [JsonPropertyName("last_step")] + public Dictionary? LastStep { get; set; } +} diff --git a/dotnet/src/Plugins/Plugins.AI/Plugins.AI.csproj b/dotnet/src/Plugins/Plugins.AI/Plugins.AI.csproj new file mode 100644 index 000000000000..472d0d6b3c2f --- /dev/null +++ b/dotnet/src/Plugins/Plugins.AI/Plugins.AI.csproj @@ -0,0 +1,34 @@ + + + + + Microsoft.SemanticKernel.Plugins.AI + $(AssemblyName) + net8.0;netstandard2.0 + alpha + + + + + + + + Semantic Kernel - AI Plugins + Semantic Kernel AI plugins. + + + + + + + + + + + + + + + + +