Skip to content

Commit

Permalink
.Net: CrewAI Plugin (#10363)
Browse files Browse the repository at this point in the history
This PR adds a new plugin to the Semantic Kernel that can interact with
and invoke CrewAI Crews that have been deployed to the CrewAI Enterprise
service.

### Contribution Checklist

<!-- Before submitting this PR, please make sure: -->

- [x] The code builds clean without any errors or warnings
- [x] The PR follows the [SK Contribution
Guidelines](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md)
and the [pre-submission formatting
script](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md#development-scripts)
raises no violations
- [x] All unit tests pass, and I have added new tests where possible
- [x] I didn't break anyone 😄

---------

Co-authored-by: Ben Thomas <[email protected]>
Co-authored-by: Dmytro Struk <[email protected]>
  • Loading branch information
3 people authored Feb 4, 2025
1 parent 0b2bd01 commit 2537edb
Show file tree
Hide file tree
Showing 19 changed files with 1,152 additions and 0 deletions.
18 changes: 18 additions & 0 deletions dotnet/SK-dotnet.sln
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}
Expand Down
1 change: 1 addition & 0 deletions dotnet/samples/Concepts/Concepts.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@
<ProjectReference Include="..\..\src\Functions\Functions.Prompty\Functions.Prompty.csproj" />
<ProjectReference Include="..\..\src\Planners\Planners.Handlebars\Planners.Handlebars.csproj" />
<ProjectReference Include="..\..\src\Planners\Planners.OpenAI\Planners.OpenAI.csproj" />
<ProjectReference Include="..\..\src\Plugins\Plugins.AI\Plugins.AI.csproj" />
<ProjectReference Include="..\..\src\Plugins\Plugins.Core\Plugins.Core.csproj" />
<ProjectReference Include="..\..\src\Plugins\Plugins.Memory\Plugins.Memory.csproj" />
<ProjectReference Include="..\..\src\Plugins\Plugins.MsGraph\Plugins.MsGraph.csproj" />
Expand Down
108 changes: 108 additions & 0 deletions dotnet/samples/Concepts/Plugins/CrewAI_Plugin.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
// Copyright (c) Microsoft. All rights reserved.

using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.Plugins.AI.CrewAI;

namespace Plugins;

/// <summary>
/// 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.
/// </summary>
public class CrewAI_Plugin(ITestOutputHelper output) : BaseTest(output)
{
/// <summary>
/// Shows how to kickoff an existing CrewAI Enterprise Crew and wait for it to complete.
/// </summary>
[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);
}

/// <summary>
/// Shows how to kickoff an existing CrewAI Enterprise Crew as a plugin.
/// </summary>
[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);
}
}
1 change: 1 addition & 0 deletions dotnet/samples/Concepts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ public static void Initialize(IConfigurationRoot configRoot)
public static VertexAIConfig VertexAI => LoadSection<VertexAIConfig>();
public static AzureCosmosDbMongoDbConfig AzureCosmosDbMongoDb => LoadSection<AzureCosmosDbMongoDbConfig>();

public static CrewAIConfig CrewAI => LoadSection<CrewAIConfig>();

private static T LoadSection<T>([CallerMemberName] string? caller = null)
{
if (s_instance is null)
Expand Down Expand Up @@ -309,4 +311,10 @@ public MsGraphConfiguration(
this.RedirectUri = redirectUri;
}
}

public class CrewAIConfig
{
public string Endpoint { get; set; }
public string AuthToken { get; set; }
}
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Tests for the <see cref="CrewAIEnterpriseClient"/> class.
/// </summary>
public sealed partial class CrewAIEnterpriseClientTests
{
private readonly Mock<HttpMessageHandler> _httpMessageHandlerMock;
private readonly CrewAIEnterpriseClient _client;

/// <summary>
/// Initializes a new instance of the <see cref="CrewAIEnterpriseClientTests"/> class.
/// </summary>
public CrewAIEnterpriseClientTests()
{
this._httpMessageHandlerMock = new Mock<HttpMessageHandler>();
using var httpClientFactory = new MockHttpClientFactory(this._httpMessageHandlerMock);
this._client = new CrewAIEnterpriseClient(
endpoint: new Uri("http://example.com"),
authTokenProvider: () => Task.FromResult("token"),
httpClientFactory);
}

/// <summary>
/// Tests that <see cref="CrewAIEnterpriseClient.GetInputsAsync"/> returns the required inputs from the CrewAI API.
/// </summary>
/// <returns></returns>
[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<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.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);
}

/// <summary>
/// Tests that <see cref="CrewAIEnterpriseClient.KickoffAsync"/> returns the kickoff id from the CrewAI API.
/// </summary>
/// <returns></returns>
[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<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(responseMessage);

// Act
var result = await this._client.KickoffAsync(new { key = "value" });

// Assert
Assert.NotNull(result);
Assert.Equal("12345", result.KickoffId);
}

/// <summary>
/// Tests that <see cref="CrewAIEnterpriseClient.GetStatusAsync"/> returns the status of the CrewAI Crew.
/// </summary>
/// <param name="state"></param>
/// <returns></returns>
/// <exception cref="ArgumentOutOfRangeException"></exception>
[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<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.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());
}
}
Loading

0 comments on commit 2537edb

Please sign in to comment.