Skip to content

Commit 2537edb

Browse files
alliscodeBen Thomasdmytrostruk
authored
.Net: CrewAI Plugin (#10363)
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]>
1 parent 0b2bd01 commit 2537edb

19 files changed

+1152
-0
lines changed

dotnet/SK-dotnet.sln

+18
Original file line numberDiff line numberDiff line change
@@ -441,6 +441,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "kernel-functions-generator"
441441
EndProject
442442
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Agents.AzureAI", "src\Agents\AzureAI\Agents.AzureAI.csproj", "{EA35F1B5-9148-4189-BE34-5E00AED56D65}"
443443
EndProject
444+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Plugins.AI", "src\Plugins\Plugins.AI\Plugins.AI.csproj", "{0C64EC81-8116-4388-87AD-BA14D4B59974}"
445+
EndProject
446+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Plugins.AI.UnitTests", "src\Plugins\Plugins.AI.UnitTests\Plugins.AI.UnitTests.csproj", "{03ACF9DD-00C9-4F2B-80F1-537E2151AF5F}"
447+
EndProject
444448
Global
445449
GlobalSection(SolutionConfigurationPlatforms) = preSolution
446450
Debug|Any CPU = Debug|Any CPU
@@ -1180,6 +1184,18 @@ Global
11801184
{EA35F1B5-9148-4189-BE34-5E00AED56D65}.Publish|Any CPU.Build.0 = Publish|Any CPU
11811185
{EA35F1B5-9148-4189-BE34-5E00AED56D65}.Release|Any CPU.ActiveCfg = Release|Any CPU
11821186
{EA35F1B5-9148-4189-BE34-5E00AED56D65}.Release|Any CPU.Build.0 = Release|Any CPU
1187+
{0C64EC81-8116-4388-87AD-BA14D4B59974}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
1188+
{0C64EC81-8116-4388-87AD-BA14D4B59974}.Debug|Any CPU.Build.0 = Debug|Any CPU
1189+
{0C64EC81-8116-4388-87AD-BA14D4B59974}.Publish|Any CPU.ActiveCfg = Publish|Any CPU
1190+
{0C64EC81-8116-4388-87AD-BA14D4B59974}.Publish|Any CPU.Build.0 = Publish|Any CPU
1191+
{0C64EC81-8116-4388-87AD-BA14D4B59974}.Release|Any CPU.ActiveCfg = Release|Any CPU
1192+
{0C64EC81-8116-4388-87AD-BA14D4B59974}.Release|Any CPU.Build.0 = Release|Any CPU
1193+
{03ACF9DD-00C9-4F2B-80F1-537E2151AF5F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
1194+
{03ACF9DD-00C9-4F2B-80F1-537E2151AF5F}.Debug|Any CPU.Build.0 = Debug|Any CPU
1195+
{03ACF9DD-00C9-4F2B-80F1-537E2151AF5F}.Publish|Any CPU.ActiveCfg = Debug|Any CPU
1196+
{03ACF9DD-00C9-4F2B-80F1-537E2151AF5F}.Publish|Any CPU.Build.0 = Debug|Any CPU
1197+
{03ACF9DD-00C9-4F2B-80F1-537E2151AF5F}.Release|Any CPU.ActiveCfg = Release|Any CPU
1198+
{03ACF9DD-00C9-4F2B-80F1-537E2151AF5F}.Release|Any CPU.Build.0 = Release|Any CPU
11831199
EndGlobalSection
11841200
GlobalSection(SolutionProperties) = preSolution
11851201
HideSolutionNode = FALSE
@@ -1342,6 +1358,8 @@ Global
13421358
{2EB6E4C2-606D-B638-2E08-49EA2061C428} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
13431359
{78785CB1-66CF-4895-D7E5-A440DD84BE86} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
13441360
{EA35F1B5-9148-4189-BE34-5E00AED56D65} = {6823CD5E-2ABE-41EB-B865-F86EC13F0CF9}
1361+
{0C64EC81-8116-4388-87AD-BA14D4B59974} = {D6D598DF-C17C-46F4-B2B9-CDE82E2DE132}
1362+
{03ACF9DD-00C9-4F2B-80F1-537E2151AF5F} = {D6D598DF-C17C-46F4-B2B9-CDE82E2DE132}
13451363
EndGlobalSection
13461364
GlobalSection(ExtensibilityGlobals) = postSolution
13471365
SolutionGuid = {FBDC56A3-86AD-4323-AA0F-201E59123B83}

dotnet/samples/Concepts/Concepts.csproj

+1
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@
8383
<ProjectReference Include="..\..\src\Functions\Functions.Prompty\Functions.Prompty.csproj" />
8484
<ProjectReference Include="..\..\src\Planners\Planners.Handlebars\Planners.Handlebars.csproj" />
8585
<ProjectReference Include="..\..\src\Planners\Planners.OpenAI\Planners.OpenAI.csproj" />
86+
<ProjectReference Include="..\..\src\Plugins\Plugins.AI\Plugins.AI.csproj" />
8687
<ProjectReference Include="..\..\src\Plugins\Plugins.Core\Plugins.Core.csproj" />
8788
<ProjectReference Include="..\..\src\Plugins\Plugins.Memory\Plugins.Memory.csproj" />
8889
<ProjectReference Include="..\..\src\Plugins\Plugins.MsGraph\Plugins.MsGraph.csproj" />
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
// Copyright (c) Microsoft. All rights reserved.
2+
3+
using Microsoft.SemanticKernel;
4+
using Microsoft.SemanticKernel.Plugins.AI.CrewAI;
5+
6+
namespace Plugins;
7+
8+
/// <summary>
9+
/// This example shows how to interact with an existing CrewAI Enterprise Crew directly or as a plugin.
10+
/// These examples require a valid CrewAI Enterprise deployment with an endpoint, auth token, and known inputs.
11+
/// </summary>
12+
public class CrewAI_Plugin(ITestOutputHelper output) : BaseTest(output)
13+
{
14+
/// <summary>
15+
/// Shows how to kickoff an existing CrewAI Enterprise Crew and wait for it to complete.
16+
/// </summary>
17+
[Fact]
18+
public async Task UsingCrewAIEnterpriseAsync()
19+
{
20+
string crewAIEndpoint = TestConfiguration.CrewAI.Endpoint;
21+
string crewAIAuthToken = TestConfiguration.CrewAI.AuthToken;
22+
23+
var crew = new CrewAIEnterprise(
24+
endpoint: new Uri(crewAIEndpoint),
25+
authTokenProvider: async () => crewAIAuthToken);
26+
27+
// The required inputs for the Crew must be known in advance. This example is modeled after the
28+
// Enterprise Content Marketing Crew Template and requires the following inputs:
29+
var inputs = new
30+
{
31+
company = "CrewAI",
32+
topic = "Agentic products for consumers",
33+
};
34+
35+
// Invoke directly with our inputs
36+
var kickoffId = await crew.KickoffAsync(inputs);
37+
Console.WriteLine($"CrewAI Enterprise Crew kicked off with ID: {kickoffId}");
38+
39+
// Wait for completion
40+
var result = await crew.WaitForCrewCompletionAsync(kickoffId);
41+
Console.WriteLine("CrewAI Enterprise Crew completed with the following result:");
42+
Console.WriteLine(result);
43+
}
44+
45+
/// <summary>
46+
/// Shows how to kickoff an existing CrewAI Enterprise Crew as a plugin.
47+
/// </summary>
48+
[Fact]
49+
public async Task UsingCrewAIEnterpriseAsPluginAsync()
50+
{
51+
string crewAIEndpoint = TestConfiguration.CrewAI.Endpoint;
52+
string crewAIAuthToken = TestConfiguration.CrewAI.AuthToken;
53+
string openAIModelId = TestConfiguration.OpenAI.ChatModelId;
54+
string openAIApiKey = TestConfiguration.OpenAI.ApiKey;
55+
56+
if (openAIModelId is null || openAIApiKey is null)
57+
{
58+
Console.WriteLine("OpenAI credentials not found. Skipping example.");
59+
return;
60+
}
61+
62+
// Setup the Kernel and AI Services
63+
Kernel kernel = Kernel.CreateBuilder()
64+
.AddOpenAIChatCompletion(
65+
modelId: openAIModelId,
66+
apiKey: openAIApiKey)
67+
.Build();
68+
69+
var crew = new CrewAIEnterprise(
70+
endpoint: new Uri(crewAIEndpoint),
71+
authTokenProvider: async () => crewAIAuthToken);
72+
73+
// The required inputs for the Crew must be known in advance. This example is modeled after the
74+
// Enterprise Content Marketing Crew Template and requires string inputs for the company and topic.
75+
// We need to describe the type and purpose of each input to allow the LLM to invoke the crew as expected.
76+
var crewPluginDefinitions = new[]
77+
{
78+
new CrewAIInputMetadata(Name: "company", Description: "The name of the company that should be researched", Type: typeof(string)),
79+
new CrewAIInputMetadata(Name: "topic", Description: "The topic that should be researched", Type: typeof(string)),
80+
};
81+
82+
// Create the CrewAI Plugin. This builds a plugin that can be added to the Kernel and invoked like any other plugin.
83+
// The plugin will contain the following functions:
84+
// - Kickoff: Starts the Crew with the specified inputs and returns the Id of the scheduled kickoff.
85+
// - KickoffAndWait: Starts the Crew with the specified inputs and waits for the Crew to complete before returning the result.
86+
// - WaitForCrewCompletion: Waits for the specified Crew kickoff to complete and returns the result.
87+
// - GetCrewKickoffStatus: Gets the status of the specified Crew kickoff.
88+
var crewPlugin = crew.CreateKernelPlugin(
89+
name: "EnterpriseContentMarketingCrew",
90+
description: "Conducts thorough research on the specified company and topic to identify emerging trends, analyze competitor strategies, and gather data-driven insights.",
91+
inputMetadata: crewPluginDefinitions);
92+
93+
// Add the plugin to the Kernel
94+
kernel.Plugins.Add(crewPlugin);
95+
96+
// Invoke the CrewAI Plugin directly as shown below, or use automaic function calling with an LLM.
97+
var kickoffAndWaitFunction = crewPlugin["KickoffAndWait"];
98+
var result = await kernel.InvokeAsync(
99+
function: kickoffAndWaitFunction,
100+
arguments: new()
101+
{
102+
["company"] = "CrewAI",
103+
["topic"] = "Consumer AI Products"
104+
});
105+
106+
Console.WriteLine(result);
107+
}
108+
}

dotnet/samples/Concepts/README.md

+1
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@ dotnet test -l "console;verbosity=detailed" --filter "FullyQualifiedName=ChatCom
166166
- [CreatePluginFromOpenApiSpec_Jira](https://github.com/microsoft/semantic-kernel/blob/main/dotnet/samples/Concepts/Plugins/CreatePluginFromOpenApiSpec_Jira.cs)
167167
- [CreatePluginFromOpenApiSpec_Klarna](https://github.com/microsoft/semantic-kernel/blob/main/dotnet/samples/Concepts/Plugins/CreatePluginFromOpenApiSpec_Klarna.cs)
168168
- [CreatePluginFromOpenApiSpec_RepairService](https://github.com/microsoft/semantic-kernel/blob/main/dotnet/samples/Concepts/Plugins/CreatePluginFromOpenApiSpec_RepairService.cs)
169+
- [CrewAI_Plugin](https://github.com/microsoft/semantic-kernel/blob/main/dotnet/samples/Concepts/Plugins/CrewAI_Plugin.cs)
169170
- [OpenApiPlugin_PayloadHandling](https://github.com/microsoft/semantic-kernel/blob/main/dotnet/samples/Concepts/Plugins/OpenApiPlugin_PayloadHandling.cs)
170171
- [OpenApiPlugin_CustomHttpContentReader](https://github.com/microsoft/semantic-kernel/blob/main/dotnet/samples/Concepts/Plugins/OpenApiPlugin_CustomHttpContentReader.cs)
171172
- [OpenApiPlugin_Customization](https://github.com/microsoft/semantic-kernel/blob/main/dotnet/samples/Concepts/Plugins/OpenApiPlugin_Customization.cs)

dotnet/src/InternalUtilities/samples/InternalUtilities/TestConfiguration.cs

+8
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ public static void Initialize(IConfigurationRoot configRoot)
4949
public static VertexAIConfig VertexAI => LoadSection<VertexAIConfig>();
5050
public static AzureCosmosDbMongoDbConfig AzureCosmosDbMongoDb => LoadSection<AzureCosmosDbMongoDbConfig>();
5151

52+
public static CrewAIConfig CrewAI => LoadSection<CrewAIConfig>();
53+
5254
private static T LoadSection<T>([CallerMemberName] string? caller = null)
5355
{
5456
if (s_instance is null)
@@ -309,4 +311,10 @@ public MsGraphConfiguration(
309311
this.RedirectUri = redirectUri;
310312
}
311313
}
314+
315+
public class CrewAIConfig
316+
{
317+
public string Endpoint { get; set; }
318+
public string AuthToken { get; set; }
319+
}
312320
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
// Copyright (c) Microsoft. All rights reserved.
2+
3+
using System;
4+
using System.Net;
5+
using System.Net.Http;
6+
using System.Threading;
7+
using System.Threading.Tasks;
8+
using Microsoft.SemanticKernel.Plugins.AI.CrewAI;
9+
using Moq;
10+
using Moq.Protected;
11+
using Xunit;
12+
13+
namespace SemanticKernel.Plugins.AI.UnitTests.CrewAI;
14+
15+
/// <summary>
16+
/// Tests for the <see cref="CrewAIEnterpriseClient"/> class.
17+
/// </summary>
18+
public sealed partial class CrewAIEnterpriseClientTests
19+
{
20+
private readonly Mock<HttpMessageHandler> _httpMessageHandlerMock;
21+
private readonly CrewAIEnterpriseClient _client;
22+
23+
/// <summary>
24+
/// Initializes a new instance of the <see cref="CrewAIEnterpriseClientTests"/> class.
25+
/// </summary>
26+
public CrewAIEnterpriseClientTests()
27+
{
28+
this._httpMessageHandlerMock = new Mock<HttpMessageHandler>();
29+
using var httpClientFactory = new MockHttpClientFactory(this._httpMessageHandlerMock);
30+
this._client = new CrewAIEnterpriseClient(
31+
endpoint: new Uri("http://example.com"),
32+
authTokenProvider: () => Task.FromResult("token"),
33+
httpClientFactory);
34+
}
35+
36+
/// <summary>
37+
/// Tests that <see cref="CrewAIEnterpriseClient.GetInputsAsync"/> returns the required inputs from the CrewAI API.
38+
/// </summary>
39+
/// <returns></returns>
40+
[Fact]
41+
public async Task GetInputsAsyncReturnsCrewAIRequiredInputsAsync()
42+
{
43+
// Arrange
44+
var responseContent = "{\"inputs\": [\"input1\", \"input2\"]}";
45+
using var responseMessage = new HttpResponseMessage
46+
{
47+
StatusCode = HttpStatusCode.OK,
48+
Content = new StringContent(responseContent)
49+
};
50+
51+
this._httpMessageHandlerMock.Protected()
52+
.Setup<Task<HttpResponseMessage>>(
53+
"SendAsync",
54+
ItExpr.IsAny<HttpRequestMessage>(),
55+
ItExpr.IsAny<CancellationToken>())
56+
.ReturnsAsync(responseMessage);
57+
58+
// Act
59+
var result = await this._client.GetInputsAsync();
60+
61+
// Assert
62+
Assert.NotNull(result);
63+
Assert.Equal(2, result.Inputs.Count);
64+
Assert.Contains("input1", result.Inputs);
65+
Assert.Contains("input2", result.Inputs);
66+
}
67+
68+
/// <summary>
69+
/// Tests that <see cref="CrewAIEnterpriseClient.KickoffAsync"/> returns the kickoff id from the CrewAI API.
70+
/// </summary>
71+
/// <returns></returns>
72+
[Fact]
73+
public async Task KickoffAsyncReturnsCrewAIKickoffResponseAsync()
74+
{
75+
// Arrange
76+
var responseContent = "{\"kickoff_id\": \"12345\"}";
77+
using var responseMessage = new HttpResponseMessage
78+
{
79+
StatusCode = HttpStatusCode.OK,
80+
Content = new StringContent(responseContent)
81+
};
82+
83+
this._httpMessageHandlerMock.Protected()
84+
.Setup<Task<HttpResponseMessage>>(
85+
"SendAsync",
86+
ItExpr.IsAny<HttpRequestMessage>(),
87+
ItExpr.IsAny<CancellationToken>())
88+
.ReturnsAsync(responseMessage);
89+
90+
// Act
91+
var result = await this._client.KickoffAsync(new { key = "value" });
92+
93+
// Assert
94+
Assert.NotNull(result);
95+
Assert.Equal("12345", result.KickoffId);
96+
}
97+
98+
/// <summary>
99+
/// Tests that <see cref="CrewAIEnterpriseClient.GetStatusAsync"/> returns the status of the CrewAI Crew.
100+
/// </summary>
101+
/// <param name="state"></param>
102+
/// <returns></returns>
103+
/// <exception cref="ArgumentOutOfRangeException"></exception>
104+
[Theory]
105+
[InlineData(CrewAIKickoffState.Pending)]
106+
[InlineData(CrewAIKickoffState.Started)]
107+
[InlineData(CrewAIKickoffState.Running)]
108+
[InlineData(CrewAIKickoffState.Success)]
109+
[InlineData(CrewAIKickoffState.Failed)]
110+
[InlineData(CrewAIKickoffState.Failure)]
111+
[InlineData(CrewAIKickoffState.NotFound)]
112+
public async Task GetStatusAsyncReturnsCrewAIStatusResponseAsync(CrewAIKickoffState state)
113+
{
114+
var crewAIStatusState = state switch
115+
{
116+
CrewAIKickoffState.Pending => "PENDING",
117+
CrewAIKickoffState.Started => "STARTED",
118+
CrewAIKickoffState.Running => "RUNNING",
119+
CrewAIKickoffState.Success => "SUCCESS",
120+
CrewAIKickoffState.Failed => "FAILED",
121+
CrewAIKickoffState.Failure => "FAILURE",
122+
CrewAIKickoffState.NotFound => "NOT FOUND",
123+
_ => throw new ArgumentOutOfRangeException(nameof(state), state, null)
124+
};
125+
126+
// Arrange
127+
var responseContent = $"{{\"state\": \"{crewAIStatusState}\", \"result\": \"The Result\", \"last_step\": {{\"step1\": \"value1\"}}}}";
128+
using var responseMessage = new HttpResponseMessage
129+
{
130+
StatusCode = HttpStatusCode.OK,
131+
Content = new StringContent(responseContent)
132+
};
133+
134+
this._httpMessageHandlerMock.Protected()
135+
.Setup<Task<HttpResponseMessage>>(
136+
"SendAsync",
137+
ItExpr.IsAny<HttpRequestMessage>(),
138+
ItExpr.IsAny<CancellationToken>())
139+
.ReturnsAsync(responseMessage);
140+
141+
// Act
142+
var result = await this._client.GetStatusAsync("12345");
143+
144+
// Assert
145+
Assert.NotNull(result);
146+
Assert.Equal(state, result.State);
147+
Assert.Equal("The Result", result.Result);
148+
Assert.NotNull(result.LastStep);
149+
Assert.Equal("value1", result.LastStep["step1"].ToString());
150+
}
151+
}

0 commit comments

Comments
 (0)