diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index faa6bae454a1..c0150982d16f 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -5,15 +5,15 @@ true + + + - - - @@ -57,8 +57,8 @@ - + @@ -89,6 +89,7 @@ + @@ -96,18 +97,18 @@ - + - + + + @@ -123,6 +124,7 @@ + diff --git a/dotnet/SK-dotnet.sln b/dotnet/SK-dotnet.sln index b7202e21a1b1..acf61a114486 100644 --- a/dotnet/SK-dotnet.sln +++ b/dotnet/SK-dotnet.sln @@ -533,6 +533,20 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProcessWithCloudEvents.Proc EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProcessWithCloudEvents.Grpc", "samples\Demos\ProcessWithCloudEvents\ProcessWithCloudEvents.Grpc\ProcessWithCloudEvents.Grpc.csproj", "{08D84994-794A-760F-95FD-4EFA8998A16D}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Runtime", "Runtime", "{A70ED5A7-F8E1-4A57-9455-3C05989542DA}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Runtime.Abstractions", "src\Agents\Runtime\Abstractions\Runtime.Abstractions.csproj", "{B9C86C5D-EB4C-8A16-E567-27025AC59A28}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Runtime.Abstractions.Tests", "src\Agents\Runtime\Abstractions.Tests\Runtime.Abstractions.Tests.csproj", "{BB74EEE2-F048-A1A4-F53E-2B384A6F8BC4}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Runtime.Core", "src\Agents\Runtime\Core\Runtime.Core.csproj", "{19DC60E6-AD08-4BCB-A4DF-B80E0941B458}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Runtime.Core.Tests", "src\Agents\Runtime\Core.Tests\Runtime.Core.Tests.csproj", "{A4F05541-7D23-A5A9-033D-382F1E13D0FE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Runtime.InProcess", "src\Agents\Runtime\InProcess\Runtime.InProcess.csproj", "{CCC909E4-5269-A31E-0BFD-4863B4B29BBB}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Runtime.InProcess.Tests", "src\Agents\Runtime\InProcess.Tests\Runtime.InProcess.Tests.csproj", "{DA6B4ED4-ED0B-D25C-889C-9F940E714891}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -1458,6 +1472,42 @@ Global {08D84994-794A-760F-95FD-4EFA8998A16D}.Publish|Any CPU.Build.0 = Release|Any CPU {08D84994-794A-760F-95FD-4EFA8998A16D}.Release|Any CPU.ActiveCfg = Release|Any CPU {08D84994-794A-760F-95FD-4EFA8998A16D}.Release|Any CPU.Build.0 = Release|Any CPU + {B9C86C5D-EB4C-8A16-E567-27025AC59A28}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B9C86C5D-EB4C-8A16-E567-27025AC59A28}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B9C86C5D-EB4C-8A16-E567-27025AC59A28}.Publish|Any CPU.ActiveCfg = Release|Any CPU + {B9C86C5D-EB4C-8A16-E567-27025AC59A28}.Publish|Any CPU.Build.0 = Release|Any CPU + {B9C86C5D-EB4C-8A16-E567-27025AC59A28}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B9C86C5D-EB4C-8A16-E567-27025AC59A28}.Release|Any CPU.Build.0 = Release|Any CPU + {BB74EEE2-F048-A1A4-F53E-2B384A6F8BC4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BB74EEE2-F048-A1A4-F53E-2B384A6F8BC4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BB74EEE2-F048-A1A4-F53E-2B384A6F8BC4}.Publish|Any CPU.ActiveCfg = Release|Any CPU + {BB74EEE2-F048-A1A4-F53E-2B384A6F8BC4}.Publish|Any CPU.Build.0 = Release|Any CPU + {BB74EEE2-F048-A1A4-F53E-2B384A6F8BC4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BB74EEE2-F048-A1A4-F53E-2B384A6F8BC4}.Release|Any CPU.Build.0 = Release|Any CPU + {19DC60E6-AD08-4BCB-A4DF-B80E0941B458}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {19DC60E6-AD08-4BCB-A4DF-B80E0941B458}.Debug|Any CPU.Build.0 = Debug|Any CPU + {19DC60E6-AD08-4BCB-A4DF-B80E0941B458}.Publish|Any CPU.ActiveCfg = Release|Any CPU + {19DC60E6-AD08-4BCB-A4DF-B80E0941B458}.Publish|Any CPU.Build.0 = Release|Any CPU + {19DC60E6-AD08-4BCB-A4DF-B80E0941B458}.Release|Any CPU.ActiveCfg = Release|Any CPU + {19DC60E6-AD08-4BCB-A4DF-B80E0941B458}.Release|Any CPU.Build.0 = Release|Any CPU + {A4F05541-7D23-A5A9-033D-382F1E13D0FE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A4F05541-7D23-A5A9-033D-382F1E13D0FE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A4F05541-7D23-A5A9-033D-382F1E13D0FE}.Publish|Any CPU.ActiveCfg = Release|Any CPU + {A4F05541-7D23-A5A9-033D-382F1E13D0FE}.Publish|Any CPU.Build.0 = Release|Any CPU + {A4F05541-7D23-A5A9-033D-382F1E13D0FE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A4F05541-7D23-A5A9-033D-382F1E13D0FE}.Release|Any CPU.Build.0 = Release|Any CPU + {CCC909E4-5269-A31E-0BFD-4863B4B29BBB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CCC909E4-5269-A31E-0BFD-4863B4B29BBB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CCC909E4-5269-A31E-0BFD-4863B4B29BBB}.Publish|Any CPU.ActiveCfg = Release|Any CPU + {CCC909E4-5269-A31E-0BFD-4863B4B29BBB}.Publish|Any CPU.Build.0 = Release|Any CPU + {CCC909E4-5269-A31E-0BFD-4863B4B29BBB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CCC909E4-5269-A31E-0BFD-4863B4B29BBB}.Release|Any CPU.Build.0 = Release|Any CPU + {DA6B4ED4-ED0B-D25C-889C-9F940E714891}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DA6B4ED4-ED0B-D25C-889C-9F940E714891}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DA6B4ED4-ED0B-D25C-889C-9F940E714891}.Publish|Any CPU.ActiveCfg = Release|Any CPU + {DA6B4ED4-ED0B-D25C-889C-9F940E714891}.Publish|Any CPU.Build.0 = Release|Any CPU + {DA6B4ED4-ED0B-D25C-889C-9F940E714891}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DA6B4ED4-ED0B-D25C-889C-9F940E714891}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -1656,6 +1706,13 @@ Global {7C092DD9-9985-4D18-A817-15317D984149} = {5D4C0700-BBB5-418F-A7B2-F392B9A18263} {31F6608A-FD36-F529-A5FC-C954A0B5E29E} = {7C092DD9-9985-4D18-A817-15317D984149} {08D84994-794A-760F-95FD-4EFA8998A16D} = {7C092DD9-9985-4D18-A817-15317D984149} + {A70ED5A7-F8E1-4A57-9455-3C05989542DA} = {6823CD5E-2ABE-41EB-B865-F86EC13F0CF9} + {B9C86C5D-EB4C-8A16-E567-27025AC59A28} = {A70ED5A7-F8E1-4A57-9455-3C05989542DA} + {BB74EEE2-F048-A1A4-F53E-2B384A6F8BC4} = {A70ED5A7-F8E1-4A57-9455-3C05989542DA} + {19DC60E6-AD08-4BCB-A4DF-B80E0941B458} = {A70ED5A7-F8E1-4A57-9455-3C05989542DA} + {A4F05541-7D23-A5A9-033D-382F1E13D0FE} = {A70ED5A7-F8E1-4A57-9455-3C05989542DA} + {CCC909E4-5269-A31E-0BFD-4863B4B29BBB} = {A70ED5A7-F8E1-4A57-9455-3C05989542DA} + {DA6B4ED4-ED0B-D25C-889C-9F940E714891} = {A70ED5A7-F8E1-4A57-9455-3C05989542DA} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {FBDC56A3-86AD-4323-AA0F-201E59123B83} diff --git a/dotnet/src/Agents/AzureAI/AzureAIAgent.cs b/dotnet/src/Agents/AzureAI/AzureAIAgent.cs index 557299ae044e..440dd0c74b65 100644 --- a/dotnet/src/Agents/AzureAI/AzureAIAgent.cs +++ b/dotnet/src/Agents/AzureAI/AzureAIAgent.cs @@ -93,7 +93,7 @@ public AzureAIAgent( } /// - /// %%% + /// The associated client. /// public AgentsClient Client { get; } diff --git a/dotnet/src/Agents/Runtime/Abstractions.Tests/AgentIdTests.cs b/dotnet/src/Agents/Runtime/Abstractions.Tests/AgentIdTests.cs new file mode 100644 index 000000000000..d0f338b62bcf --- /dev/null +++ b/dotnet/src/Agents/Runtime/Abstractions.Tests/AgentIdTests.cs @@ -0,0 +1,122 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using FluentAssertions; +using Xunit; + +namespace Microsoft.SemanticKernel.Agents.Runtime.Abstractions.Tests; + +[Trait("Category", "Unit")] +public class AgentIdTests() +{ + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("invalid\u007Fkey")] // DEL character (127) is outside ASCII 32-126 range + [InlineData("invalid\u0000key")] // NULL character is outside ASCII 32-126 range + [InlineData("invalid\u0010key")] // Control character is outside ASCII 32-126 range + [InlineData("InvalidKey💀")] // Control character is outside ASCII 32-126 range + public void AgentIdShouldThrowArgumentExceptionWithInvalidKey(string? invalidKey) + { + // Act & Assert + ArgumentException exception = Assert.Throws(() => new AgentId("validType", invalidKey!)); + Assert.Contains("Invalid AgentId key", exception.Message); + } + + [Fact] + public void AgentIdShouldInitializeCorrectlyTest() + { + AgentId agentId = new("TestType", "TestKey"); + + agentId.Type.Should().Be("TestType"); + agentId.Key.Should().Be("TestKey"); + } + + [Fact] + public void AgentIdShouldConvertFromTupleTest() + { + (string, string) agentTuple = ("TupleType", "TupleKey"); + AgentId agentId = new(agentTuple); + + agentId.Type.Should().Be("TupleType"); + agentId.Key.Should().Be("TupleKey"); + } + + [Fact] + public void AgentIdShouldConvertFromAgentType() + { + AgentType agentType = "TestType"; + AgentId agentId = new(agentType, "TestKey"); + + agentId.Type.Should().Be("TestType"); + agentId.Key.Should().Be("TestKey"); + } + + [Fact] + public void AgentIdShouldParseFromStringTest() + { + AgentId agentId = AgentId.FromStr("ParsedType/ParsedKey"); + + agentId.Type.Should().Be("ParsedType"); + agentId.Key.Should().Be("ParsedKey"); + } + + [Fact] + public void AgentIdShouldCompareEqualityCorrectlyTest() + { + AgentId agentId1 = new("SameType", "SameKey"); + AgentId agentId2 = new("SameType", "SameKey"); + AgentId agentId3 = new("DifferentType", "DifferentKey"); + + agentId1.Should().Be(agentId2); + agentId1.Should().NotBe(agentId3); + (agentId1 == agentId2).Should().BeTrue(); + (agentId1 != agentId3).Should().BeTrue(); + } + + [Fact] + public void AgentIdShouldGenerateCorrectHashCodeTest() + { + AgentId agentId1 = new("HashType", "HashKey"); + AgentId agentId2 = new("HashType", "HashKey"); + AgentId agentId3 = new("DifferentType", "DifferentKey"); + + agentId1.GetHashCode().Should().Be(agentId2.GetHashCode()); + agentId1.GetHashCode().Should().NotBe(agentId3.GetHashCode()); + } + + [Fact] + public void AgentIdShouldConvertExplicitlyFromStringTest() + { + AgentId agentId = (AgentId)"ConvertedType/ConvertedKey"; + + agentId.Type.Should().Be("ConvertedType"); + agentId.Key.Should().Be("ConvertedKey"); + } + + [Fact] + public void AgentIdShouldReturnCorrectToStringTest() + { + AgentId agentId = new("ToStringType", "ToStringKey"); + + agentId.ToString().Should().Be("ToStringType/ToStringKey"); + } + + [Fact] + public void AgentIdShouldCompareInequalityForWrongTypeTest() + { + AgentId agentId1 = new("Type1", "Key1"); + + (!agentId1.Equals(Guid.NewGuid())).Should().BeTrue(); + } + + [Fact] + public void AgentIdShouldCompareInequalityCorrectlyTest() + { + AgentId agentId1 = new("Type1", "Key1"); + AgentId agentId2 = new("Type2", "Key2"); + + (agentId1 != agentId2).Should().BeTrue(); + } +} diff --git a/dotnet/src/Agents/Runtime/Abstractions.Tests/AgentMetaDataTests.cs b/dotnet/src/Agents/Runtime/Abstractions.Tests/AgentMetaDataTests.cs new file mode 100644 index 000000000000..9ad839c52996 --- /dev/null +++ b/dotnet/src/Agents/Runtime/Abstractions.Tests/AgentMetaDataTests.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft. All rights reserved. + +using FluentAssertions; +using Xunit; + +namespace Microsoft.SemanticKernel.Agents.Runtime.Abstractions.Tests; + +[Trait("Category", "Unit")] +public class AgentMetadataTests() +{ + [Fact] + public void AgentMetadataShouldInitializeCorrectlyTest() + { + // Arrange & Act + AgentMetadata metadata = new("TestType", "TestKey", "TestDescription"); + + // Assert + metadata.Type.Should().Be("TestType"); + metadata.Key.Should().Be("TestKey"); + metadata.Description.Should().Be("TestDescription"); + } +} diff --git a/dotnet/src/Agents/Runtime/Abstractions.Tests/AgentProxyTests.cs b/dotnet/src/Agents/Runtime/Abstractions.Tests/AgentProxyTests.cs new file mode 100644 index 000000000000..63474724a27e --- /dev/null +++ b/dotnet/src/Agents/Runtime/Abstractions.Tests/AgentProxyTests.cs @@ -0,0 +1,91 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using Xunit; + +namespace Microsoft.SemanticKernel.Agents.Runtime.Abstractions.Tests; + +[Trait("Category", "Unit")] +public class AgentProxyTests +{ + private readonly Mock mockRuntime; + private readonly AgentId agentId; + private readonly AgentProxy agentProxy; + + public AgentProxyTests() + { + this.mockRuntime = new Mock(); + this.agentId = new AgentId("testType", "testKey"); + this.agentProxy = new AgentProxy(this.agentId, this.mockRuntime.Object); + } + + [Fact] + public void IdMatchesAgentIdTest() + { + // Assert + Assert.Equal(this.agentId, this.agentProxy.Id); + } + + [Fact] + public void MetadataShouldMatchAgentTest() + { + AgentMetadata expectedMetadata = new("testType", "testKey", "testDescription"); + this.mockRuntime.Setup(r => r.GetAgentMetadataAsync(this.agentId)) + .ReturnsAsync(expectedMetadata); + + Assert.Equal(expectedMetadata, this.agentProxy.Metadata); + } + + [Fact] + public async Task SendMessageResponseTest() + { + // Arrange + object message = new { Content = "Hello" }; + AgentId sender = new("senderType", "senderKey"); + object response = new { Content = "Response" }; + + this.mockRuntime.Setup(r => r.SendMessageAsync(message, this.agentId, sender, null, It.IsAny())) + .ReturnsAsync(response); + + // Act + object? result = await this.agentProxy.SendMessageAsync(message, sender); + + // Assert + Assert.Equal(response, result); + } + + [Fact] + public async Task LoadStateTest() + { + // Arrange + JsonElement state = JsonDocument.Parse("{\"key\":\"value\"}").RootElement; + + this.mockRuntime.Setup(r => r.LoadAgentStateAsync(this.agentId, state)) + .Returns(ValueTask.CompletedTask); + + // Act + await this.agentProxy.LoadStateAsync(state); + + // Assert + this.mockRuntime.Verify(r => r.LoadAgentStateAsync(this.agentId, state), Times.Once); + } + + [Fact] + public async Task SaveStateTest() + { + // Arrange + JsonElement expectedState = JsonDocument.Parse("{\"key\":\"value\"}").RootElement; + + this.mockRuntime.Setup(r => r.SaveAgentStateAsync(this.agentId)) + .ReturnsAsync(expectedState); + + // Act + JsonElement result = await this.agentProxy.SaveStateAsync(); + + // Assert + Assert.Equal(expectedState, result); + } +} diff --git a/dotnet/src/Agents/Runtime/Abstractions.Tests/AgentTypeTests.cs b/dotnet/src/Agents/Runtime/Abstractions.Tests/AgentTypeTests.cs new file mode 100644 index 000000000000..3bc7ca5d4c2e --- /dev/null +++ b/dotnet/src/Agents/Runtime/Abstractions.Tests/AgentTypeTests.cs @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using Xunit; + +namespace Microsoft.SemanticKernel.Agents.Runtime.Abstractions.Tests; + +[Trait("Category", "Unit")] +public class AgentTypeTests +{ + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("invalid type")] // Agent type must only contain alphanumeric letters or underscores + [InlineData("123invalidType")] // Agent type cannot start with a number + [InlineData("invalid@type")] // Agent type must only contain alphanumeric letters or underscores + [InlineData("invalid-type")] // Agent type cannot alphanumeric underscores. + public void AgentIdShouldThrowArgumentExceptionWithInvalidType(string? invalidType) + { + // Act & Assert + ArgumentException exception = Assert.Throws(() => new AgentType(invalidType!)); + Assert.Contains("Invalid AgentId type", exception.Message); + } + + [Fact] + public void ImplicitConversionFromStringTest() + { + // Arrange + string agentTypeName = "TestAgent"; + + // Act + AgentType agentType = agentTypeName; + + // Assert + Assert.Equal(agentTypeName, agentType.Name); + } + + [Fact] + public void ImplicitConversionToStringTest() + { + // Arrange + AgentType agentType = "TestAgent"; + + // Act + string agentTypeName = agentType; + + // Assert + Assert.Equal("TestAgent", agentTypeName); + } + + [Fact] + public void ExplicitConversionFromTypeTest() + { + // Arrange + Type type = typeof(string); + + // Act + AgentType agentType = (AgentType)type; + + // Assert + Assert.Equal(type.Name, agentType.Name); + } +} diff --git a/dotnet/src/Agents/Runtime/Abstractions.Tests/MessageContextTests.cs b/dotnet/src/Agents/Runtime/Abstractions.Tests/MessageContextTests.cs new file mode 100644 index 000000000000..f918fcc03a89 --- /dev/null +++ b/dotnet/src/Agents/Runtime/Abstractions.Tests/MessageContextTests.cs @@ -0,0 +1,82 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading; +using Xunit; + +namespace Microsoft.SemanticKernel.Agents.Runtime.Abstractions.Tests; + +[Trait("Category", "Unit")] +public class MessageContextTests +{ + [Fact] + public void ConstructWithMessageIdAndCancellationTokenTest() + { + // Arrange + string messageId = Guid.NewGuid().ToString(); + CancellationToken cancellationToken = new(); + + // Act + MessageContext messageContext = new(messageId, cancellationToken); + + // Assert + Assert.Equal(messageId, messageContext.MessageId); + Assert.Equal(cancellationToken, messageContext.CancellationToken); + } + + [Fact] + public void ConstructWithCancellationTokenTest() + { + // Arrange + CancellationToken cancellationToken = new(); + + // Act + MessageContext messageContext = new(cancellationToken); + + // Assert + Assert.NotNull(messageContext.MessageId); + Assert.Equal(cancellationToken, messageContext.CancellationToken); + } + + [Fact] + public void AssignSenderTest() + { + // Arrange + MessageContext messageContext = new(new CancellationToken()); + AgentId sender = new("type", "key"); + + // Act + messageContext.Sender = sender; + + // Assert + Assert.Equal(sender, messageContext.Sender); + } + + [Fact] + public void AssignTopicTest() + { + // Arrange + MessageContext messageContext = new(new CancellationToken()); + TopicId topic = new("type", "source"); + + // Act + messageContext.Topic = topic; + + // Assert + Assert.Equal(topic, messageContext.Topic); + } + + [Fact] + public void AssignIsRpcPropertyTest() + { + // Arrange + MessageContext messageContext = new(new CancellationToken()) + { + // Act + IsRpc = true + }; + + // Assert + Assert.True(messageContext.IsRpc); + } +} diff --git a/dotnet/src/Agents/Runtime/Abstractions.Tests/Runtime.Abstractions.Tests.csproj b/dotnet/src/Agents/Runtime/Abstractions.Tests/Runtime.Abstractions.Tests.csproj new file mode 100644 index 000000000000..e0298ff6577b --- /dev/null +++ b/dotnet/src/Agents/Runtime/Abstractions.Tests/Runtime.Abstractions.Tests.csproj @@ -0,0 +1,30 @@ + + + + Microsoft.SemanticKernel.Agents.Runtime.Abstractions.UnitTests + Microsoft.SemanticKernel.Agents.Runtime.Abstractions.UnitTests + net8.0 + True + $(NoWarn);CA1707;CA2007;CA1812;CA1861;CA1063;CS0618;CS1591;IDE1006;VSTHRD111;SKEXP0001;SKEXP0050;SKEXP0110;OPENAI001 + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/dotnet/src/Agents/Runtime/Abstractions.Tests/TopicIdTests.cs b/dotnet/src/Agents/Runtime/Abstractions.Tests/TopicIdTests.cs new file mode 100644 index 000000000000..69d931ee9200 --- /dev/null +++ b/dotnet/src/Agents/Runtime/Abstractions.Tests/TopicIdTests.cs @@ -0,0 +1,184 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using Xunit; + +namespace Microsoft.SemanticKernel.Agents.Runtime.Abstractions.Tests; + +[Trait("Category", "Unit")] +public class TopicIdTests +{ + [Fact] + public void ConstrWithTypeOnlyTest() + { + // Arrange & Act + TopicId topicId = new("testtype"); + + // Assert + Assert.Equal("testtype", topicId.Type); + Assert.Equal(TopicId.DefaultSource, topicId.Source); + } + + [Fact] + public void ConstructWithTypeAndSourceTest() + { + // Arrange & Act + TopicId topicId = new("testtype", "customsource"); + + // Assert + Assert.Equal("testtype", topicId.Type); + Assert.Equal("customsource", topicId.Source); + } + + [Fact] + public void ConstructWithTupleTest() + { + // Arrange + (string, string) tuple = ("testtype", "customsource"); + + // Act + TopicId topicId = new(tuple); + + // Assert + Assert.Equal("testtype", topicId.Type); + Assert.Equal("customsource", topicId.Source); + } + + [Fact] + public void ConvertFromStringTest() + { + // Arrange + const string topicIdStr = "testtype/customsource"; + + // Act + TopicId topicId = TopicId.FromStr(topicIdStr); + + // Assert + Assert.Equal("testtype", topicId.Type); + Assert.Equal("customsource", topicId.Source); + } + + [Theory] + [InlineData("invalid-format")] + [InlineData("too/many/parts")] + [InlineData("")] + public void InvalidFormatFromStringThrowsTest(string invalidInput) + { + // Act & Assert + Assert.Throws(() => TopicId.FromStr(invalidInput)); + } + + [Fact] + public void ToStringTest() + { + // Arrange + TopicId topicId = new("testtype", "customsource"); + + // Act + string result = topicId.ToString(); + + // Assert + Assert.Equal("testtype/customsource", result); + } + + [Fact] + public void EqualityTest() + { + // Arrange + TopicId topicId1 = new("testtype", "customsource"); + TopicId topicId2 = new("testtype", "customsource"); + + // Act & Assert + Assert.True(topicId1.Equals(topicId2)); + Assert.True(topicId1.Equals((object)topicId2)); + } + + [Fact] + public void InequalityTest() + { + // Arrange + TopicId topicId1 = new("testtype1", "source1"); + TopicId topicId2 = new("testtype2", "source2"); + TopicId topicId3 = new("testtype1", "source2"); + TopicId topicId4 = new("testtype2", "source1"); + + // Act & Assert + Assert.False(topicId1.Equals(topicId2)); + Assert.False(topicId1.Equals(topicId3)); + Assert.False(topicId1.Equals(topicId4)); + } + + [Fact] + public void NullEqualityTest() + { + // Arrange + TopicId topicId = new("testtype", "customsource"); + + // Act & Assert + Assert.False(topicId.Equals(null)); + } + + [Fact] + public void DifferentTypeEqualityTest() + { + // Arrange + TopicId topicId = new("testtype", "customsource"); + const string differentType = "not-a-topic-id"; + + // Act & Assert + Assert.False(topicId.Equals(differentType)); + } + + [Fact] + public void GetHashCodeTest() + { + // Arrange + TopicId topicId1 = new("testtype", "customsource"); + TopicId topicId2 = new("testtype", "customsource"); + + // Act + int hash1 = topicId1.GetHashCode(); + int hash2 = topicId2.GetHashCode(); + + // Assert + Assert.Equal(hash1, hash2); + } + + [Fact] + public void ExplicitConversionTest() + { + // Arrange + string topicIdStr = "testtype/customsource"; + + // Act + TopicId topicId = (TopicId)topicIdStr; + + // Assert + Assert.Equal("testtype", topicId.Type); + Assert.Equal("customsource", topicId.Source); + } + + [Fact] + public void IsWildcardMatchTest() + { + // Arrange + TopicId topicId1 = new("testtype", "source1"); + TopicId topicId2 = new("testtype", "source2"); + + // Act & Assert + Assert.True(topicId1.IsWildcardMatch(topicId2)); + Assert.True(topicId2.IsWildcardMatch(topicId1)); + } + + [Fact] + public void IsWildcardMismatchTest() + { + // Arrange + TopicId topicId1 = new("testtype1", "source"); + TopicId topicId2 = new("testtype2", "source"); + + // Act & Assert + Assert.False(topicId1.IsWildcardMatch(topicId2)); + Assert.False(topicId2.IsWildcardMatch(topicId1)); + } +} diff --git a/dotnet/src/Agents/Runtime/Abstractions/AgentId.cs b/dotnet/src/Agents/Runtime/Abstractions/AgentId.cs new file mode 100644 index 000000000000..bab4f9b51183 --- /dev/null +++ b/dotnet/src/Agents/Runtime/Abstractions/AgentId.cs @@ -0,0 +1,136 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Text.RegularExpressions; +using Microsoft.SemanticKernel.Agents.Runtime.Internal; + +namespace Microsoft.SemanticKernel.Agents.Runtime; + +/// +/// Agent ID uniquely identifies an agent instance within an agent runtime, including a distributed runtime. +/// It serves as the "address" of the agent instance for receiving messages. +/// \ +/// +/// See the Python equivalent: +/// AgentId in AutoGen (Python). +/// +[DebuggerDisplay($"AgentId(type=\"{{{nameof(Type)}}}\", key=\"{{{nameof(Key)}}}\")")] +public struct AgentId : IEquatable +{ + /// + /// The default source value used when no source is explicitly provided. + /// + public const string DefaultKey = "default"; + + private static readonly Regex KeyRegex = new(@"^[\x20-\x7E]+$", RegexOptions.Compiled); // ASCII 32-126 + + /// + /// An identifier that associates an agent with a specific factory function. + /// Strings may only be composed of alphanumeric letters (a-z) and (0-9), or underscores (_). + /// + public string Type { get; } + + /// + /// Agent instance identifier. + /// Strings may only be composed of alphanumeric letters (a-z) and (0-9), or underscores (_). + /// + public string Key { get; } + + internal static Regex KeyRegex1 => KeyRegex2; + + internal static Regex KeyRegex2 => KeyRegex; + + /// + /// Initializes a new instance of the struct. + /// + /// The agent type. + /// Agent instance identifier. + public AgentId(string type, string key) + { + AgentType.Validate(type); + + if (string.IsNullOrWhiteSpace(key) || !KeyRegex.IsMatch(key)) + { + throw new ArgumentException($"Invalid AgentId key: '{key}'. Must only contain ASCII characters 32-126."); + } + + this.Type = type; + this.Key = key; + } + + /// + /// Initializes a new instance of the struct from a tuple. + /// + /// A tuple containing the agent type and key. + public AgentId((string Type, string Key) kvPair) + : this(kvPair.Type, kvPair.Key) + { + } + + /// + /// Initializes a new instance of the struct from an . + /// + /// The agent type. + /// Agent instance identifier. + public AgentId(AgentType type, string key) + : this(type.Name, key) + { + } + + /// + /// Convert a string of the format "type/key" into an . + /// + /// The agent ID string. + /// An instance of . + public static AgentId FromStr(string maybeAgentId) => new(maybeAgentId.ToKeyValuePair(nameof(Type), nameof(Key))); + + /// + /// Returns the string representation of the . + /// + /// A string in the format "type/key". + public override readonly string ToString() => $"{this.Type}/{this.Key}"; + + /// + /// Determines whether the specified object is equal to the current . + /// + /// The object to compare with the current instance. + /// true if the specified object is equal to the current ; otherwise, false. + public override readonly bool Equals([NotNullWhen(true)] object? obj) + { + return (obj is AgentId other && this.Equals(other)); + } + + /// + public readonly bool Equals(AgentId other) + { + return this.Type == other.Type && this.Key == other.Key; + } + + /// + /// Returns a hash code for this . + /// + /// A hash code for the current instance. + public override readonly int GetHashCode() + { + return HashCode.Combine(this.Type, this.Key); + } + + /// + /// Explicitly converts a string to an . + /// + /// The string representation of an agent ID. + /// An instance of . + public static explicit operator AgentId(string id) => FromStr(id); + + /// + /// Equality operator for . + /// + public static bool operator ==(AgentId left, AgentId right) => left.Equals(right); + + /// + /// Inequality operator for . + /// + public static bool operator !=(AgentId left, AgentId right) => !left.Equals(right); +} diff --git a/dotnet/src/Agents/Runtime/Abstractions/AgentMetadata.cs b/dotnet/src/Agents/Runtime/Abstractions/AgentMetadata.cs new file mode 100644 index 000000000000..56ca1afe77bf --- /dev/null +++ b/dotnet/src/Agents/Runtime/Abstractions/AgentMetadata.cs @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.SemanticKernel.Agents.Runtime; + +/// +/// Represents metadata associated with an agent, including its type, unique key, and description. +/// +public readonly struct AgentMetadata(string type, string key, string description) : IEquatable +{ + /// + /// An identifier that associates an agent with a specific factory function. + /// Strings may only be composed of alphanumeric letters (a-z, 0-9), or underscores (_). + /// + public string Type { get; } = type; + + /// + /// A unique key identifying the agent instance. + /// Strings may only be composed of alphanumeric letters (a-z, 0-9), or underscores (_). + /// + public string Key { get; } = key; + + /// + /// A brief description of the agent's purpose or functionality. + /// + public string Description { get; } = description; + + /// + public override readonly bool Equals(object? obj) + { + return obj is AgentMetadata agentMetadata && this.Equals(agentMetadata); + } + + /// + public readonly bool Equals(AgentMetadata other) + { + return this.Type.Equals(other.Type, StringComparison.Ordinal) && this.Key.Equals(other.Key, StringComparison.Ordinal); + } + + /// + public override readonly int GetHashCode() + { + return HashCode.Combine(this.Type, this.Key); + } + + /// + public static bool operator ==(AgentMetadata left, AgentMetadata right) + { + return left.Equals(right); + } + + /// + public static bool operator !=(AgentMetadata left, AgentMetadata right) + { + return !(left == right); + } +} diff --git a/dotnet/src/Agents/Runtime/Abstractions/AgentProxy.cs b/dotnet/src/Agents/Runtime/Abstractions/AgentProxy.cs new file mode 100644 index 000000000000..fda0bbd8ee4f --- /dev/null +++ b/dotnet/src/Agents/Runtime/Abstractions/AgentProxy.cs @@ -0,0 +1,85 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.SemanticKernel.Agents.Runtime; + +/// +/// A proxy that allows you to use an in place of its associated . +/// +public class AgentProxy +{ + /// + /// The runtime instance used to interact with agents. + /// + private readonly IAgentRuntime _runtime; + private AgentMetadata? _metadata; + + /// + /// Initializes a new instance of the class. + /// + public AgentProxy(AgentId agentId, IAgentRuntime runtime) + { + this.Id = agentId; + this._runtime = runtime; + } + + /// + /// The target agent for this proxy. + /// + public AgentId Id { get; } + + /// + /// Gets the metadata of the agent. + /// + /// + /// An instance of containing details about the agent. + /// + public AgentMetadata Metadata => this._metadata ??= this.QueryMetadataAndUnwrap(); + + /// + /// Sends a message to the agent and processes the response. + /// + /// The message to send to the agent. + /// The agent that is sending the message. + /// + /// The message ID. If null, a new message ID will be generated. + /// This message ID must be unique and is recommended to be a UUID. + /// + /// + /// A token used to cancel an in-progress operation. Defaults to null. + /// + /// A task representing the asynchronous operation, returning the response from the agent. + public ValueTask SendMessageAsync(object message, AgentId sender, string? messageId = null, CancellationToken cancellationToken = default) + { + return this._runtime.SendMessageAsync(message, this.Id, sender, messageId, cancellationToken); + } + + /// + /// Loads the state of the agent from a previously saved state. + /// + /// A dictionary representing the state of the agent. Must be JSON serializable. + /// A task representing the asynchronous operation. + public ValueTask LoadStateAsync(JsonElement state) + { + return this._runtime.LoadAgentStateAsync(this.Id, state); + } + + /// + /// Saves the state of the agent. The result must be JSON serializable. + /// + /// A task representing the asynchronous operation, returning a dictionary containing the saved state. + public ValueTask SaveStateAsync() + { + return this._runtime.SaveAgentStateAsync(this.Id); + } + + private AgentMetadata QueryMetadataAndUnwrap() + { +#pragma warning disable VSTHRD002 // Avoid problematic synchronous waits + return this._runtime.GetAgentMetadataAsync(this.Id).AsTask().ConfigureAwait(false).GetAwaiter().GetResult(); +#pragma warning restore VSTHRD002 // Avoid problematic synchronous waits + } +} diff --git a/dotnet/src/Agents/Runtime/Abstractions/AgentType.cs b/dotnet/src/Agents/Runtime/Abstractions/AgentType.cs new file mode 100644 index 000000000000..a16b67058e97 --- /dev/null +++ b/dotnet/src/Agents/Runtime/Abstractions/AgentType.cs @@ -0,0 +1,103 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Text.RegularExpressions; + +namespace Microsoft.SemanticKernel.Agents.Runtime; + +/// +/// Represents the type of an agent as a string. +/// This is a strongly-typed wrapper around a string, ensuring type safety when working with agent types. +/// +/// +/// This struct is immutable and provides implicit conversion to and from . +/// +public readonly partial struct AgentType : IEquatable +{ +#if NET + [GeneratedRegex("^[a-zA-Z_][a-zA-Z0-9_]*$")] + private static partial Regex TypeRegex(); +#else + private static Regex TypeRegex() => new("^[a-zA-Z_][a-zA-Z0-9_]*$", RegexOptions.Compiled); +#endif + + internal static void Validate(string type) + { + if (string.IsNullOrWhiteSpace(type) || !TypeRegex().IsMatch(type)) + { + throw new ArgumentException($"Invalid AgentId type: '{type}'. Must be alphanumeric (a-z, 0-9, _) and cannot start with a number or contain spaces."); + } + } + + /// + /// Initializes a new instance of the struct. + /// + /// The agent type. + public AgentType(string type) + { + Validate(type); + this.Name = type; + } + + /// + /// The string representation of this agent type. + /// + public string Name { get; } + + /// + /// Returns the string representation of the . + /// + /// A string in the format "type/source". + public override readonly string ToString() => this.Name; + + /// + /// Explicitly converts a to an . + /// + /// The .NET to convert. + /// An instance with the name of the provided type. + public static explicit operator AgentType(Type type) => new(type.Name); + + /// + /// Implicitly converts a to an . + /// + /// The string representation of the agent type. + /// An instance with the given name. + public static implicit operator AgentType(string type) => new(type); + + /// + /// Implicitly converts an to a . + /// + /// The instance. + /// The string representation of the agent type. + public static implicit operator string(AgentType type) => type.ToString(); + + /// + public override bool Equals(object? obj) + { + return obj is AgentType other && this.Equals(other); + } + + /// + public bool Equals(AgentType other) + { + return this.Name.Equals(other.Name, StringComparison.Ordinal); + } + + /// + public override int GetHashCode() + { + return this.Name.GetHashCode(); + } + + /// + public static bool operator ==(AgentType left, AgentType right) + { + return left.Equals(right); + } + + /// + public static bool operator !=(AgentType left, AgentType right) + { + return !(left == right); + } +} diff --git a/dotnet/src/Agents/Runtime/Abstractions/Exceptions/CantHandleException.cs b/dotnet/src/Agents/Runtime/Abstractions/Exceptions/CantHandleException.cs new file mode 100644 index 000000000000..e3899559aac5 --- /dev/null +++ b/dotnet/src/Agents/Runtime/Abstractions/Exceptions/CantHandleException.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.SemanticKernel.Agents.Runtime; + +/// +/// Exception thrown when a handler cannot process the given message. +/// +[ExcludeFromCodeCoverage] +public class CantHandleException : Exception +{ + /// + /// Initializes a new instance of the class. + /// + public CantHandleException() : base("The handler cannot process the given message.") { } + + /// + /// Initializes a new instance of the class with a custom error message. + /// + /// The custom error message. + public CantHandleException(string message) : base(message) { } + + /// + /// Initializes a new instance of the class with a custom error message and an inner exception. + /// + /// The custom error message. + /// The inner exception that caused this error. + public CantHandleException(string message, Exception innerException) : base(message, innerException) { } +} diff --git a/dotnet/src/Agents/Runtime/Abstractions/Exceptions/MessageDroppedException.cs b/dotnet/src/Agents/Runtime/Abstractions/Exceptions/MessageDroppedException.cs new file mode 100644 index 000000000000..81a3542c6a7b --- /dev/null +++ b/dotnet/src/Agents/Runtime/Abstractions/Exceptions/MessageDroppedException.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.SemanticKernel.Agents.Runtime; + +/// +/// Exception thrown when a message is dropped. +/// +[ExcludeFromCodeCoverage] +public class MessageDroppedException : Exception +{ + /// + /// Initializes a new instance of the class. + /// + public MessageDroppedException() : base("The message was dropped.") { } + + /// + /// Initializes a new instance of the class with a custom error message. + /// + /// The custom error message. + public MessageDroppedException(string message) : base(message) { } + + /// + /// Initializes a new instance of the class with a custom error message and an inner exception. + /// + /// The custom error message. + /// The inner exception that caused this error. + public MessageDroppedException(string message, Exception innerException) : base(message, innerException) { } +} diff --git a/dotnet/src/Agents/Runtime/Abstractions/Exceptions/NotAccessibleException.cs b/dotnet/src/Agents/Runtime/Abstractions/Exceptions/NotAccessibleException.cs new file mode 100644 index 000000000000..6e5866e4ede1 --- /dev/null +++ b/dotnet/src/Agents/Runtime/Abstractions/Exceptions/NotAccessibleException.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.SemanticKernel.Agents.Runtime; + +/// +/// Exception thrown when an attempt is made to access an unavailable value, such as a remote resource. +/// +[ExcludeFromCodeCoverage] +public class NotAccessibleException : Exception +{ + /// + /// Initializes a new instance of the class. + /// + public NotAccessibleException() : base("The requested value is not accessible.") { } + + /// + /// Initializes a new instance of the class with a custom error message. + /// + /// The custom error message. + public NotAccessibleException(string message) : base(message) { } + + /// + /// Initializes a new instance of the class with a custom error message and an inner exception. + /// + /// The custom error message. + /// The inner exception that caused this error. + public NotAccessibleException(string message, Exception innerException) : base(message, innerException) { } +} diff --git a/dotnet/src/Agents/Runtime/Abstractions/Exceptions/UndeliverableException.cs b/dotnet/src/Agents/Runtime/Abstractions/Exceptions/UndeliverableException.cs new file mode 100644 index 000000000000..aff3665cf8c0 --- /dev/null +++ b/dotnet/src/Agents/Runtime/Abstractions/Exceptions/UndeliverableException.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Diagnostics.CodeAnalysis; +namespace Microsoft.SemanticKernel.Agents.Runtime; + +/// +/// Exception thrown when a message cannot be delivered. +/// +[ExcludeFromCodeCoverage] +public class UndeliverableException : Exception +{ + /// + /// Initializes a new instance of the class. + /// + public UndeliverableException() : base("The message cannot be delivered.") { } + + /// + /// Initializes a new instance of the class with a custom error message. + /// + /// The custom error message. + public UndeliverableException(string message) : base(message) { } + + /// + /// Initializes a new instance of the class with a custom error message and an inner exception. + /// + /// The custom error message. + /// The inner exception that caused this error. + public UndeliverableException(string message, Exception innerException) : base(message, innerException) { } +} diff --git a/dotnet/src/Agents/Runtime/Abstractions/IAgent.cs b/dotnet/src/Agents/Runtime/Abstractions/IAgent.cs new file mode 100644 index 000000000000..049f50c65d47 --- /dev/null +++ b/dotnet/src/Agents/Runtime/Abstractions/IAgent.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading.Tasks; + +namespace Microsoft.SemanticKernel.Agents.Runtime; + +/// +/// Represents an agent within the runtime that can process messages, maintain state, and be closed when no longer needed. +/// +public interface IAgent : ISaveState +{ + /// + /// Gets the unique identifier of the agent. + /// + AgentId Id { get; } + + /// + /// Gets metadata associated with the agent. + /// + AgentMetadata Metadata { get; } + + /// + /// Handles an incoming message for the agent. + /// This should only be called by the runtime, not by other agents. + /// + /// The received message. The type should match one of the expected subscription types. + /// The context of the message, providing additional metadata. + /// + /// A task representing the asynchronous operation, returning a response to the message. + /// The response can be null if no reply is necessary. + /// + /// Thrown if the message was cancelled. + /// Thrown if the agent cannot handle the message. + ValueTask OnMessageAsync(object message, MessageContext messageContext); // TODO: How do we express this properly in .NET? +} diff --git a/dotnet/src/Agents/Runtime/Abstractions/IAgentRuntime.cs b/dotnet/src/Agents/Runtime/Abstractions/IAgentRuntime.cs new file mode 100644 index 000000000000..49879b0a6c87 --- /dev/null +++ b/dotnet/src/Agents/Runtime/Abstractions/IAgentRuntime.cs @@ -0,0 +1,123 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Hosting; + +namespace Microsoft.SemanticKernel.Agents.Runtime; + +/// +/// Defines the runtime environment for agents, managing message sending, subscriptions, agent resolution, and state persistence. +/// +public interface IAgentRuntime : IHostedService, ISaveState +{ + /// + /// Sends a message to an agent and gets a response. + /// This method should be used to communicate directly with an agent. + /// + /// The message to send. + /// The agent to send the message to. + /// The agent sending the message. Should be null if sent from an external source. + /// A unique identifier for the message. If null, a new ID will be generated. + /// A token to cancel the operation if needed. + /// A task representing the asynchronous operation, returning the response from the agent. + /// Thrown if the recipient cannot handle the message. + /// Thrown if the message cannot be delivered. + ValueTask SendMessageAsync(object message, AgentId recipient, AgentId? sender = null, string? messageId = null, CancellationToken cancellationToken = default); + + /// + /// Publishes a message to all agents subscribed to the given topic. + /// No responses are expected from publishing. + /// + /// The message to publish. + /// The topic to publish the message to. + /// The agent sending the message. Defaults to null. + /// A unique message ID. If null, a new one will be generated. + /// A token to cancel the operation if needed. + /// A task representing the asynchronous operation. + /// Thrown if the message cannot be delivered. + ValueTask PublishMessageAsync(object message, TopicId topic, AgentId? sender = null, string? messageId = null, CancellationToken cancellationToken = default); + + /// + /// Retrieves an agent by its unique identifier. + /// + /// The unique identifier of the agent. + /// If true, the agent is fetched lazily. + /// A task representing the asynchronous operation, returning the agent's ID. + ValueTask GetAgentAsync(AgentId agentId, bool lazy = true/*, CancellationToken? = default*/); + + /// + /// Retrieves an agent by its type. + /// + /// The type of the agent. + /// An optional key to specify variations of the agent. Defaults to "default". + /// If true, the agent is fetched lazily. + /// A task representing the asynchronous operation, returning the agent's ID. + ValueTask GetAgentAsync(AgentType agentType, string key = "default", bool lazy = true/*, CancellationToken? = default*/); + + /// + /// Retrieves an agent by its string representation. + /// + /// The string representation of the agent. + /// An optional key to specify variations of the agent. Defaults to "default". + /// If true, the agent is fetched lazily. + /// A task representing the asynchronous operation, returning the agent's ID. + ValueTask GetAgentAsync(string agent, string key = "default", bool lazy = true/*, CancellationToken? = default*/); + + /// + /// Saves the state of an agent. + /// The result must be JSON serializable. + /// + /// The ID of the agent whose state is being saved. + /// A task representing the asynchronous operation, returning a dictionary of the saved state. + ValueTask SaveAgentStateAsync(AgentId agentId/*, CancellationToken? cancellationToken = default*/); + + /// + /// Loads the saved state into an agent. + /// + /// The ID of the agent whose state is being restored. + /// The state dictionary to restore. + /// A task representing the asynchronous operation. + ValueTask LoadAgentStateAsync(AgentId agentId, JsonElement state/*, CancellationToken? cancellationToken = default*/); + + /// + /// Retrieves metadata for an agent. + /// + /// The ID of the agent. + /// A task representing the asynchronous operation, returning the agent's metadata. + ValueTask GetAgentMetadataAsync(AgentId agentId/*, CancellationToken? cancellationToken = default*/); + + /// + /// Adds a new subscription for the runtime to handle when processing published messages. + /// + /// The subscription to add. + /// A task representing the asynchronous operation. + ValueTask AddSubscriptionAsync(ISubscriptionDefinition subscription/*, CancellationToken? cancellationToken = default*/); + + /// + /// Removes a subscription from the runtime. + /// + /// The unique identifier of the subscription to remove. + /// A task representing the asynchronous operation. + /// Thrown if the subscription does not exist. + ValueTask RemoveSubscriptionAsync(string subscriptionId/*, CancellationToken? cancellationToken = default*/); + + /// + /// Registers an agent factory with the runtime, associating it with a specific agent type. + /// The type must be unique. + /// + /// The agent type to associate with the factory. + /// A function that asynchronously creates the agent instance. + /// A task representing the asynchronous operation, returning the registered . + ValueTask RegisterAgentFactoryAsync(AgentType type, Func> factoryFunc); + + /// + /// Attempts to retrieve an for the specified agent. + /// + /// The ID of the agent. + /// A task representing the asynchronous operation, returning an if successful. + ValueTask TryGetAgentProxyAsync(AgentId agentId); +} diff --git a/dotnet/src/Agents/Runtime/Abstractions/IHostableAgent.cs b/dotnet/src/Agents/Runtime/Abstractions/IHostableAgent.cs new file mode 100644 index 000000000000..1833fb1a9522 --- /dev/null +++ b/dotnet/src/Agents/Runtime/Abstractions/IHostableAgent.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; + +namespace Microsoft.SemanticKernel.Agents.Runtime; + +/// +/// Represents an agent that can be explicitly hosted and closed when the runtime shuts down. +/// +public interface IHostableAgent : IAgent +{ + /// + /// Called when the runtime is closing. + /// + /// A task representing the asynchronous operation. + ValueTask CloseAsync(); +} diff --git a/dotnet/src/Agents/Runtime/Abstractions/ISaveState.cs b/dotnet/src/Agents/Runtime/Abstractions/ISaveState.cs new file mode 100644 index 000000000000..3b48bb04910a --- /dev/null +++ b/dotnet/src/Agents/Runtime/Abstractions/ISaveState.cs @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using System.Threading.Tasks; + +namespace Microsoft.SemanticKernel.Agents.Runtime; + +/// +/// Defines a contract for saving and loading the state of an object. +/// The state must be JSON serializable. +/// +public interface ISaveState +{ + /// + /// Saves the current state of the object. + /// + /// + /// A task representing the asynchronous operation, returning a dictionary + /// containing the saved state. The structure of the state is implementation-defined + /// but must be JSON serializable. + /// + ValueTask SaveStateAsync(); + + /// + /// Loads a previously saved state into the object. + /// + /// + /// A dictionary representing the saved state. The structure of the state + /// is implementation-defined but must be JSON serializable. + /// + /// A task representing the asynchronous operation. + ValueTask LoadStateAsync(JsonElement state); +} diff --git a/dotnet/src/Agents/Runtime/Abstractions/ISubscriptionDefinition.cs b/dotnet/src/Agents/Runtime/Abstractions/ISubscriptionDefinition.cs new file mode 100644 index 000000000000..5b310d45ae82 --- /dev/null +++ b/dotnet/src/Agents/Runtime/Abstractions/ISubscriptionDefinition.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.SemanticKernel.Agents.Runtime; + +/// +/// Defines a subscription that matches topics and maps them to agents. +/// +public interface ISubscriptionDefinition +{ + /// + /// Gets the unique identifier of the subscription. + /// + string Id { get; } + + /// + /// Determines whether the specified object is equal to the current subscription. + /// + /// The object to compare with the current instance. + /// true if the specified object is equal to this instance; otherwise, false. + bool Equals([NotNullWhen(true)] object? obj); + + /// + /// Determines whether the specified subscription is equal to the current subscription. + /// + /// The subscription to compare. + /// true if the subscriptions are equal; otherwise, false. + bool Equals(ISubscriptionDefinition? other); + + /// + /// Returns a hash code for this subscription. + /// + /// A hash code for the subscription. + int GetHashCode(); + + /// + /// Checks if a given matches the subscription. + /// + /// The topic to check. + /// true if the topic matches the subscription; otherwise, false. + bool Matches(TopicId topic); + + /// + /// Maps a to an . + /// Should only be called if returns true. + /// + /// The topic to map. + /// The that should handle the topic. + AgentId MapToAgent(TopicId topic); +} diff --git a/dotnet/src/Agents/Runtime/Abstractions/Internal/KeyValueParserExtensions.cs b/dotnet/src/Agents/Runtime/Abstractions/Internal/KeyValueParserExtensions.cs new file mode 100644 index 000000000000..50b583c9f59d --- /dev/null +++ b/dotnet/src/Agents/Runtime/Abstractions/Internal/KeyValueParserExtensions.cs @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Text.RegularExpressions; + +namespace Microsoft.SemanticKernel.Agents.Runtime.Internal; + +/// +/// Provides helper methods for parsing key-value string representations. +/// +internal static class KeyValueParserExtensions +{ + /// + /// The regular expression pattern used to match key-value pairs in the format "key/value". + /// + private const string KVPairPattern = @"^(?\w+)/(?\w+)$"; + + /// + /// The compiled regex used for extracting key-value pairs from a string. + /// + private static readonly Regex KVPairRegex = new(KVPairPattern, RegexOptions.Compiled); + + /// + /// Parses a string in the format "key/value" into a tuple containing the key and value. + /// + /// The input string containing a key-value pair. + /// The expected name of the key component. + /// The expected name of the value component. + /// A tuple containing the extracted key and value. + /// + /// Thrown if the input string does not match the expected "key/value" format. + /// + /// + /// Example usage: + /// + /// string input = "agent1/12345"; + /// var result = input.ToKVPair("Type", "Key"); + /// Console.WriteLine(result.Item1); // Outputs: agent1 + /// Console.WriteLine(result.Item2); // Outputs: 12345 + /// + /// + public static (string, string) ToKeyValuePair(this string inputPair, string keyName, string valueName) + { + Match match = KVPairRegex.Match(inputPair); + if (match.Success) + { + return (match.Groups["key"].Value, match.Groups["value"].Value); + } + + throw new FormatException($"Invalid key-value pair format: {inputPair}; expecting \"{{{keyName}}}/{{{valueName}}}\""); + } +} diff --git a/dotnet/src/Agents/Runtime/Abstractions/MessageContext.cs b/dotnet/src/Agents/Runtime/Abstractions/MessageContext.cs new file mode 100644 index 000000000000..b6337d3cfb7e --- /dev/null +++ b/dotnet/src/Agents/Runtime/Abstractions/MessageContext.cs @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading; + +namespace Microsoft.SemanticKernel.Agents.Runtime; + +/// +/// Represents the context of a message being sent within the agent runtime. +/// This includes metadata such as the sender, topic, RPC status, and cancellation handling. +/// +public class MessageContext(string messageId, CancellationToken cancellationToken) +{ + /// + /// Initializes a new instance of the class. + /// + public MessageContext(CancellationToken cancellation) : this(Guid.NewGuid().ToString(), cancellation) + { } + + /// + /// Gets or sets the unique identifier for this message. + /// + public string MessageId { get; } = messageId; + + /// + /// Gets or sets the cancellation token associated with this message. + /// This can be used to cancel the operation if necessary. + /// + public CancellationToken CancellationToken { get; } = cancellationToken; + + /// + /// Gets or sets the sender of the message. + /// If null, the sender is unspecified. + /// + public AgentId? Sender { get; set; } + + /// + /// Gets or sets the topic associated with the message. + /// If null, the message is not tied to a specific topic. + /// + public TopicId? Topic { get; set; } + + /// + /// Gets or sets a value indicating whether this message is part of an RPC (Remote Procedure Call). + /// + public bool IsRpc { get; set; } +} diff --git a/dotnet/src/Agents/Runtime/Abstractions/Runtime.Abstractions.csproj b/dotnet/src/Agents/Runtime/Abstractions/Runtime.Abstractions.csproj new file mode 100644 index 000000000000..9f687750928e --- /dev/null +++ b/dotnet/src/Agents/Runtime/Abstractions/Runtime.Abstractions.csproj @@ -0,0 +1,38 @@ + + + + Microsoft.SemanticKernel.Agents.Runtime.Abstractions + Microsoft.SemanticKernel.Agents.Runtime.Abstractions + net8.0;netstandard2.0 + $(NoWarn);IDE1006;IDE0130 + preview + SKIPSKABSTRACTION + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dotnet/src/Agents/Runtime/Abstractions/TopicId.cs b/dotnet/src/Agents/Runtime/Abstractions/TopicId.cs new file mode 100644 index 000000000000..9f5f2be120e0 --- /dev/null +++ b/dotnet/src/Agents/Runtime/Abstractions/TopicId.cs @@ -0,0 +1,149 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Diagnostics.CodeAnalysis; +using Microsoft.SemanticKernel.Agents.Runtime.Internal; + +namespace Microsoft.SemanticKernel.Agents.Runtime; + +/// +/// Represents a topic identifier that defines the scope of a broadcast message. +/// The agent runtime implements a publish-subscribe model through its broadcast API, +/// where messages must be published with a specific topic. +/// +/// See the Python equivalent: +/// CloudEvents Type Specification. +/// +public struct TopicId : IEquatable +{ + /// + /// The default source value used when no source is explicitly provided. + /// + public const string DefaultSource = "default"; + + /// + /// The separator character for the string representation of the topic. + /// + public const string Separator = "/"; + + /// + /// Gets the type of the event that this represents. + /// This adheres to the CloudEvents specification. + /// + /// Must match the pattern: ^[\w\-\.\:\=]+$. + /// + /// Learn more here: + /// CloudEvents Type. + /// + public string Type { get; } + + /// + /// Gets the source that identifies the context in which an event happened. + /// This adheres to the CloudEvents specification. + /// + /// Learn more here: + /// CloudEvents Source. + /// + public string Source { get; } + + /// + /// Initializes a new instance of the struct. + /// + /// The type of the topic. + /// The source of the event. Defaults to if not specified. + public TopicId(string type, string source = DefaultSource) + { + this.Type = type; + this.Source = source; + } + + /// + /// Initializes a new instance of the struct from a tuple. + /// + /// A tuple containing the topic type and source. + public TopicId((string Type, string Source) kvPair) : this(kvPair.Type, kvPair.Source) + { + } + + /// + /// Converts a string in the format "type/source" into a . + /// + /// The topic ID string. + /// An instance of . + /// Thrown when the string is not in the valid "type/source" format. + public static TopicId FromStr(string maybeTopicId) => new(maybeTopicId.ToKeyValuePair(nameof(Type), nameof(Source))); + + /// + /// Returns the string representation of the . + /// + /// A string in the format "type/source". + public override readonly string ToString() => $"{this.Type}{Separator}{this.Source}"; + + /// + /// Determines whether the specified object is equal to the current . + /// + /// The object to compare with the current instance. + /// true if the specified object is equal to the current ; otherwise, false. + public override readonly bool Equals([NotNullWhen(true)] object? obj) + { + if (obj is TopicId other) + { + return this.Type == other.Type && this.Source == other.Source; + } + + return false; + } + + /// + /// Determines whether the specified object is equal to the current . + /// + /// The object to compare with the current instance. + /// true if the specified object is equal to the current ; otherwise, false. + public readonly bool Equals([NotNullWhen(true)] TopicId other) + { + return this.Type == other.Type && this.Source == other.Source; + } + + /// + /// Returns a hash code for this . + /// + /// A hash code for the current instance. + public override readonly int GetHashCode() + { + return HashCode.Combine(this.Type, this.Source); + } + + /// + /// Explicitly converts a string to a . + /// + /// The string representation of a topic ID. + /// An instance of . + public static explicit operator TopicId(string id) => FromStr(id); + + // TODO: Implement < for wildcard matching (type, *) + // == => < + // Type == other.Type => < + /// + /// Determines whether the given matches another topic. + /// + /// The topic ID to compare against. + /// + /// true if the topic types are equal; otherwise, false. + /// + public readonly bool IsWildcardMatch(TopicId other) + { + return this.Type == other.Type; + } + + /// + public static bool operator ==(TopicId left, TopicId right) + { + return left.Equals(right); + } + + /// + public static bool operator !=(TopicId left, TopicId right) + { + return !(left == right); + } +} diff --git a/dotnet/src/Agents/Runtime/Core.Tests/AgentRuntimeExtensionsTests.cs b/dotnet/src/Agents/Runtime/Core.Tests/AgentRuntimeExtensionsTests.cs new file mode 100644 index 000000000000..9cacde1f3fd8 --- /dev/null +++ b/dotnet/src/Agents/Runtime/Core.Tests/AgentRuntimeExtensionsTests.cs @@ -0,0 +1,155 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.SemanticKernel.Agents.Runtime.InProcess; +using Xunit; + +namespace Microsoft.SemanticKernel.Agents.Runtime.Core.Tests; + +[Trait("Category", "Unit")] +public class AgentRuntimeExtensionsTests +{ + private const string TestTopic1 = "test.1.topic"; + private const string TestTopic2 = "test.2.topic"; + private const string TestTopicPrefix = "test.2"; + + [Fact] + public async Task RegisterAgentTypeWithStringAsync_WithBaseAgent() + { + // Arrange + string agentTypeName = nameof(TestAgent); + Guid value = Guid.NewGuid(); + ServiceProvider serviceProvider = new ServiceCollection().BuildServiceProvider(); + + await using InProcessRuntime runtime = new(); + + // Act + AgentType registeredType = await runtime.RegisterAgentTypeAsync(agentTypeName, serviceProvider, [value]); + AgentId registeredId = await runtime.GetAgentAsync(agentTypeName, lazy: false); + + // Assert + Assert.Equal(agentTypeName, registeredType.Name); + Assert.Equal(agentTypeName, registeredId.Type); + + // Act + TestAgent agent = await runtime.TryGetUnderlyingAgentInstanceAsync(registeredId); + + // Assert + Assert.NotNull(agent); + Assert.Equal(agentTypeName, agent.Id.Type); + TestAgent testAgent = Assert.IsType(agent); + Assert.Equal(value, testAgent.Value); + } + + [Fact] + public async Task RegisterAgentTypeWithStringAsync_NotWithBaseAgent() + { + // Arrange + string agentTypeName = nameof(NotBaseAgent); + ServiceProvider serviceProvider = new ServiceCollection().BuildServiceProvider(); + + await using InProcessRuntime runtime = new(); + + // Act + AgentType registeredType = await runtime.RegisterAgentTypeAsync(agentTypeName, typeof(NotBaseAgent), serviceProvider); + + // Assert + await Assert.ThrowsAsync(async () => await runtime.GetAgentAsync(agentTypeName, lazy: false)); + } + + [Fact] + public async Task RegisterImplicitAgentSubscriptionsAsync() + { + // Arrange + string agentTypeName = nameof(TestAgent); + TopicId topic1 = new(TestTopic1); + TopicId topic2 = new(TestTopic2); + + ServiceProvider serviceProvider = new ServiceCollection().BuildServiceProvider(); + await using InProcessRuntime runtime = new(); + + // Act + AgentType registeredType = await runtime.RegisterAgentTypeAsync(agentTypeName, serviceProvider, [Guid.Empty]); + await runtime.RegisterImplicitAgentSubscriptionsAsync(agentTypeName); + + // Arrange + await runtime.StartAsync(); + + try + { + // Act - publish messages to each topic + string messageText1 = "Test message #1"; + string messageText2 = "Test message #1"; + await runtime.PublishMessageAsync(messageText1, topic1); + await runtime.PublishMessageAsync(messageText2, topic2); + + // Get agent and verify it received messages + AgentId registeredId = await runtime.GetAgentAsync(agentTypeName, lazy: false); + TestAgent agent = await runtime.TryGetUnderlyingAgentInstanceAsync(registeredId); + + // Assert + Assert.NotNull(agent); + Assert.Equal(2, agent.ReceivedMessages.Count); + Assert.Contains(messageText1, agent.ReceivedMessages); + Assert.Contains(messageText2, agent.ReceivedMessages); + } + finally + { + // Arrange + await runtime.StopAsync(); + } + } + + [TypeSubscription(TestTopic1)] + [TypePrefixSubscription(TestTopicPrefix)] + private sealed class TestAgent : BaseAgent, IHandle + { + public List ReceivedMessages { get; } = []; + + public TestAgent(AgentId id, IAgentRuntime runtime, Guid value) + : base(id, runtime, "Test Subscribing Agent", null) + { + this.Value = value; + } + + public Guid Value { get; } + + public ValueTask HandleAsync(string item, MessageContext messageContext) + { + this.ReceivedMessages.Add(item); + + return ValueTask.CompletedTask; + } + } + + private sealed class NotBaseAgent : IHostableAgent + { + public AgentId Id => throw new NotImplementedException(); + + public AgentMetadata Metadata => throw new NotImplementedException(); + + public ValueTask CloseAsync() + { + throw new NotImplementedException(); + } + + public ValueTask LoadStateAsync(JsonElement state) + { + throw new NotImplementedException(); + } + + public ValueTask OnMessageAsync(object message, MessageContext messageContext) + { + throw new NotImplementedException(); + } + + public ValueTask SaveStateAsync() + { + throw new NotImplementedException(); + } + } +} diff --git a/dotnet/src/Agents/Runtime/Core.Tests/AgentsAppBuilderTests.cs b/dotnet/src/Agents/Runtime/Core.Tests/AgentsAppBuilderTests.cs new file mode 100644 index 000000000000..cce3e763b012 --- /dev/null +++ b/dotnet/src/Agents/Runtime/Core.Tests/AgentsAppBuilderTests.cs @@ -0,0 +1,167 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Reflection; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.SemanticKernel.Agents.Runtime.InProcess; +using Xunit; + +namespace Microsoft.SemanticKernel.Agents.Runtime.Core.Tests; + +[Trait("Category", "Unit")] +public class AgentsAppBuilderTests +{ + [Fact] + public void Constructor_WithoutParameters_ShouldCreateNewHostApplicationBuilder() + { + // Act + AgentsAppBuilder builder = new(); + + // Assert + builder.Services.Should().NotBeNull(); + builder.Configuration.Should().NotBeNull(); + } + + [Fact] + public void Constructor_WithBaseBuilder_ShouldUseProvidedBuilder() + { + // Arrange + HostApplicationBuilder baseBuilder = new(); + + // Add a test service to verify it's the same builder + baseBuilder.Services.AddSingleton(); + + // Act + AgentsAppBuilder builder = new(baseBuilder); + + // Assert + builder.Services.Should().BeSameAs(baseBuilder.Services); + builder.Services.BuildServiceProvider().GetService().Should().NotBeNull(); + } + + [Fact] + public void Services_ShouldReturnBuilderServices() + { + // Arrange + AgentsAppBuilder builder = new(); + + // Act + IServiceCollection services = builder.Services; + + // Assert + services.Should().NotBeNull(); + } + + [Fact] + public void Configuration_ShouldReturnBuilderConfiguration() + { + // Arrange + AgentsAppBuilder builder = new(); + + // Act + IConfiguration configuration = builder.Configuration; + + // Assert + configuration.Should().NotBeNull(); + } + + [Fact] + public async Task UseRuntime_ShouldRegisterRuntimeInServices() + { + // Arrange + AgentsAppBuilder builder = new(); + await using InProcessRuntime runtime = new(); + + // Act + AgentsAppBuilder result = builder.UseRuntime(runtime); + + // Assert + result.Should().BeSameAs(builder); + IAgentRuntime? resolvedRuntime = builder.Services.BuildServiceProvider().GetService(); + resolvedRuntime.Should().BeSameAs(runtime); + + // Verify it's also registered as a hosted service + IHostedService? hostedService = builder.Services.BuildServiceProvider().GetService(); + hostedService.Should().BeSameAs(runtime); + } + + [Fact] + public void AddAgentsFromAssemblies_WithoutParameters_ShouldScanCurrentDomain() + { + // Arrange + AgentsAppBuilder builder = new(); + + // Act - using the parameterless version calls AppDomain.CurrentDomain.GetAssemblies() + builder.AddAgentsFromAssemblies(); + + // Assert + // We just verify it doesn't throw, as the actual agents registered depend on the loaded assemblies + } + + [Fact] + public void AddAgentsFromAssemblies_WithAssemblies_ShouldRegisterAgentsFromProvidedAssemblies() + { + // Arrange + AgentsAppBuilder builder = new(); + Assembly testAssembly = typeof(TestAgent).Assembly; + + // Act + AgentsAppBuilder result = builder.AddAgentsFromAssemblies(testAssembly); + + // Assert + result.Should().BeSameAs(builder); + // The assertion on actual agent registration is done in BuildAsync test + } + + [Fact] + public void AddAgent_ShouldRegisterAgentType() + { + // Arrange + AgentsAppBuilder builder = new(); + AgentType agentType = new("TestAgent"); + + // Act + AgentsAppBuilder result = builder.AddAgent(agentType); + + // Assert + result.Should().BeSameAs(builder); + // Actual agent registration is tested in BuildAsync + } + + [Fact] + public async Task BuildAsync_ShouldReturnAgentsAppWithRegisteredAgents() + { + // Arrange + AgentsAppBuilder builder = new(); + await using InProcessRuntime runtime = new(); + builder.UseRuntime(runtime); + + AgentType testAgentType = new("TestAgent"); + builder.AddAgent(testAgentType); + + // Act + AgentsApp app = await builder.BuildAsync(); + AgentId agentId = await runtime.GetAgentAsync(testAgentType); + + // Assert + app.Should().NotBeNull(); + app.Host.Should().NotBeNull(); + app.AgentRuntime.Should().BeSameAs(runtime); + agentId.Type.Should().BeSameAs(testAgentType.Name); + } + + // Private test interfaces and classes to support the tests + private interface ITestService { } + + private sealed class TestService : ITestService { } + + private sealed class TestAgent : BaseAgent + { + public TestAgent(AgentId id, IAgentRuntime runtime, string description, ILogger? logger = null) + : base(id, runtime, description, logger) { } + } +} diff --git a/dotnet/src/Agents/Runtime/Core.Tests/AgentsAppTests.cs b/dotnet/src/Agents/Runtime/Core.Tests/AgentsAppTests.cs new file mode 100644 index 000000000000..9356d94b0134 --- /dev/null +++ b/dotnet/src/Agents/Runtime/Core.Tests/AgentsAppTests.cs @@ -0,0 +1,278 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Moq; +using Xunit; + +namespace Microsoft.SemanticKernel.Agents.Runtime.Core.Tests; + +[Trait("Category", "Unit")] +public class AgentsAppTests +{ + [Fact] + public void Constructor_ShouldInitializeHost() + { + // Arrange + Mock mockHost = new(); + + // Act + AgentsApp agentsApp = new(mockHost.Object); + + // Assert + agentsApp.Host.Should().BeSameAs(mockHost.Object); + } + + [Fact] + public void Services_ShouldReturnHostServices() + { + // Arrange + Mock mockServiceProvider = new(); + Mock mockHost = new(); + mockHost.Setup(h => h.Services).Returns(mockServiceProvider.Object); + AgentsApp agentsApp = new(mockHost.Object); + + // Act + IServiceProvider result = agentsApp.Services; + + // Assert + result.Should().BeSameAs(mockServiceProvider.Object); + } + + [Fact] + public void ApplicationLifetime_ShouldGetFromServices() + { + // Arrange + Mock mockLifetime = new(); + ServiceProvider serviceProvider = new ServiceCollection() + .AddSingleton(mockLifetime.Object) + .BuildServiceProvider(); + + Mock mockHost = new(); + mockHost.Setup(h => h.Services).Returns(serviceProvider); + + AgentsApp agentsApp = new(mockHost.Object); + + // Act + IHostApplicationLifetime result = agentsApp.ApplicationLifetime; + + // Assert + result.Should().BeSameAs(mockLifetime.Object); + } + + [Fact] + public void AgentRuntime_ShouldGetFromServices() + { + // Arrange + Mock mockAgentRuntime = new(); + ServiceProvider serviceProvider = new ServiceCollection() + .AddSingleton(mockAgentRuntime.Object) + .BuildServiceProvider(); + + Mock mockHost = new(); + mockHost.Setup(h => h.Services).Returns(serviceProvider); + + AgentsApp agentsApp = new(mockHost.Object); + + // Act + IAgentRuntime result = agentsApp.AgentRuntime; + + // Assert + result.Should().BeSameAs(mockAgentRuntime.Object); + } + + [Fact] + public async Task StartAsync_ShouldStartHost() + { + // Arrange + Mock mockHost = new(); + mockHost.Setup(h => h.StartAsync(It.IsAny())) + .Returns(Task.CompletedTask); + + AgentsApp agentsApp = new(mockHost.Object); + + // Act + await agentsApp.StartAsync(); + + // Assert + mockHost.Verify(h => h.StartAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task StartAsync_WhenAlreadyRunning_ShouldThrowInvalidOperationException() + { + // Arrange + Mock mockHost = new(); + AgentsApp agentsApp = new(mockHost.Object); + + // Act & Assert + await agentsApp.StartAsync(); + await Assert.ThrowsAsync(() => agentsApp.StartAsync().AsTask()); + } + + [Fact] + public async Task ShutdownAsync_ShouldStopHost() + { + // Arrange + Mock mockHost = new(); + mockHost.Setup(h => h.StopAsync(It.IsAny())) + .Returns(Task.CompletedTask); + + AgentsApp agentsApp = new(mockHost.Object); + await agentsApp.StartAsync(); // Start first so we can shut down + + // Act + await agentsApp.ShutdownAsync(); + + // Assert + mockHost.Verify(h => h.StopAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task ShutdownAsync_WhenNotRunning_ShouldThrowInvalidOperationException() + { + // Arrange + Mock mockHost = new(); + AgentsApp agentsApp = new(mockHost.Object); + + // Act & Assert + await Assert.ThrowsAsync(() => agentsApp.ShutdownAsync().AsTask()); + } + + [Fact] + public async Task PublishMessageAsync_WhenNotRunning_ShouldStartHostFirst() + { + // Arrange + Mock mockAgentRuntime = new(); + ServiceProvider serviceProvider = new ServiceCollection() + .AddSingleton(mockAgentRuntime.Object) + .BuildServiceProvider(); + + Mock mockHost = new(); + mockHost.Setup(h => h.Services).Returns(serviceProvider); + mockHost.Setup(h => h.StartAsync(It.IsAny())) + .Returns(Task.CompletedTask); + + AgentsApp agentsApp = new(mockHost.Object); + + string message = "test message"; + TopicId topic = new("test-topic"); + + // Act + await agentsApp.PublishMessageAsync(message, topic); + + // Assert + mockHost.Verify(h => h.StartAsync(It.IsAny()), Times.Once); + mockAgentRuntime.Verify( + r => + r.PublishMessageAsync( + message, + topic, + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task PublishMessageAsync_WhenRunning_ShouldNotStartHostAgain() + { + // Arrange + Mock mockAgentRuntime = new(); + ServiceProvider serviceProvider = new ServiceCollection() + .AddSingleton(mockAgentRuntime.Object) + .BuildServiceProvider(); + + Mock mockHost = new(); + mockHost.Setup(h => h.Services).Returns(serviceProvider); + mockHost.Setup(h => h.StartAsync(It.IsAny())) + .Returns(Task.CompletedTask); + + AgentsApp agentsApp = new(mockHost.Object); + await agentsApp.StartAsync(); // Start first + + string message = "test message"; + TopicId topic = new("test-topic"); + + // Act + await agentsApp.PublishMessageAsync(message, topic); + + // Assert + mockHost.Verify(h => h.StartAsync(It.IsAny()), Times.Once); + mockAgentRuntime.Verify( + r => + r.PublishMessageAsync( + message, + topic, + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task PublishMessageAsync_ShouldPassAllParameters() + { + // Arrange + Mock mockAgentRuntime = new(); + ServiceProvider serviceProvider = new ServiceCollection() + .AddSingleton(mockAgentRuntime.Object) + .BuildServiceProvider(); + + Mock mockHost = new(); + mockHost.Setup(h => h.Services).Returns(serviceProvider); + + AgentsApp agentsApp = new(mockHost.Object); + await agentsApp.StartAsync(); + + string message = "test message"; + TopicId topic = new("test-topic"); + string messageId = "test-message-id"; + + // Act + await agentsApp.PublishMessageAsync(message, topic, messageId, CancellationToken.None); + + // Assert + mockAgentRuntime.Verify( + r => + r.PublishMessageAsync( + message, + topic, + It.IsAny(), + messageId, + CancellationToken.None), + Times.Once); + } + + [Fact] + public async Task WaitForShutdownAsync_ShouldBlock() + { + // Arrange + IHost host = new HostApplicationBuilder().Build(); + + AgentsApp agentsApp = new(host); + await agentsApp.StartAsync(); + + ValueTask shutdownTask = ValueTask.CompletedTask; + try + { + // Assert - Verify initial state + agentsApp.ApplicationLifetime.ApplicationStopped.IsCancellationRequested.Should().BeFalse(); + + // Act + shutdownTask = agentsApp.ShutdownAsync(); + await agentsApp.WaitForShutdownAsync(); + + // Assert + agentsApp.ApplicationLifetime.ApplicationStopped.IsCancellationRequested.Should().BeTrue(); + } + finally + { + await shutdownTask; // Ensure shutdown completes + } + } +} diff --git a/dotnet/src/Agents/Runtime/Core.Tests/BaseAgentTests.cs b/dotnet/src/Agents/Runtime/Core.Tests/BaseAgentTests.cs new file mode 100644 index 000000000000..8b263aa5b11a --- /dev/null +++ b/dotnet/src/Agents/Runtime/Core.Tests/BaseAgentTests.cs @@ -0,0 +1,355 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.SemanticKernel.Agents.Runtime.InProcess; +using Moq; +using Xunit; + +namespace Microsoft.SemanticKernel.Agents.Runtime.Core.Tests; + +[Trait("Category", "Unit")] +public class BaseAgentTests +{ + [Fact] + public void Constructor_InitializesActivitySource_Correctly() + { + BaseAgent.TraceSource.Name.Should().Be("Microsoft.SemanticKernel.Agents.Runtime"); + } + + [Fact] + public void Constructor_InitializesProperties_Correctly() + { + // Arrange + using ILoggerFactory loggerFactory = LoggerFactory.Create(_ => { }); + ILogger logger = loggerFactory.CreateLogger(); + AgentId agentId = new("TestType", "TestKey"); + const string description = "Test Description"; + Mock runtimeMock = new(); + + // Act + TestAgentA agent = new(agentId, runtimeMock.Object, description, logger); + + // Assert + agent.Id.Should().Be(agentId); + agent.Metadata.Type.Should().Be(agentId.Type); + agent.Metadata.Key.Should().Be(agentId.Key); + agent.Metadata.Description.Should().Be(description); + agent.Logger.Should().Be(logger); + } + + [Fact] + public void Constructor_WithNoLogger_CreatesNullLogger() + { + // Arrange + AgentId agentId = new("TestType", "TestKey"); + string description = "Test Description"; + Mock runtimeMock = new(); + + // Act + TestAgentA agent = new(agentId, runtimeMock.Object, description); + + // Assert + agent.Logger.Should().Be(NullLogger.Instance); + } + + [Fact] + public async Task OnMessageAsync_WithoutMatchingHandler() + { + // Arrange + Mock runtimeMock = new(); + AgentId agentId = new("TestType", "TestKey"); + TestAgentA agent = new(agentId, runtimeMock.Object, "Test Agent"); + MessageContext context = new(CancellationToken.None); + + // Act + const string message = "This is a TestMessage"; + object? result = await agent.OnMessageAsync(message, context); + + // Assert + result.Should().BeNull(); + agent.ReceivedMessages.Should().BeEmpty(); + } + + [Fact] + public async Task OnMessageAsync_WithMatchingHandler_NoResult() + { + // Arrange + Mock runtimeMock = new(); + AgentId agentId = new("TestType", "TestKey"); + TestAgentA agent = new(agentId, runtimeMock.Object, "Test Agent"); + + // Act + TestMessage message = new() { Content = "Hello World" }; + MessageContext context = new(CancellationToken.None); + object? result = await agent.OnMessageAsync(message, context); + + // Assert + result.Should().BeNull(); + agent.ReceivedMessages.Should().ContainSingle(); + } + + [Fact] + public async Task OnMessageAsync_WithMatchingHandler_HasResult() + { + // Arrange + Mock runtimeMock = new(); + AgentId agentId = new("TestType", "TestKey"); + TestAgentB agent = new(agentId, runtimeMock.Object); + + // Act + TestMessage message = new() { Content = "Hello World" }; + MessageContext context = new(CancellationToken.None); + object? result = await agent.OnMessageAsync(message, context); + + // Assert + result.Should().Be(message.Content); + agent.ReceivedMessages.Should().ContainSingle(); + agent.ReceivedMessages[0].Should().Contain(message.Content); + } + + [Fact] + public async Task CloseAsync_ReturnsCompletedTask() + { + // Arrange + await using InProcessRuntime runtime = new(); + AgentId agentId = new("TestType", "TestKey"); + TestAgentA agent = new(agentId, runtime, "Test Agent"); + + // Act + await agent.CloseAsync(); + + // Assert + agent.IsClosed.Should().BeTrue(); + } + + [Fact] + public async Task PublishMessageAsync_Received() + { + // Arrange + ServiceProvider services = new ServiceCollection().BuildServiceProvider(); + await using InProcessRuntime runtime = new(); + TopicId topic = new("TestTopic"); + AgentType senderType = nameof(TestAgentC); + AgentType receiverType = nameof(TestAgentB); + await runtime.RegisterAgentTypeAsync(receiverType, services); + await runtime.AddSubscriptionAsync(new TypeSubscription(topic.Type, receiverType)); + AgentId receiverId = await runtime.GetAgentAsync(receiverType, lazy: false); + await runtime.RegisterAgentTypeAsync(senderType, services, [topic]); + AgentId senderId = await runtime.GetAgentAsync(senderType, lazy: false); + + // Act + await runtime.StartAsync(); + TestMessage message = new() { Content = "Hello World" }; + try + { + await runtime.SendMessageAsync(message, senderId); + } + finally + { + await runtime.RunUntilIdleAsync(); + } + + // Assert + await VerifyMessageHandled(runtime, senderId, message.Content); + await VerifyMessageHandled(runtime, receiverId, message.Content); + } + + [Fact] + public async Task SendMessageAsync_Received() + { + // Arrange + ServiceProvider services = new ServiceCollection().BuildServiceProvider(); + await using InProcessRuntime runtime = new(); + AgentType senderType = nameof(TestAgentD); + AgentType receiverType = nameof(TestAgentB); + await runtime.RegisterAgentTypeAsync(receiverType, services); + AgentId receiverId = await runtime.GetAgentAsync(receiverType, lazy: false); + await runtime.RegisterAgentTypeAsync(senderType, services, [receiverId]); + AgentId senderId = await runtime.GetAgentAsync(senderType, lazy: false); + + // Act + await runtime.StartAsync(); + TestMessage message = new() { Content = "Hello World" }; + try + { + await runtime.SendMessageAsync(message, senderId); + } + finally + { + await runtime.RunUntilIdleAsync(); + } + + // Assert + await VerifyMessageHandled(runtime, senderId, message.Content); + await VerifyMessageHandled(runtime, receiverId, message.Content); + } + + private static async Task VerifyMessageHandled(InProcessRuntime runtime, AgentId agentId, string expectedContent) + { + TestAgent agent = await runtime.TryGetUnderlyingAgentInstanceAsync(agentId); + agent.ReceivedMessages.Should().ContainSingle(); + agent.ReceivedMessages[0].Should().Be(expectedContent); + } + + [Fact] + public async Task SaveStateAsync_ReturnsEmptyJsonElement() + { + // Arrange + await using InProcessRuntime runtime = new(); + AgentId agentId = new("TestType", "TestKey"); + TestAgentA agent = new(agentId, runtime, "Test Agent"); + + // Act + var state = await agent.SaveStateAsync(); + + // Assert + state.ValueKind.Should().Be(JsonValueKind.Object); + state.EnumerateObject().Count().Should().Be(0); + } + + [Fact] + public async Task LoadStateAsync_WithValidState_HandlesStateCorrectly() + { + // Arrange + await using InProcessRuntime runtime = new(); + AgentId agentId = new("TestType", "TestKey"); + TestAgentA agent = new(agentId, runtime, "Test Agent"); + + JsonElement state = JsonDocument.Parse("{ }").RootElement; + + // Act + await agent.LoadStateAsync(state); + + // Assert + // BaseAgent's default implementation just accepts any state without error + // This is primarily testing that the default method doesn't throw exceptions + } + + [Fact] + public async Task GetAgentAsync_WithValidType_ReturnsAgentId() + { + // Arrange + ServiceProvider services = new ServiceCollection().BuildServiceProvider(); + await using InProcessRuntime runtime = new(); + AgentType agentType = nameof(TestAgentB); + await runtime.RegisterAgentTypeAsync(agentType, services); + + AgentId callingAgentId = new("CallerType", "CallerKey"); + TestAgentB callingAgent = new(callingAgentId, runtime); + + // Act + await runtime.StartAsync(); + AgentId? retrievedAgentId = await callingAgent.GetAgentAsync(agentType); + + // Assert + retrievedAgentId.Should().NotBeNull(); + retrievedAgentId!.Value.Type.Should().Be(agentType.Name); + retrievedAgentId!.Value.Key.Should().Be(AgentId.DefaultKey); + + // Act + retrievedAgentId = await callingAgent.GetAgentAsync("badtype"); + + // Assert + retrievedAgentId.Should().BeNull(); + } + + // Custom test message + private sealed class TestMessage + { + public string Content { get; set; } = string.Empty; + } + + // TestAgent that collects the messages it receives + protected abstract class TestAgent : BaseAgent + { + public List ReceivedMessages { get; } = []; + + protected TestAgent(AgentId id, IAgentRuntime runtime, string description, ILogger? logger = null) + : base(id, runtime, description, logger) + { + } + } + + private sealed class TestAgentA : TestAgent, IHandle + { + public bool IsClosed { get; private set; } + + public TestAgentA(AgentId id, IAgentRuntime runtime, string description, ILogger? logger = null) + : base(id, runtime, description, logger) + { + } + + public ValueTask HandleAsync(TestMessage item, MessageContext messageContext) + { + this.ReceivedMessages.Add(item.Content); + return ValueTask.CompletedTask; + } + + public override ValueTask CloseAsync() + { + this.IsClosed = true; + return base.CloseAsync(); + } + } + + // TestAgent that implements handler for TestMessage that produces a result + private sealed class TestAgentB : TestAgent, IHandle + { + public TestAgentB(AgentId id, IAgentRuntime runtime) + : base(id, runtime, "Test agent with handler result") + { + } + + public ValueTask HandleAsync(TestMessage item, MessageContext messageContext) + { + this.ReceivedMessages.Add(item.Content); + return ValueTask.FromResult(item.Content); + } + + public new ValueTask GetAgentAsync(AgentType agent, CancellationToken cancellationToken = default) => base.GetAgentAsync(agent, cancellationToken); + } + + // TestAgent that implements handler for TestMessage that responds by publishing to a topic + private sealed class TestAgentC : TestAgent, IHandle + { + private readonly TopicId _broadcastTopic; + + public TestAgentC(AgentId id, IAgentRuntime runtime, TopicId broadcastTopic) + : base(id, runtime, "Test agent that publishes") + { + this._broadcastTopic = broadcastTopic; + } + + public async ValueTask HandleAsync(TestMessage item, MessageContext messageContext) + { + this.ReceivedMessages.Add(item.Content); + await this.PublishMessageAsync(item, this._broadcastTopic, messageContext.MessageId, messageContext.CancellationToken); + } + } + + // TestAgent that implements handler for TestMessage that responds by messaging another agent + private sealed class TestAgentD : TestAgent, IHandle + { + private readonly AgentId _receiverId; + + public TestAgentD(AgentId id, IAgentRuntime runtime, AgentId receiverId) + : base(id, runtime, "Test agent that sends") + { + this._receiverId = receiverId; + } + + public async ValueTask HandleAsync(TestMessage item, MessageContext messageContext) + { + this.ReceivedMessages.Add(item.Content); + await this.SendMessageAsync(item, this._receiverId, messageContext.MessageId, messageContext.CancellationToken); + } + } +} diff --git a/dotnet/src/Agents/Runtime/Core.Tests/Runtime.Core.Tests.csproj b/dotnet/src/Agents/Runtime/Core.Tests/Runtime.Core.Tests.csproj new file mode 100644 index 000000000000..faa2db16a161 --- /dev/null +++ b/dotnet/src/Agents/Runtime/Core.Tests/Runtime.Core.Tests.csproj @@ -0,0 +1,31 @@ + + + + Microsoft.SemanticKernel.Agents.Runtime.Core.Tests + Microsoft.SemanticKernel.Agents.Runtime.Core.Tests + net8.0 + True + $(NoWarn);CA1707;CA2007;CA1812;CA1861;CA1063;CS0618;CS1591;IDE1006;VSTHRD111;SKEXP0001;SKEXP0050;SKEXP0110;OPENAI001 + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + diff --git a/dotnet/src/Agents/Runtime/Core.Tests/TypePrefixSubscriptionAttributeTests.cs b/dotnet/src/Agents/Runtime/Core.Tests/TypePrefixSubscriptionAttributeTests.cs new file mode 100644 index 000000000000..4e19f4483b61 --- /dev/null +++ b/dotnet/src/Agents/Runtime/Core.Tests/TypePrefixSubscriptionAttributeTests.cs @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using Xunit; + +namespace Microsoft.SemanticKernel.Agents.Runtime.Core.Tests; + +[Trait("Category", "Unit")] +public class TypePrefixSubscriptionAttributeTests +{ + [Fact] + public void Constructor_SetsTopicCorrectly() + { + // Arrange & Act + TypePrefixSubscriptionAttribute attribute = new("test-topic"); + + // Assert + Assert.Equal("test-topic", attribute.Topic); + } + + [Fact] + public void Bind_CreatesTypeSubscription() + { + // Arrange + TypePrefixSubscriptionAttribute attribute = new("test"); + AgentType agentType = new("testagent"); + + // Act + ISubscriptionDefinition subscription = attribute.Bind(agentType); + + // Assert + Assert.NotNull(subscription); + TypePrefixSubscription typeSubscription = Assert.IsType(subscription); + Assert.Equal("test", typeSubscription.TopicTypePrefix); + Assert.Equal(agentType, typeSubscription.AgentType); + } + + [Fact] + public void AttributeUsage_AllowsOnlyClasses() + { + // Arrange + Type attributeType = typeof(TypePrefixSubscriptionAttribute); + + // Act + AttributeUsageAttribute usageAttribute = + (AttributeUsageAttribute)Attribute.GetCustomAttribute( + attributeType, + typeof(AttributeUsageAttribute))!; + + // Assert + Assert.NotNull(usageAttribute); + Assert.Equal(AttributeTargets.Class, usageAttribute.ValidOn); + } +} diff --git a/dotnet/src/Agents/Runtime/Core.Tests/TypePrefixSubscriptionTests.cs b/dotnet/src/Agents/Runtime/Core.Tests/TypePrefixSubscriptionTests.cs new file mode 100644 index 000000000000..7f2834f66180 --- /dev/null +++ b/dotnet/src/Agents/Runtime/Core.Tests/TypePrefixSubscriptionTests.cs @@ -0,0 +1,233 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using FluentAssertions; +using Xunit; + +namespace Microsoft.SemanticKernel.Agents.Runtime.Core.Tests; + +[Trait("Category", "Unit")] +public class TypePrefixSubscriptionTests +{ + [Fact] + public void Constructor_WithProvidedId_ShouldSetProperties() + { + // Arrange + string topicTypePrefix = "testPrefix"; + AgentType agentType = new("testAgent"); + string id = "custom-id"; + + // Act + TypePrefixSubscription subscription = new(topicTypePrefix, agentType, id); + + // Assert + subscription.TopicTypePrefix.Should().Be(topicTypePrefix); + subscription.AgentType.Should().Be(agentType); + subscription.Id.Should().Be(id); + } + + [Fact] + public void Constructor_WithoutId_ShouldGenerateGuid() + { + // Arrange + string topicTypePrefix = "testPrefix"; + AgentType agentType = new("testAgent"); + + // Act + TypePrefixSubscription subscription = new(topicTypePrefix, agentType); + + // Assert + subscription.TopicTypePrefix.Should().Be(topicTypePrefix); + subscription.AgentType.Should().Be(agentType); + subscription.Id.Should().NotBeNullOrEmpty(); + Guid.TryParse(subscription.Id, out _).Should().BeTrue(); + } + + [Fact] + public void Matches_TopicWithMatchingPrefix_ShouldReturnTrue() + { + // Arrange + string topicTypePrefix = "testPrefix"; + TypePrefixSubscription subscription = new(topicTypePrefix, new AgentType("testAgent")); + TopicId topic = new(topicTypePrefix, "source1"); + + // Act + bool result = subscription.Matches(topic); + + // Assert + result.Should().BeTrue(); + } + + [Fact] + public void Matches_TopicWithMatchingPrefixAndAdditionalSuffix_ShouldReturnTrue() + { + // Arrange + string topicTypePrefix = "testPrefix"; + TypePrefixSubscription subscription = new(topicTypePrefix, new AgentType("testAgent")); + TopicId topic = new($"{topicTypePrefix}Suffix", "source1"); + + // Act + bool result = subscription.Matches(topic); + + // Assert + result.Should().BeTrue(); + } + + [Fact] + public void Matches_TopicWithDifferentPrefix_ShouldReturnFalse() + { + // Arrange + TypePrefixSubscription subscription = new("testPrefix", new AgentType("testAgent")); + TopicId topic = new("differentPrefix", "source1"); + + // Act + bool result = subscription.Matches(topic); + + // Assert + result.Should().BeFalse(); + } + + [Fact] + public void MapToAgent_MatchingTopic_ShouldReturnCorrectAgentId() + { + // Arrange + string topicTypePrefix = "testPrefix"; + string source = "source1"; + AgentType agentType = new("testAgent"); + TypePrefixSubscription subscription = new(topicTypePrefix, agentType); + TopicId topic = new(topicTypePrefix, source); + + // Act + var agentId = subscription.MapToAgent(topic); + + // Assert + agentId.Type.Should().Be(agentType.Name); + agentId.Key.Should().Be(source); + } + + [Fact] + public void MapToAgent_TopicWithMatchingPrefixAndSuffix_ShouldReturnCorrectAgentId() + { + // Arrange + string topicTypePrefix = "testPrefix"; + string source = "source1"; + AgentType agentType = new("testAgent"); + TypePrefixSubscription subscription = new(topicTypePrefix, agentType); + TopicId topic = new($"{topicTypePrefix}Suffix", source); + + // Act + var agentId = subscription.MapToAgent(topic); + + // Assert + agentId.Type.Should().Be(agentType.Name); + agentId.Key.Should().Be(source); + } + + [Fact] + public void MapToAgent_NonMatchingTopic_ShouldThrowInvalidOperationException() + { + // Arrange + TypePrefixSubscription subscription = new("testPrefix", new AgentType("testAgent")); + TopicId topic = new("differentPrefix", "source1"); + + // Act & Assert + Action action = () => subscription.MapToAgent(topic); + action.Should().Throw() + .WithMessage("TopicId does not match the subscription."); + } + + [Fact] + public void Equals_SameId_ShouldReturnTrue() + { + // Arrange + string id = "custom-id"; + TypePrefixSubscription subscription1 = new("prefix1", new AgentType("agent1"), id); + TypePrefixSubscription subscription2 = new("prefix2", new AgentType("agent2"), id); + + // Act & Assert + subscription1.Equals((object)subscription2).Should().BeTrue(); + subscription1.Equals(subscription2 as ISubscriptionDefinition).Should().BeTrue(); + } + + [Fact] + public void Equals_SameTypeAndAgentType_ShouldReturnTrue() + { + // Arrange + string topicTypePrefix = "prefix1"; + AgentType agentType = new("agent1"); + TypePrefixSubscription subscription1 = new(topicTypePrefix, agentType, "id1"); + TypePrefixSubscription subscription2 = new(topicTypePrefix, agentType, "id2"); + + // Act & Assert + subscription1.Equals((object)subscription2).Should().BeTrue(); + } + + [Fact] + public void Equals_DifferentIdAndProperties_ShouldReturnFalse() + { + // Arrange + TypePrefixSubscription subscription1 = new("prefix1", new AgentType("agent1"), "id1"); + TypePrefixSubscription subscription2 = new("prefix2", new AgentType("agent2"), "id2"); + + // Act & Assert + subscription1.Equals((object)subscription2).Should().BeFalse(); + } + + [Fact] + public void Equals_ISubscriptionDefinition_WithDifferentId_ShouldReturnFalse() + { + // Arrange + TypePrefixSubscription subscription1 = new("prefix1", new AgentType("agent1"), "id1"); + TypePrefixSubscription subscription2 = new("prefix1", new AgentType("agent1"), "id2"); + + // Act & Assert + subscription1.Equals(subscription2 as ISubscriptionDefinition).Should().BeFalse(); + } + + [Fact] + public void Equals_WithNull_ShouldReturnFalse() + { + // Arrange + TypePrefixSubscription subscription = new("prefix1", new AgentType("agent1")); + + // Act & Assert + subscription.Equals(null as object).Should().BeFalse(); + subscription.Equals(null as ISubscriptionDefinition).Should().BeFalse(); + } + + [Fact] + public void Equals_WithDifferentType_ShouldReturnFalse() + { + // Arrange + TypePrefixSubscription subscription = new("prefix1", new AgentType("agent1")); + object differentObject = new(); + + // Act & Assert + subscription.Equals(differentObject).Should().BeFalse(); + } + + [Fact] + public void GetHashCode_SameValues_ShouldReturnSameHashCode() + { + // Arrange + string id = "custom-id"; + string topicTypePrefix = "prefix1"; + AgentType agentType = new("agent1"); + TypePrefixSubscription subscription1 = new(topicTypePrefix, agentType, id); + TypePrefixSubscription subscription2 = new(topicTypePrefix, agentType, id); + + // Act & Assert + subscription1.GetHashCode().Should().Be(subscription2.GetHashCode()); + } + + [Fact] + public void GetHashCode_DifferentValues_ShouldReturnDifferentHashCodes() + { + // Arrange + TypePrefixSubscription subscription1 = new("prefix1", new AgentType("agent1"), "id1"); + TypePrefixSubscription subscription2 = new("prefix2", new AgentType("agent2"), "id2"); + + // Act & Assert + subscription1.GetHashCode().Should().NotBe(subscription2.GetHashCode()); + } +} diff --git a/dotnet/src/Agents/Runtime/Core.Tests/TypeSubscriptionAttributeTests.cs b/dotnet/src/Agents/Runtime/Core.Tests/TypeSubscriptionAttributeTests.cs new file mode 100644 index 000000000000..90f38f703255 --- /dev/null +++ b/dotnet/src/Agents/Runtime/Core.Tests/TypeSubscriptionAttributeTests.cs @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using Xunit; + +namespace Microsoft.SemanticKernel.Agents.Runtime.Core.Tests; + +[Trait("Category", "Unit")] +public class TypeSubscriptionAttributeTests +{ + [Fact] + public void Constructor_SetsTopicCorrectly() + { + // Arrange & Act + TypeSubscriptionAttribute attribute = new("test-topic"); + + // Assert + Assert.Equal("test-topic", attribute.Topic); + } + + [Fact] + public void Bind_CreatesTypeSubscription() + { + // Arrange + TypeSubscriptionAttribute attribute = new("test-topic"); + AgentType agentType = new("testagent"); + + // Act + ISubscriptionDefinition subscription = attribute.Bind(agentType); + + // Assert + Assert.NotNull(subscription); + TypeSubscription typeSubscription = Assert.IsType(subscription); + Assert.Equal("test-topic", typeSubscription.TopicType); + Assert.Equal(agentType, typeSubscription.AgentType); + } + + [Fact] + public void AttributeUsage_AllowsOnlyClasses() + { + // Arrange + Type attributeType = typeof(TypeSubscriptionAttribute); + + // Act + AttributeUsageAttribute usageAttribute = + (AttributeUsageAttribute)Attribute.GetCustomAttribute( + attributeType, + typeof(AttributeUsageAttribute))!; + + // Assert + Assert.NotNull(usageAttribute); + Assert.Equal(AttributeTargets.Class, usageAttribute.ValidOn); + } +} diff --git a/dotnet/src/Agents/Runtime/Core.Tests/TypeSubscriptionTests.cs b/dotnet/src/Agents/Runtime/Core.Tests/TypeSubscriptionTests.cs new file mode 100644 index 000000000000..e7ea01647a62 --- /dev/null +++ b/dotnet/src/Agents/Runtime/Core.Tests/TypeSubscriptionTests.cs @@ -0,0 +1,190 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using FluentAssertions; +using Xunit; + +namespace Microsoft.SemanticKernel.Agents.Runtime.Core.Tests; + +[Trait("Category", "Unit")] +public class TypeSubscriptionTests +{ + [Fact] + public void Constructor_WithProvidedId_ShouldSetProperties() + { + // Arrange + string topicType = "testTopic"; + AgentType agentType = new("testAgent"); + string id = "custom-id"; + + // Act + TypeSubscription subscription = new(topicType, agentType, id); + + // Assert + subscription.TopicType.Should().Be(topicType); + subscription.AgentType.Should().Be(agentType); + subscription.Id.Should().Be(id); + } + + [Fact] + public void Constructor_WithoutId_ShouldGenerateGuid() + { + // Arrange + string topicType = "testTopic"; + AgentType agentType = new("testAgent"); + + // Act + TypeSubscription subscription = new(topicType, agentType); + + // Assert + subscription.TopicType.Should().Be(topicType); + subscription.AgentType.Should().Be(agentType); + subscription.Id.Should().NotBeNullOrEmpty(); + Guid.TryParse(subscription.Id, out _).Should().BeTrue(); + } + + [Fact] + public void Matches_TopicWithMatchingType_ShouldReturnTrue() + { + // Arrange + string topicType = "testTopic"; + TypeSubscription subscription = new(topicType, new AgentType("testAgent")); + TopicId topic = new(topicType, "source1"); + + // Act + bool result = subscription.Matches(topic); + + // Assert + result.Should().BeTrue(); + } + + [Fact] + public void Matches_TopicWithDifferentType_ShouldReturnFalse() + { + // Arrange + TypeSubscription subscription = new("testTopic", new AgentType("testAgent")); + TopicId topic = new("differentTopic", "source1"); + + // Act + bool result = subscription.Matches(topic); + + // Assert + result.Should().BeFalse(); + } + + [Fact] + public void MapToAgent_MatchingTopic_ShouldReturnCorrectAgentId() + { + // Arrange + string topicType = "testTopic"; + string source = "source1"; + AgentType agentType = new("testAgent"); + TypeSubscription subscription = new(topicType, agentType); + TopicId topic = new(topicType, source); + + // Act + var agentId = subscription.MapToAgent(topic); + + // Assert + agentId.Type.Should().Be(agentType.Name); + agentId.Key.Should().Be(source); + } + + [Fact] + public void MapToAgent_NonMatchingTopic_ShouldThrowInvalidOperationException() + { + // Arrange + TypeSubscription subscription = new("testTopic", new AgentType("testAgent")); + TopicId topic = new("differentTopic", "source1"); + + // Act & Assert + Action action = () => subscription.MapToAgent(topic); + action.Should().Throw() + .WithMessage("TopicId does not match the subscription."); + } + + [Fact] + public void Equals_SameId_ShouldReturnTrue() + { + // Arrange + string id = "custom-id"; + TypeSubscription subscription1 = new("topic1", new AgentType("agent1"), id); + TypeSubscription subscription2 = new("topic2", new AgentType("agent2"), id); + + // Act & Assert + subscription1.Equals((object)subscription2).Should().BeTrue(); + subscription1.Equals(subscription2 as ISubscriptionDefinition).Should().BeTrue(); + } + + [Fact] + public void Equals_SameTypeAndAgentType_ShouldReturnTrue() + { + // Arrange + string topicType = "topic1"; + AgentType agentType = new("agent1"); + TypeSubscription subscription1 = new(topicType, agentType, "id1"); + TypeSubscription subscription2 = new(topicType, agentType, "id2"); + + // Act & Assert + subscription1.Equals((object)subscription2).Should().BeTrue(); + } + + [Fact] + public void Equals_DifferentIdAndProperties_ShouldReturnFalse() + { + // Arrange + TypeSubscription subscription1 = new("topic1", new AgentType("agent1"), "id1"); + TypeSubscription subscription2 = new("topic2", new AgentType("agent2"), "id2"); + + // Act & Assert + subscription1.Equals((object)subscription2).Should().BeFalse(); + subscription1.Equals(subscription2 as ISubscriptionDefinition).Should().BeFalse(); + } + + [Fact] + public void Equals_WithNull_ShouldReturnFalse() + { + // Arrange + TypeSubscription subscription = new("topic1", new AgentType("agent1")); + + // Act & Assert + subscription.Equals(null as object).Should().BeFalse(); + subscription.Equals(null as ISubscriptionDefinition).Should().BeFalse(); + } + + [Fact] + public void Equals_WithDifferentType_ShouldReturnFalse() + { + // Arrange + TypeSubscription subscription = new("topic1", new AgentType("agent1")); + object differentObject = new(); + + // Act & Assert + subscription.Equals(differentObject).Should().BeFalse(); + } + + [Fact] + public void GetHashCode_SameValues_ShouldReturnSameHashCode() + { + // Arrange + string id = "custom-id"; + string topicType = "topic1"; + AgentType agentType = new("agent1"); + TypeSubscription subscription1 = new(topicType, agentType, id); + TypeSubscription subscription2 = new(topicType, agentType, id); + + // Act & Assert + subscription1.GetHashCode().Should().Be(subscription2.GetHashCode()); + } + + [Fact] + public void GetHashCode_DifferentValues_ShouldReturnDifferentHashCodes() + { + // Arrange + TypeSubscription subscription1 = new("topic1", new AgentType("agent1"), "id1"); + TypeSubscription subscription2 = new("topic2", new AgentType("agent2"), "id2"); + + // Act & Assert + subscription1.GetHashCode().Should().NotBe(subscription2.GetHashCode()); + } +} diff --git a/dotnet/src/Agents/Runtime/Core/AgentRuntimeExtensions.cs b/dotnet/src/Agents/Runtime/Core/AgentRuntimeExtensions.cs new file mode 100644 index 000000000000..7d83d6fc8723 --- /dev/null +++ b/dotnet/src/Agents/Runtime/Core/AgentRuntimeExtensions.cs @@ -0,0 +1,135 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.SemanticKernel.Agents.Runtime.Core; + +/// +/// Provides extension methods for managing and registering agents within an . +/// +public static class AgentRuntimeExtensions +{ + internal const string DirectMessageTopicSuffix = ":"; + + /// + /// Registers an agent type with the runtime, providing a factory function to create instances of the agent. + /// + /// The type of agent being registered. Must implement . + /// The where the agent will be registered. + /// The representing the type of agent. + /// The service provider used for dependency injection. + /// Additional arguments to pass to the agent's constructor. + /// A representing the asynchronous operation of registering the agent. + public static ValueTask RegisterAgentTypeAsync(this IAgentRuntime runtime, AgentType type, IServiceProvider serviceProvider, params object[] additionalArguments) + where TAgent : BaseAgent + => RegisterAgentTypeAsync(runtime, type, typeof(TAgent), serviceProvider, additionalArguments); + + /// + /// Registers an agent type with the runtime using the specified runtime type and additional constructor arguments. + /// + /// The agent runtime instance to register the agent with. + /// The agent type to register. + /// The .NET type of the agent to activate. + /// The service provider for dependency injection. + /// Additional arguments to pass to the agent's constructor. + /// A representing the asynchronous registration operation containing the registered agent type. + public static ValueTask RegisterAgentTypeAsync(this IAgentRuntime runtime, AgentType type, Type runtimeType, IServiceProvider serviceProvider, params object[] additionalArguments) + { + ValueTask factory(AgentId id, IAgentRuntime runtime) => ActivateAgentAsync(serviceProvider, runtimeType, [id, runtime, .. additionalArguments]); + + return runtime.RegisterAgentFactoryAsync(type, factory); + } + + /// + /// Registers implicit subscriptions for an agent type based on the type's custom attributes. + /// + /// The type of the agent. + /// The agent runtime instance. + /// The agent type to register subscriptions for. + /// If true, class-level subscriptions are skipped. + /// If true, the direct message subscription is skipped. + /// A representing the asynchronous subscription registration operation. + public static ValueTask RegisterImplicitAgentSubscriptionsAsync(this IAgentRuntime runtime, AgentType type, bool skipClassSubscriptions = false, bool skipDirectMessageSubscription = false) + where TAgent : BaseAgent + => RegisterImplicitAgentSubscriptionsAsync(runtime, type, typeof(TAgent), skipClassSubscriptions, skipDirectMessageSubscription); + + /// + /// Registers implicit subscriptions for the specified agent type using runtime type information. + /// + /// The agent runtime instance. + /// The agent type for which to register subscriptions. + /// The .NET type of the agent. + /// If true, class-level subscriptions are not registered. + /// If true, the direct message subscription is not registered. + /// A representing the asynchronous subscription registration operation. + public static async ValueTask RegisterImplicitAgentSubscriptionsAsync(this IAgentRuntime runtime, AgentType type, Type runtimeType, bool skipClassSubscriptions = false, bool skipDirectMessageSubscription = false) + { + ISubscriptionDefinition[] subscriptions = BindSubscriptionsForAgentType(type, runtimeType, skipClassSubscriptions, skipDirectMessageSubscription); + foreach (ISubscriptionDefinition subscription in subscriptions) + { + await runtime.AddSubscriptionAsync(subscription).ConfigureAwait(false); + } + } + + /// + /// Binds subscription definitions for the given agent type based on the custom attributes applied to the runtime type. + /// + /// The agent type to bind subscriptions for. + /// The .NET type of the agent. + /// If true, class-level subscriptions are skipped. + /// If true, the direct message subscription is skipped. + /// An array of subscription definitions for the agent type. + private static ISubscriptionDefinition[] BindSubscriptionsForAgentType(AgentType agentType, Type runtimeType, bool skipClassSubscriptions = false, bool skipDirectMessageSubscription = false) + { + List subscriptions = []; + + if (!skipClassSubscriptions) + { + subscriptions.AddRange(runtimeType.GetCustomAttributes().Select(t => t.Bind(agentType))); + + subscriptions.AddRange(runtimeType.GetCustomAttributes().Select(t => t.Bind(agentType))); + } + + if (!skipDirectMessageSubscription) + { + // Direct message subscription using agent name as prefix. + subscriptions.Add(new TypePrefixSubscription(agentType.Name + DirectMessageTopicSuffix, agentType)); + } + + return [.. subscriptions]; + } + + /// + /// Instantiates and activates an agent asynchronously using dependency injection. + /// + /// The service provider used for dependency injection. + /// The .NET type of the agent being activated. + /// Additional arguments to pass to the agent's constructor. + /// A representing the asynchronous activation of the agent. + private static ValueTask ActivateAgentAsync(IServiceProvider serviceProvider, Type runtimeType, params object[] additionalArguments) + { + try + { + IHostableAgent agent = (BaseAgent)ActivatorUtilities.CreateInstance(serviceProvider, runtimeType, additionalArguments); + +#if !NETCOREAPP + return agent.AsValueTask(); +#else + return ValueTask.FromResult(agent); +#endif + } + catch (Exception e) when (!e.IsCriticalException()) + { +#if !NETCOREAPP + return e.AsValueTask(); +#else + return ValueTask.FromException(e); +#endif + } + } +} diff --git a/dotnet/src/Agents/Runtime/Core/AgentsApp.cs b/dotnet/src/Agents/Runtime/Core/AgentsApp.cs new file mode 100644 index 000000000000..61a1827775af --- /dev/null +++ b/dotnet/src/Agents/Runtime/Core/AgentsApp.cs @@ -0,0 +1,104 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Microsoft.SemanticKernel.Agents.Runtime.Core; + +/// +/// Represents the core application hosting the agent runtime. +/// Manages the application lifecycle including startup, shutdown, and message publishing. +/// +public class AgentsApp +{ + private int _runningCount; + + /// + /// Initializes a new instance of the class. + /// + /// The underlying application host. + internal AgentsApp(IHost host) + { + this.Host = host; + } + + /// + /// Gets the underlying host responsible for managing application lifetime. + /// + public IHost Host { get; } + + /// + /// Gets the service provider for dependency resolution. + /// + public IServiceProvider Services => this.Host.Services; + + /// + /// Gets the application lifetime object to manage startup and shutdown events. + /// + public IHostApplicationLifetime ApplicationLifetime => this.Services.GetRequiredService(); + + /// + /// Gets the agent runtime responsible for handling agent messaging and operations. + /// + public IAgentRuntime AgentRuntime => this.Services.GetRequiredService(); + + /// + /// Starts the application by initiating the host. + /// Throws an exception if the application is already running. + /// + public async ValueTask StartAsync() + { + if (Interlocked.Exchange(ref this._runningCount, 1) != 0) + { + throw new InvalidOperationException("Application is already running."); + } + + await this.Host.StartAsync().ConfigureAwait(false); + } + + /// + /// Shuts down the application by stopping the host. + /// Throws an exception if the application is not running. + /// + public async ValueTask ShutdownAsync() + { + if (Interlocked.Exchange(ref this._runningCount, 0) != 1) + { + throw new InvalidOperationException("Application is already stopped."); + } + + await this.Host.StopAsync().ConfigureAwait(false); + } + + /// + /// Publishes a message to the specified topic. + /// If the application is not running, it starts the host first. + /// + /// The type of the message being published. + /// The message to publish. + /// The topic to which the message will be published. + /// An optional unique identifier for the message. + /// A token to cancel the operation if needed. + public async ValueTask PublishMessageAsync(TMessage message, TopicId topic, string? messageId = null, CancellationToken cancellationToken = default) + where TMessage : notnull + { + if (Volatile.Read(ref this._runningCount) == 0) + { + await this.StartAsync().ConfigureAwait(false); + } + + await this.AgentRuntime.PublishMessageAsync(message, topic, messageId: messageId, cancellationToken: cancellationToken).ConfigureAwait(false); + } + + /// + /// Waits for the host to complete its shutdown process. + /// + /// A token to cancel the operation if needed. + public Task WaitForShutdownAsync(CancellationToken cancellationToken = default) + { + return this.Host.WaitForShutdownAsync(cancellationToken); + } +} diff --git a/dotnet/src/Agents/Runtime/Core/AgentsAppBuilder.cs b/dotnet/src/Agents/Runtime/Core/AgentsAppBuilder.cs new file mode 100644 index 000000000000..a83dcea5f2cf --- /dev/null +++ b/dotnet/src/Agents/Runtime/Core/AgentsAppBuilder.cs @@ -0,0 +1,138 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Microsoft.SemanticKernel.Agents.Runtime.Core; + +/// +/// Provides a fluent API to configure and build an instance. +/// +public class AgentsAppBuilder +{ + private readonly HostApplicationBuilder _builder; + private readonly List>> _agentTypeRegistrations; + + /// + /// Initializes a new instance of the class using the specified . + /// + /// An optional host application builder to use; if null, a new instance is created. + public AgentsAppBuilder(HostApplicationBuilder? baseBuilder = null) + { + this._builder = baseBuilder ?? new HostApplicationBuilder(); + this._agentTypeRegistrations = []; + } + + /// + /// Gets the dependency injection service collection. + /// + public IServiceCollection Services => this._builder.Services; + + /// + /// Gets the application's configuration. + /// + public IConfiguration Configuration => this._builder.Configuration; + + /// + /// Scans all assemblies loaded in the current application domain to register available agents. + /// + public void AddAgentsFromAssemblies() + { + this.AddAgentsFromAssemblies(AppDomain.CurrentDomain.GetAssemblies()); + } + + /// + /// Configures the AgentsApp to use the specified agent runtime. + /// + /// The type of the runtime. + /// The runtime instance to use. + /// The modified instance of . + public AgentsAppBuilder UseRuntime(TRuntime runtime) where TRuntime : class, IAgentRuntime + { + this.Services.AddSingleton(_ => runtime); + this.Services.AddHostedService(services => runtime); + + return this; + } + + /// + /// Registers agents from the provided assemblies. + /// + /// An array of assemblies to scan for agents. + /// The modified instance of . + public AgentsAppBuilder AddAgentsFromAssemblies(params Assembly[] assemblies) + { + IEnumerable agentTypes = + assemblies.SelectMany(assembly => assembly.GetTypes()) + .Where( + type => + typeof(BaseAgent).IsAssignableFrom(type) && + !type.IsAbstract); + + foreach (Type agentType in agentTypes) + { + // TODO: Expose skipClassSubscriptions and skipDirectMessageSubscription as parameters? + this.AddAgent(agentType.Name, agentType); + } + + return this; + } + + /// + /// Registers an agent of type with the associated agent type and subscription options. + /// + /// The .NET type of the agent. + /// The agent type identifier. + /// Option to skip class subscriptions. + /// Option to skip direct message subscriptions. + /// The modified instance of . + public AgentsAppBuilder AddAgent(AgentType agentType, bool skipClassSubscriptions = false, bool skipDirectMessageSubscription = false) where TAgent : IHostableAgent + => this.AddAgent(agentType, typeof(TAgent), skipClassSubscriptions, skipDirectMessageSubscription); + + /// + /// Builds the AgentsApp instance by constructing the host and registering all agent types. + /// + /// A task representing the asynchronous operation, returning the built . + public async ValueTask BuildAsync() + { + IHost host = this._builder.Build(); + + AgentsApp app = new(host); + + foreach (Func> registration in this._agentTypeRegistrations) + { + await registration(app).ConfigureAwait(false); + } + + return app; + } + + /// + /// Registers an agent with the runtime using the specified agent type and runtime type. + /// + /// The agent type identifier. + /// The .NET type representing the agent. + /// Option to skip class subscriptions. + /// Option to skip direct message subscriptions. + /// The modified instance of . + private AgentsAppBuilder AddAgent(AgentType agentType, Type runtimeType, bool skipClassSubscriptions = false, bool skipDirectMessageSubscription = false) + { + this._agentTypeRegistrations.Add( + async app => + { + await app.AgentRuntime.RegisterAgentTypeAsync(agentType, runtimeType, app.Services).ConfigureAwait(false); + + await app.AgentRuntime.RegisterImplicitAgentSubscriptionsAsync(agentType, runtimeType, skipClassSubscriptions, skipDirectMessageSubscription).ConfigureAwait(false); + + return agentType; + }); + + return this; + } +} diff --git a/dotnet/src/Agents/Runtime/Core/BaseAgent.cs b/dotnet/src/Agents/Runtime/Core/BaseAgent.cs new file mode 100644 index 000000000000..b6acd0448472 --- /dev/null +++ b/dotnet/src/Agents/Runtime/Core/BaseAgent.cs @@ -0,0 +1,164 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.SemanticKernel.Agents.Runtime.Core.Internal; + +namespace Microsoft.SemanticKernel.Agents.Runtime.Core; + +/// +/// Represents the base class for an agent in the AutoGen system. +/// +public abstract class BaseAgent : IHostableAgent, ISaveState +{ + /// + /// The activity source for tracing. + /// + public static readonly ActivitySource TraceSource = new($"{typeof(IAgent).Namespace}"); + + private readonly Dictionary _handlerInvokers; + private readonly IAgentRuntime _runtime; + + /// + /// Provides logging capabilities used for diagnostic and operational information. + /// + protected internal ILogger Logger { get; } + + /// + /// Gets the description of the agent. + /// + protected string Description { get; } + + /// + /// Gets the unique identifier of the agent. + /// + public AgentId Id { get; } + + /// + /// Gets the metadata of the agent. + /// + public AgentMetadata Metadata { get; } + + /// + /// Initializes a new instance of the BaseAgent class with the specified identifier, runtime, description, and optional logger. + /// + /// The unique identifier of the agent. + /// The runtime environment in which the agent operates. + /// A brief description of the agent's purpose. + /// An optional logger for recording diagnostic information. + protected BaseAgent( + AgentId id, + IAgentRuntime runtime, + string description, + ILogger? logger = null) + { + this.Logger = logger ?? NullLogger.Instance; + + this.Id = id; + this.Description = description; + this.Metadata = new AgentMetadata(this.Id.Type, this.Id.Key, this.Description); + + this._runtime = runtime; + this._handlerInvokers = HandlerInvoker.ReflectAgentHandlers(this); + } + + /// + /// Handles an incoming message by determining its type and invoking the corresponding handler method if available. + /// + /// The message object to be handled. + /// The context associated with the message. + /// A ValueTask that represents the asynchronous operation, containing the response object or null. + public async ValueTask OnMessageAsync(object message, MessageContext messageContext) + { + // Determine type of message, then get handler method and invoke it + Type messageType = message.GetType(); + if (this._handlerInvokers.TryGetValue(messageType, out HandlerInvoker? handlerInvoker)) + { + return await handlerInvoker.InvokeAsync(message, messageContext).ConfigureAwait(false); + } + + return null; + } + + /// + public virtual ValueTask SaveStateAsync() + { +#if !NETCOREAPP + return JsonDocument.Parse("{}").RootElement.AsValueTask(); +#else + return ValueTask.FromResult(JsonDocument.Parse("{}").RootElement); +#endif + } + + /// + public virtual ValueTask LoadStateAsync(JsonElement state) + { +#if !NETCOREAPP + return Task.CompletedTask.AsValueTask(); +#else + return ValueTask.CompletedTask; +#endif + } + + /// + /// Closes this agent gracefully by releasing allocated resources and performing any necessary cleanup. + /// + public virtual ValueTask CloseAsync() + { +#if !NETCOREAPP + return Task.CompletedTask.AsValueTask(); +#else + return ValueTask.CompletedTask; +#endif + } + + /// + /// Sends a message to a specified recipient agent through the runtime. + /// + /// The requested agent's type. + /// A token used to cancel the operation if needed. + /// A ValueTask that represents the asynchronous operation, returning the response object or null. + protected async ValueTask GetAgentAsync(AgentType agent, CancellationToken cancellationToken = default) + { + try + { + return await this._runtime.GetAgentAsync(agent, lazy: false).ConfigureAwait(false); + } + catch (InvalidOperationException) + { + return null; + } + } + + /// + /// Sends a message to a specified recipient agent through the runtime. + /// + /// The message object to send. + /// The recipient agent's identifier. + /// An optional identifier for the message. + /// A token used to cancel the operation if needed. + /// A ValueTask that represents the asynchronous operation, returning the response object or null. + protected ValueTask SendMessageAsync(object message, AgentId recipient, string? messageId = null, CancellationToken cancellationToken = default) + { + return this._runtime.SendMessageAsync(message, recipient, sender: this.Id, messageId, cancellationToken); + } + + /// + /// Publishes a message to all agents subscribed to a specific topic through the runtime. + /// + /// The message object to publish. + /// The topic identifier to which the message is published. + /// An optional identifier for the message. + /// A token used to cancel the operation if needed. + /// A ValueTask that represents the asynchronous publish operation. + protected ValueTask PublishMessageAsync(object message, TopicId topic, string? messageId = null, CancellationToken cancellationToken = default) + { + return this._runtime.PublishMessageAsync(message, topic, sender: this.Id, messageId, cancellationToken); + } +} diff --git a/dotnet/src/Agents/Runtime/Core/IHandle.cs b/dotnet/src/Agents/Runtime/Core/IHandle.cs new file mode 100644 index 000000000000..63291c484d69 --- /dev/null +++ b/dotnet/src/Agents/Runtime/Core/IHandle.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; + +namespace Microsoft.SemanticKernel.Agents.Runtime.Core; + +/// +/// Defines a handler interface for processing items of type . +/// +/// The type of item to be handled. +public interface IHandle +{ + /// + /// Handles the specified item asynchronously. + /// + /// The item to be handled. + /// The context of the message being handled. + /// A task that represents the asynchronous operation. + ValueTask HandleAsync(T item, MessageContext messageContext); +} + +/// +/// Defines a handler interface for processing items of type and . +/// +/// The input type +/// The output type +public interface IHandle +{ + /// + /// Handles the specified item asynchronously. + /// + /// The item to be handled. + /// The context of the message being handled. + /// A task that represents the asynchronous operation. + ValueTask HandleAsync(TIn item, MessageContext messageContext); +} diff --git a/dotnet/src/Agents/Runtime/Core/Internal/HandlerInvoker.cs b/dotnet/src/Agents/Runtime/Core/Internal/HandlerInvoker.cs new file mode 100644 index 000000000000..8f2a63d7147c --- /dev/null +++ b/dotnet/src/Agents/Runtime/Core/Internal/HandlerInvoker.cs @@ -0,0 +1,138 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; + +namespace Microsoft.SemanticKernel.Agents.Runtime.Core.Internal; + +/// +/// Invokes handler methods asynchronously using reflection. +/// The target methods must return either a ValueTask or a ValueTask{T}. +/// This class wraps the reflection call and provides a unified asynchronous invocation interface. +/// +internal sealed class HandlerInvoker +{ + /// + /// Scans the provided agent for implemented handler interfaces (IHandle<> and IHandle<,>) via reflection, + /// creates a corresponding for each handler method, and returns a dictionary that maps + /// the message type (first generic argument of the interface) to its invoker. + /// + /// The agent instance whose handler interfaces will be reflected. + /// A dictionary mapping message types to their corresponding instances. + public static Dictionary ReflectAgentHandlers(BaseAgent agent) + { + Type realType = agent.GetType(); + + IEnumerable candidateInterfaces = + realType.GetInterfaces() + .Where(i => i.IsGenericType && + (i.GetGenericTypeDefinition() == typeof(IHandle<>) || + (i.GetGenericTypeDefinition() == typeof(IHandle<,>)))); + + Dictionary invokers = new(); + foreach (Type interface_ in candidateInterfaces) + { + MethodInfo handleAsync = + interface_.GetMethod(nameof(IHandle.HandleAsync), BindingFlags.Instance | BindingFlags.Public) ?? + throw new InvalidOperationException($"No handler method found for interface {interface_.FullName}"); + + HandlerInvoker invoker = new(handleAsync, agent); + invokers.Add(interface_.GetGenericArguments()[0], invoker); + } + + return invokers; + } + + /// + /// Represents the asynchronous invocation function. + /// + private Func> Invocation { get; } + + /// + /// Initializes a new instance of the class with the specified method information and target object. + /// + /// The MethodInfo representing the handler method to be invoked. + /// The target instance of the agent. + /// Thrown if the target is missing for a non-static method or if the method's return type is not supported. + private HandlerInvoker(MethodInfo methodInfo, BaseAgent target) + { + object? invocation(object? message, MessageContext messageContext) => methodInfo.Invoke(target, [message, messageContext]); + + Func> getResultAsync; + // Check if the method returns a non-generic ValueTask + if (methodInfo.ReturnType.IsAssignableFrom(typeof(ValueTask))) + { + getResultAsync = async (message, messageContext) => + { + // Await the ValueTask and return null as there is no result value. + await ((ValueTask)invocation(message, messageContext)!).ConfigureAwait(false); + return null; + }; + } + // Check if the method returns a generic ValueTask + else if (methodInfo.ReturnType.IsGenericType && methodInfo.ReturnType.GetGenericTypeDefinition() == typeof(ValueTask<>)) + { + // Obtain the generic type argument for ValueTask + MethodInfo typeEraseAwait = typeof(HandlerInvoker) + .GetMethod(nameof(TypeEraseAwaitAsync), BindingFlags.NonPublic | BindingFlags.Static)! + .MakeGenericMethod(methodInfo.ReturnType.GetGenericArguments()[0]); + + getResultAsync = async (message, messageContext) => + { + // Execute the invocation and then type-erase the ValueTask to ValueTask + object valueTask = invocation(message, messageContext)!; + object? typelessValueTask = typeEraseAwait.Invoke(null, [valueTask]); + + Debug.Assert(typelessValueTask is ValueTask, "Expected ValueTask after type erasure."); + + return await ((ValueTask)typelessValueTask).ConfigureAwait(false); + }; + } + else + { + throw new InvalidOperationException($"Method {methodInfo.Name} must return a ValueTask or ValueTask"); + } + + this.Invocation = getResultAsync; + } + + /// + /// Invokes the handler method asynchronously with the provided message and context. + /// + /// The message to be passed as the first argument to the handler. + /// The contextual information associated with the message. + /// A ValueTask representing the asynchronous operation, which yields the handler's result. + public async ValueTask InvokeAsync(object? obj, MessageContext messageContext) + { + try + { + return await this.Invocation.Invoke(obj, messageContext).ConfigureAwait(false); + } + catch (TargetInvocationException ex) + { + // Unwrap the exception to get the original exception thrown by the handler method. + Exception? innerException = ex.InnerException; + if (innerException != null) + { + throw innerException; + } + throw; + } + } + + /// + /// Awaits a generic ValueTask and returns its result as an object. + /// This method is used to convert a ValueTask{T} to ValueTask{object?}. + /// + /// The type of the result contained in the ValueTask. + /// The ValueTask to be awaited. + /// A ValueTask containing the result as an object. + private static async ValueTask TypeEraseAwaitAsync(ValueTask vt) + { + return await vt.ConfigureAwait(false); + } +} diff --git a/dotnet/src/Agents/Runtime/Core/Runtime.Core.csproj b/dotnet/src/Agents/Runtime/Core/Runtime.Core.csproj new file mode 100644 index 000000000000..2b996f882698 --- /dev/null +++ b/dotnet/src/Agents/Runtime/Core/Runtime.Core.csproj @@ -0,0 +1,42 @@ + + + + Microsoft.SemanticKernel.Agents.Runtime.Core + Microsoft.SemanticKernel.Agents.Runtime.Core + net8.0;netstandard2.0 + preview + SKIPSKABSTRACTION + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dotnet/src/Agents/Runtime/Core/TypePrefixSubscription.cs b/dotnet/src/Agents/Runtime/Core/TypePrefixSubscription.cs new file mode 100644 index 000000000000..be210062784f --- /dev/null +++ b/dotnet/src/Agents/Runtime/Core/TypePrefixSubscription.cs @@ -0,0 +1,107 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.SemanticKernel.Agents.Runtime.Core; + +/// +/// This subscription matches on topics based on a prefix of the type and maps to agents using the source of the topic as the agent key. +/// This subscription causes each source to have its own agent instance. +/// +/// +/// Example: +/// +/// var subscription = new TypePrefixSubscription("t1", "a1"); +/// +/// In this case: +/// - A with type `"t1"` and source `"s1"` will be handled by an agent of type `"a1"` with key `"s1"`. +/// - A with type `"t1"` and source `"s2"` will be handled by an agent of type `"a1"` with key `"s2"`. +/// - A with type `"t1SUFFIX"` and source `"s2"` will be handled by an agent of type `"a1"` with key `"s2"`. +/// +public class TypePrefixSubscription : ISubscriptionDefinition +{ + /// + /// Initializes a new instance of the class. + /// + /// Topic type prefix to match against. + /// Agent type to handle this subscription. + /// Unique identifier for the subscription. If not provided, a new UUID will be generated. + public TypePrefixSubscription(string topicTypePrefix, AgentType agentType, string? id = null) + { + this.TopicTypePrefix = topicTypePrefix; + this.AgentType = agentType; + this.Id = id ?? Guid.NewGuid().ToString(); + } + + /// + /// Gets the unique identifier of the subscription. + /// + public string Id { get; } + + /// + /// Gets the topic type prefix used for matching. + /// + public string TopicTypePrefix { get; } + + /// + /// Gets the agent type that handles this subscription. + /// + public AgentType AgentType { get; } + + /// + /// Checks if a given matches the subscription based on its type prefix. + /// + /// The topic to check. + /// true if the topic's type starts with the subscription's prefix, false otherwise. + public bool Matches(TopicId topic) + { + return topic.Type.StartsWith(this.TopicTypePrefix, StringComparison.Ordinal); + } + + /// + /// Maps a to an . Should only be called if returns true. + /// + /// The topic to map. + /// An representing the agent that should handle the topic. + /// Thrown if the topic does not match the subscription. + public AgentId MapToAgent(TopicId topic) + { + if (!this.Matches(topic)) + { + throw new InvalidOperationException("TopicId does not match the subscription."); + } + + return new AgentId(this.AgentType, topic.Source); // No need for .Name, since AgentType implicitly converts to string + } + + /// + /// Determines whether the specified object is equal to the current subscription. + /// + /// The object to compare with the current instance. + /// true if the specified object is equal to this instance; otherwise, false. + public override bool Equals([NotNullWhen(true)] object? obj) + { + return + obj is TypePrefixSubscription other && + (this.Id == other.Id || + (this.AgentType == other.AgentType && + this.TopicTypePrefix == other.TopicTypePrefix)); + } + + /// + /// Determines whether the specified subscription is equal to the current subscription. + /// + /// The subscription to compare. + /// true if the subscriptions are equal; otherwise, false. + public bool Equals(ISubscriptionDefinition? other) => this.Id == other?.Id; + + /// + /// Returns a hash code for this instance. + /// + /// A hash code for this instance, suitable for use in hashing algorithms and data structures. + public override int GetHashCode() + { + return HashCode.Combine(this.Id, this.AgentType, this.TopicTypePrefix); + } +} diff --git a/dotnet/src/Agents/Runtime/Core/TypePrefixSubscriptionAttribute.cs b/dotnet/src/Agents/Runtime/Core/TypePrefixSubscriptionAttribute.cs new file mode 100644 index 000000000000..45240021623a --- /dev/null +++ b/dotnet/src/Agents/Runtime/Core/TypePrefixSubscriptionAttribute.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.SemanticKernel.Agents.Runtime.Core; + +/// +/// Specifies that the attributed class subscribes to topics based on a type prefix. +/// +/// The topic prefix used for matching incoming messages. +[AttributeUsage(AttributeTargets.Class)] +public sealed class TypePrefixSubscriptionAttribute(string topic) : Attribute +{ + /// + /// Gets the topic prefix that this subscription listens for. + /// + public string Topic => topic; + + /// + /// Creates a subscription definition that binds the topic to the specified agent type. + /// + /// The agent type to bind to this topic. + /// An representing the binding. + internal ISubscriptionDefinition Bind(AgentType agentType) + { + return new TypePrefixSubscription(this.Topic, agentType); + } +} diff --git a/dotnet/src/Agents/Runtime/Core/TypeSubscription.cs b/dotnet/src/Agents/Runtime/Core/TypeSubscription.cs new file mode 100644 index 000000000000..aed982fea4f6 --- /dev/null +++ b/dotnet/src/Agents/Runtime/Core/TypeSubscription.cs @@ -0,0 +1,106 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.SemanticKernel.Agents.Runtime.Core; + +/// +/// This subscription matches on topics based on the exact type and maps to agents using the source of the topic as the agent key. +/// This subscription causes each source to have its own agent instance. +/// +/// +/// Example: +/// +/// var subscription = new TypeSubscription("t1", "a1"); +/// +/// In this case: +/// - A with type `"t1"` and source `"s1"` will be handled by an agent of type `"a1"` with key `"s1"`. +/// - A with type `"t1"` and source `"s2"` will be handled by an agent of type `"a1"` with key `"s2"`. +/// +public class TypeSubscription : ISubscriptionDefinition +{ + /// + /// Initializes a new instance of the class. + /// + /// The exact topic type to match against. + /// Agent type to handle this subscription. + /// Unique identifier for the subscription. If not provided, a new UUID will be generated. + public TypeSubscription(string topicType, AgentType agentType, string? id = null) + { + this.TopicType = topicType; + this.AgentType = agentType; + this.Id = id ?? Guid.NewGuid().ToString(); + } + + /// + /// Gets the unique identifier of the subscription. + /// + public string Id { get; } + + /// + /// Gets the exact topic type used for matching. + /// + public string TopicType { get; } + + /// + /// Gets the agent type that handles this subscription. + /// + public AgentType AgentType { get; } + + /// + /// Checks if a given matches the subscription based on an exact type match. + /// + /// The topic to check. + /// true if the topic's type matches exactly, false otherwise. + public bool Matches(TopicId topic) + { + return topic.Type == this.TopicType; + } + + /// + /// Maps a to an . Should only be called if returns true. + /// + /// The topic to map. + /// An representing the agent that should handle the topic. + /// Thrown if the topic does not match the subscription. + public AgentId MapToAgent(TopicId topic) + { + if (!this.Matches(topic)) + { + throw new InvalidOperationException("TopicId does not match the subscription."); + } + + return new AgentId(this.AgentType, topic.Source); + } + + /// + /// Determines whether the specified object is equal to the current subscription. + /// + /// The object to compare with the current instance. + /// true if the specified object is equal to this instance; otherwise, false. + public override bool Equals([NotNullWhen(true)] object? obj) + { + return + obj is TypeSubscription other && + (this.Id == other.Id || + (this.AgentType == other.AgentType && + this.TopicType == other.TopicType)); + } + + /// + /// Determines whether the specified subscription is equal to the current subscription. + /// + /// The subscription to compare. + /// true if the subscriptions are equal; otherwise, false. + public bool Equals(ISubscriptionDefinition? other) => this.Id == other?.Id; + + /// + /// Returns a hash code for this instance. + /// + /// A hash code for this instance, suitable for use in hashing algorithms and data structures. + public override int GetHashCode() + { + return HashCode.Combine(this.Id, this.AgentType, this.TopicType); + } +} diff --git a/dotnet/src/Agents/Runtime/Core/TypeSubscriptionAttribute.cs b/dotnet/src/Agents/Runtime/Core/TypeSubscriptionAttribute.cs new file mode 100644 index 000000000000..17cfaf2caa2c --- /dev/null +++ b/dotnet/src/Agents/Runtime/Core/TypeSubscriptionAttribute.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.SemanticKernel.Agents.Runtime.Core; + +/// +/// Specifies that the attributed class subscribes to a particular topic for agent message handling. +/// +/// The topic identifier that this class subscribes to. +[AttributeUsage(AttributeTargets.Class)] +public sealed class TypeSubscriptionAttribute(string topic) : Attribute +{ + /// + /// Gets the topic identifier associated with this subscription. + /// + public string Topic => topic; + + /// + /// Creates a subscription definition that binds the topic to the specified agent type. + /// + /// The agent type to bind to this topic. + /// An representing the binding. + internal ISubscriptionDefinition Bind(AgentType agentType) + { + return new TypeSubscription(this.Topic, agentType); + } +} diff --git a/dotnet/src/Agents/Runtime/InProcess.Tests/InProcessRuntimeTests.cs b/dotnet/src/Agents/Runtime/InProcess.Tests/InProcessRuntimeTests.cs new file mode 100644 index 000000000000..4f9ca5551f62 --- /dev/null +++ b/dotnet/src/Agents/Runtime/InProcess.Tests/InProcessRuntimeTests.cs @@ -0,0 +1,346 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using FluentAssertions; +using Xunit; + +namespace Microsoft.SemanticKernel.Agents.Runtime.InProcess.Tests; + +[Trait("Category", "Unit")] +public class InProcessRuntimeTests() +{ + [Fact] + [Trait("Category", "Unit")] + public async Task RuntimeStatusLifecycleTest() + { + // Arrange & Act + await using InProcessRuntime runtime = new(); + + // Assert + Assert.False(runtime.DeliverToSelf); + Assert.Equal(0, runtime.messageQueueCount); + + // Act + await runtime.StopAsync(); // Already stopped + await runtime.RunUntilIdleAsync(); // Never throws + + await runtime.StartAsync(); + + // Assert + // Invalid to start runtime that is already started + await Assert.ThrowsAsync(() => runtime.StartAsync()); + Assert.Equal(0, runtime.messageQueueCount); + + // Act + await runtime.StopAsync(); + + // Assert + Assert.Equal(0, runtime.messageQueueCount); + } + + [Fact] + [Trait("Category", "Unit")] + public async Task SubscriptionRegistrationLifecycleTest() + { + // Arrange + await using InProcessRuntime runtime = new(); + TestSubscription subscription = new("TestTopic", "MyAgent"); + + // Act & Assert + await Assert.ThrowsAsync(async () => await runtime.RemoveSubscriptionAsync(subscription.Id)); + + // Arrange + await runtime.AddSubscriptionAsync(subscription); + + // Act & Assert + await Assert.ThrowsAsync(async () => await runtime.AddSubscriptionAsync(subscription)); + + // Act + await runtime.RemoveSubscriptionAsync(subscription.Id); + } + + [Fact] + [Trait("Category", "Unit")] + public async Task AgentRegistrationLifecycleTest() + { + // Arrange + const string agentType = "MyAgent"; + const string agentDescription = "A test agent"; + List agents = []; + await using InProcessRuntime runtime = new(); + + // Act & Assert + await Assert.ThrowsAsync(async () => await runtime.GetAgentAsync(agentType, lazy: false)); + + // Arrange + await runtime.RegisterAgentFactoryAsync(agentType, factoryFunc); + + // Act & Assert + await Assert.ThrowsAsync(async () => await runtime.RegisterAgentFactoryAsync(agentType, factoryFunc)); + + // Act: Lookup by type + AgentId agentId = await runtime.GetAgentAsync(agentType, lazy: false); + + // Assert + Assert.Single(agents); + Assert.Single(runtime.agentInstances); + + // Act + MockAgent agent = await runtime.TryGetUnderlyingAgentInstanceAsync(agentId); + + // Assert + Assert.Equal(agentId, agent.Id); + + // Act & Assert + await Assert.ThrowsAsync(async () => await runtime.TryGetUnderlyingAgentInstanceAsync(agentId)); + + // Act: Lookup by ID + AgentId sameId = await runtime.GetAgentAsync(agentId, lazy: false); + + // Assert + Assert.Equal(agentId, sameId); + + // Act: Lookup by Type + sameId = await runtime.GetAgentAsync((AgentType)agent.Id.Type, lazy: false); + + // Assert + Assert.Equal(agentId, sameId); + + // Act: Lookup metadata + AgentMetadata metadata = await runtime.GetAgentMetadataAsync(agentId); + + // Assert + Assert.Equal(agentId.Type, metadata.Type); + Assert.Equal(agentDescription, metadata.Description); + Assert.Equal(agentId.Key, metadata.Key); + + // Act: Access proxy + AgentProxy proxy = await runtime.TryGetAgentProxyAsync(agentId); + + // Assert + Assert.Equal(agentId, proxy.Id); + Assert.Equal(metadata.Type, proxy.Metadata.Type); + Assert.Equal(metadata.Description, proxy.Metadata.Description); + Assert.Equal(metadata.Key, proxy.Metadata.Key); + + ValueTask factoryFunc(AgentId id, IAgentRuntime runtime) + { + MockAgent agent = new(id, runtime, agentDescription); + agents.Add(agent); + return ValueTask.FromResult(agent); + } + } + + [Fact] + [Trait("Category", "Unit")] + public async Task AgentStateLifecycleTest() + { + // Arrange + const string agentType = "MyAgent"; + const string testMessage = "test message"; + + await using InProcessRuntime firstRuntime = new(); + await firstRuntime.RegisterAgentFactoryAsync(agentType, factoryFunc); + + // Act + AgentId agentId = await firstRuntime.GetAgentAsync(agentType, lazy: false); + + // Assert + Assert.Single(firstRuntime.agentInstances); + + // Arrange + MockAgent agent = (MockAgent)firstRuntime.agentInstances[agentId]; + agent.ReceivedMessages.Add(testMessage); + + // Act + JsonElement agentState = await firstRuntime.SaveAgentStateAsync(agentId); + + // Arrange + await using InProcessRuntime secondRuntime = new(); + await secondRuntime.RegisterAgentFactoryAsync(agentType, factoryFunc); + + // Act + await secondRuntime.LoadAgentStateAsync(agentId, agentState); + + // Assert + Assert.Single(secondRuntime.agentInstances); + MockAgent copy = (MockAgent)secondRuntime.agentInstances[agentId]; + Assert.Single(copy.ReceivedMessages); + Assert.Equal(testMessage, copy.ReceivedMessages.Single().ToString()); + + static ValueTask factoryFunc(AgentId id, IAgentRuntime runtime) + { + MockAgent agent = new(id, runtime, "A test agent"); + return ValueTask.FromResult(agent); + } + } + + [Fact] + [Trait("Category", "Unit")] + public async Task RuntimeSendMessageTest() + { + // Arrange + await using InProcessRuntime runtime = new(); + MockAgent? agent = null; + await runtime.RegisterAgentFactoryAsync("MyAgent", (id, runtime) => + { + agent = new MockAgent(id, runtime, "A test agent"); + return ValueTask.FromResult(agent); + }); + + // Act: Ensure the agent is actually created + AgentId agentId = await runtime.GetAgentAsync("MyAgent", lazy: false); + + // Assert + Assert.NotNull(agent); + Assert.Empty(agent.ReceivedMessages); + + // Act: Send message + await runtime.StartAsync(); + await runtime.SendMessageAsync("TestMessage", agent.Id); + await runtime.RunUntilIdleAsync(); + + // Assert + Assert.Equal(0, runtime.messageQueueCount); + Assert.Single(agent.ReceivedMessages); + } + + // Agent will not deliver to self will success when runtime.DeliverToSelf is false (default) + [Theory] + [InlineData(false, 0)] + [InlineData(true, 1)] + [Trait("Category", "Unit")] + public async Task RuntimeAgentPublishToSelfTest(bool selfPublish, int receiveCount) + { + // Arrange + await using InProcessRuntime runtime = new() + { + DeliverToSelf = selfPublish + }; + + MockAgent? agent = null; + await runtime.RegisterAgentFactoryAsync("MyAgent", (id, runtime) => + { + agent = new MockAgent(id, runtime, "A test agent"); + return ValueTask.FromResult(agent); + }); + + // Assert + runtime.agentInstances.Count.Should().Be(0, "No Agent should be registered in the runtime"); + + // Act: Ensure the agent is actually created + AgentId agentId = await runtime.GetAgentAsync("MyAgent", lazy: false); + + // Assert + Assert.NotNull(agent); + runtime.agentInstances.Count.Should().Be(1, "Agent should be registered in the runtime"); + + const string TopicType = "TestTopic"; + + // Arrange + await runtime.AddSubscriptionAsync(new TestSubscription(TopicType, agentId.Type)); + + // Act + await runtime.StartAsync(); + await runtime.PublishMessageAsync("SelfMessage", new TopicId(TopicType), sender: agentId); + await runtime.RunUntilIdleAsync(); + + // Assert + Assert.Equal(receiveCount, agent.ReceivedMessages.Count); + } + + [Fact] + [Trait("Category", "Unit")] + public async Task RuntimeShouldSaveLoadStateCorrectlyTest() + { + // Arrange: Create a runtime and register an agent + await using InProcessRuntime runtime = new(); + MockAgent? agent = null; + await runtime.RegisterAgentFactoryAsync("MyAgent", (id, runtime) => + { + agent = new MockAgent(id, runtime, "test agent"); + return ValueTask.FromResult(agent); + }); + + // Get agent ID and instantiate agent by publishing + AgentId agentId = await runtime.GetAgentAsync("MyAgent", lazy: false); + const string TopicType = "TestTopic"; + await runtime.AddSubscriptionAsync(new TestSubscription(TopicType, agentId.Type)); + + await runtime.StartAsync(); + await runtime.PublishMessageAsync("test", new TopicId(TopicType)); + await runtime.RunUntilIdleAsync(); + + // Act: Save the state + JsonElement savedState = await runtime.SaveStateAsync(); + + // Assert: Ensure the agent's state is stored as a valid JSON type + Assert.NotNull(agent); + savedState.TryGetProperty(agentId.ToString(), out JsonElement agentState).Should().BeTrue("Agent state should be saved"); + agentState.ValueKind.Should().Be(JsonValueKind.Array, "Agent state should be stored as a JSON array"); + agent.ReceivedMessages.Count.Should().Be(1, "Agent should be have state restored"); + + // Arrange: Serialize and Deserialize the state to simulate persistence + string json = JsonSerializer.Serialize(savedState); + json.Should().NotBeNullOrEmpty("Serialized state should not be empty"); + IDictionary deserializedState = JsonSerializer.Deserialize>(json) + ?? throw new InvalidOperationException("Deserialized state is unexpectedly null"); + deserializedState.Should().ContainKey(agentId.ToString()); + + // Act: Start new runtime and restore the state + agent = null; + await using InProcessRuntime newRuntime = new(); + await newRuntime.StartAsync(); + await newRuntime.RegisterAgentFactoryAsync("MyAgent", (id, runtime) => + { + agent = new MockAgent(id, runtime, "another agent"); + return ValueTask.FromResult(agent); + }); + + // Assert: Show that no agent instances exist in the new runtime + newRuntime.agentInstances.Count.Should().Be(0, "Agent should be registered in the new runtime"); + + // Act: Load the state into the new runtime and show that agent is now instantiated + await newRuntime.LoadStateAsync(savedState); + + // Assert + Assert.NotNull(agent); + newRuntime.agentInstances.Count.Should().Be(1, "Agent should be registered in the new runtime"); + newRuntime.agentInstances.Should().ContainKey(agentId, "Agent should be loaded into the new runtime"); + agent.ReceivedMessages.Count.Should().Be(1, "Agent should be have state restored"); + } + + private sealed class TextMessage + { + public string Source { get; set; } = string.Empty; + public string Content { get; set; } = string.Empty; + } + + private sealed class WrongAgent : IAgent, IHostableAgent + { + public AgentId Id => throw new NotImplementedException(); + + public AgentMetadata Metadata => throw new NotImplementedException(); + + public ValueTask CloseAsync() => ValueTask.CompletedTask; + + public ValueTask LoadStateAsync(JsonElement state) + { + throw new NotImplementedException(); + } + + public ValueTask OnMessageAsync(object message, MessageContext messageContext) + { + throw new NotImplementedException(); + } + + public ValueTask SaveStateAsync() + { + throw new NotImplementedException(); + } + } +} diff --git a/dotnet/src/Agents/Runtime/InProcess.Tests/MessageDeliveryTests.cs b/dotnet/src/Agents/Runtime/InProcess.Tests/MessageDeliveryTests.cs new file mode 100644 index 000000000000..4a9abb930c68 --- /dev/null +++ b/dotnet/src/Agents/Runtime/InProcess.Tests/MessageDeliveryTests.cs @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.SemanticKernel.Agents.Runtime.InProcess.Tests; + +[Trait("Category", "Unit")] +public class MessageDeliveryTests +{ + private static readonly Func EmptyServicer = (_, _) => new ValueTask(); + + [Fact] + public void Constructor_InitializesProperties() + { + // Arrange + MessageEnvelope message = new(new object()); + ResultSink resultSink = new(); + + // Act + MessageDelivery delivery = new(message, EmptyServicer, resultSink); + + // Assert + Assert.Same(message, delivery.Message); + Assert.Same(EmptyServicer, delivery.Servicer); + Assert.Same(resultSink, delivery.ResultSink); + } + + [Fact] + public async Task Future_WithResultSink_ReturnsSinkFuture() + { + // Arrange + MessageEnvelope message = new(new object()); + + ResultSink resultSink = new(); + int expectedResult = 42; + resultSink.SetResult(expectedResult); + + // Act + MessageDelivery delivery = new(message, EmptyServicer, resultSink); + object? result = await delivery.ResultSink.Future; + + // Assert + Assert.Equal(expectedResult, result); + } + + [Fact] + public async Task InvokeAsync_CallsServicerWithCorrectParameters() + { + // Arrange + MessageEnvelope message = new(new object()); + CancellationToken cancellationToken = new(); + + bool servicerCalled = false; + MessageEnvelope? passedMessage = null; + CancellationToken? passedToken = null; + + ValueTask servicer(MessageEnvelope msg, CancellationToken token) + { + servicerCalled = true; + passedMessage = msg; + passedToken = token; + return ValueTask.CompletedTask; + } + + ResultSink sink = new(); + MessageDelivery delivery = new(message, servicer, sink); + + // Act + await delivery.InvokeAsync(cancellationToken); + + // Assert + Assert.True(servicerCalled); + Assert.Same(message, passedMessage); + Assert.Equal(cancellationToken, passedToken); + } +} diff --git a/dotnet/src/Agents/Runtime/InProcess.Tests/MessageEnvelopeTests.cs b/dotnet/src/Agents/Runtime/InProcess.Tests/MessageEnvelopeTests.cs new file mode 100644 index 000000000000..f14bbe7f2f4a --- /dev/null +++ b/dotnet/src/Agents/Runtime/InProcess.Tests/MessageEnvelopeTests.cs @@ -0,0 +1,103 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.SemanticKernel.Agents.Runtime.InProcess.Tests; + +[Trait("Category", "Unit")] +public class MessageEnvelopeTests +{ + [Fact] + public void ConstructAllParametersTest() + { + // Arrange + object message = new { Content = "Test message" }; + const string messageId = "testid"; + CancellationToken cancellation = new(); + + // Act + MessageEnvelope envelope = new(message, messageId, cancellation); + + // Assert + Assert.Same(message, envelope.Message); + Assert.Equal(messageId, envelope.MessageId); + Assert.Equal(cancellation, envelope.Cancellation); + Assert.Null(envelope.Sender); + Assert.Null(envelope.Receiver); + Assert.Null(envelope.Topic); + } + + [Fact] + public void ConstructOnlyRequiredParametersTest() + { + // Arrange & Act + MessageEnvelope envelope = new("test"); + + // Assert + Assert.NotNull(envelope.MessageId); + Assert.NotEmpty(envelope.MessageId); + // Verify it's a valid GUID + Assert.True(Guid.TryParse(envelope.MessageId, out _)); + } + + [Fact] + public void WithSenderTest() + { + // Arrange + MessageEnvelope envelope = new("test"); + AgentId sender = new("testtype", "testkey"); + + // Act + MessageEnvelope result = envelope.WithSender(sender); + + // Assert + Assert.Same(envelope, result); + Assert.Equal(sender, envelope.Sender); + } + + [Fact] + public async Task ForSendTest() + { + // Arrange + MessageEnvelope envelope = new("test"); + AgentId receiver = new("receivertype", "receiverkey"); + object expectedResult = new { Response = "Success" }; + + ValueTask servicer(MessageEnvelope env, CancellationToken ct) => ValueTask.FromResult(expectedResult); + + // Act + MessageDelivery delivery = envelope.ForSend(receiver, servicer); + + // Assert + Assert.NotNull(delivery); + Assert.Same(envelope, delivery.Message); + Assert.Equal(receiver, envelope.Receiver); + + // Invoke the servicer to verify result sink works + await delivery.InvokeAsync(CancellationToken.None); + Assert.True(delivery.ResultSink.Future.IsCompleted); + object? result = await delivery.ResultSink.Future; + Assert.Same(expectedResult, result); + } + + [Fact] + public void ForPublishTest() + { + // Arrange + MessageEnvelope envelope = new("test"); + TopicId topic = new("testtopic"); + + static ValueTask servicer(MessageEnvelope env, CancellationToken ct) => ValueTask.CompletedTask; + + // Act + MessageDelivery delivery = envelope.ForPublish(topic, servicer); + + // Assert + Assert.NotNull(delivery); + Assert.Same(envelope, delivery.Message); + Assert.Equal(topic, envelope.Topic); + } +} diff --git a/dotnet/src/Agents/Runtime/InProcess.Tests/MessagingTestFixture.cs b/dotnet/src/Agents/Runtime/InProcess.Tests/MessagingTestFixture.cs new file mode 100644 index 000000000000..106a82a43d68 --- /dev/null +++ b/dotnet/src/Agents/Runtime/InProcess.Tests/MessagingTestFixture.cs @@ -0,0 +1,203 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.SemanticKernel.Agents.Runtime.Core; + +namespace Microsoft.SemanticKernel.Agents.Runtime.InProcess.Tests; + +public sealed class BasicMessage +{ + public string Content { get; set; } = string.Empty; +} + +#pragma warning disable RCS1194 // Implement exception constructors +public sealed class TestException : Exception { } +#pragma warning restore RCS1194 // Implement exception constructors + +public sealed class PublisherAgent : TestAgent, IHandle +{ + private readonly IList targetTopics; + + public PublisherAgent(AgentId id, IAgentRuntime runtime, string description, IList targetTopics) + : base(id, runtime, description) + { + this.targetTopics = targetTopics; + } + + public async ValueTask HandleAsync(BasicMessage item, MessageContext messageContext) + { + this.ReceivedMessages.Add(item); + foreach (TopicId targetTopic in this.targetTopics) + { + await this.PublishMessageAsync( + new BasicMessage { Content = $"@{targetTopic}: {item.Content}" }, + targetTopic); + } + } +} + +public sealed class SendOnAgent : TestAgent, IHandle +{ + private readonly IList targetKeys; + + public SendOnAgent(AgentId id, IAgentRuntime runtime, string description, IList targetKeys) + : base(id, runtime, description) + { + this.targetKeys = targetKeys; + } + + public async ValueTask HandleAsync(BasicMessage item, MessageContext messageContext) + { + foreach (Guid targetKey in this.targetKeys) + { + AgentId targetId = new(nameof(ReceiverAgent), targetKey.ToString()); + BasicMessage response = new() { Content = $"@{targetKey}: {item.Content}" }; + await this.SendMessageAsync(response, targetId); + } + } +} + +public sealed class ReceiverAgent : TestAgent, IHandle +{ + public List Messages { get; } = []; + + public ReceiverAgent(AgentId id, IAgentRuntime runtime, string description) + : base(id, runtime, description) + { + } + + public ValueTask HandleAsync(BasicMessage item, MessageContext messageContext) + { + this.Messages.Add(item); + return ValueTask.CompletedTask; + } +} + +public sealed class ProcessorAgent : TestAgent, IHandle +{ + private Func ProcessFunc { get; } + + public ProcessorAgent(AgentId id, IAgentRuntime runtime, Func processFunc, string description) + : base(id, runtime, description) + { + this.ProcessFunc = processFunc; + } + + public ValueTask HandleAsync(BasicMessage item, MessageContext messageContext) + { + BasicMessage result = new() { Content = this.ProcessFunc.Invoke(((BasicMessage)item).Content) }; + + return ValueTask.FromResult(result); + } +} + +public sealed class CancelAgent : TestAgent, IHandle +{ + public CancelAgent(AgentId id, IAgentRuntime runtime, string description) + : base(id, runtime, description) + { + } + + public ValueTask HandleAsync(BasicMessage item, MessageContext messageContext) + { + CancellationToken cancelledToken = new(canceled: true); + cancelledToken.ThrowIfCancellationRequested(); + + return ValueTask.CompletedTask; + } +} + +public sealed class ErrorAgent : TestAgent, IHandle +{ + public ErrorAgent(AgentId id, IAgentRuntime runtime, string description) + : base(id, runtime, description) + { + } + + public bool DidThrow { get; private set; } + + public ValueTask HandleAsync(BasicMessage item, MessageContext messageContext) + { + this.DidThrow = true; + + throw new TestException(); + } +} + +public sealed class MessagingTestFixture +{ + private Dictionary AgentsTypeMap { get; } = []; + public InProcessRuntime Runtime { get; } = new(); + + public ValueTask RegisterFactoryMapInstances(AgentType type, Func> factory) + where TAgent : IHostableAgent + { + async ValueTask WrappedFactory(AgentId id, IAgentRuntime runtime) + { + TAgent agent = await factory(id, runtime); + this.GetAgentInstances()[id] = agent; + return agent; + } + + return this.Runtime.RegisterAgentFactoryAsync(type, WrappedFactory); + } + + public Dictionary GetAgentInstances() where TAgent : IHostableAgent + { + if (!this.AgentsTypeMap.TryGetValue(typeof(TAgent), out object? maybeAgentMap) || + maybeAgentMap is not Dictionary result) + { + this.AgentsTypeMap[typeof(TAgent)] = result = []; + } + + return result; + } + public async ValueTask RegisterReceiverAgent(string? agentNameSuffix = null, params string[] topicTypes) + { + await this.RegisterFactoryMapInstances( + $"{nameof(ReceiverAgent)}{agentNameSuffix ?? string.Empty}", + (id, runtime) => ValueTask.FromResult(new ReceiverAgent(id, runtime, string.Empty))); + + foreach (string topicType in topicTypes) + { + await this.Runtime.AddSubscriptionAsync(new TestSubscription(topicType, $"{nameof(ReceiverAgent)}{agentNameSuffix ?? string.Empty}")); + } + } + + public async ValueTask RegisterErrorAgent(string? agentNameSuffix = null, params string[] topicTypes) + { + await this.RegisterFactoryMapInstances( + $"{nameof(ErrorAgent)}{agentNameSuffix ?? string.Empty}", + (id, runtime) => ValueTask.FromResult(new ErrorAgent(id, runtime, string.Empty))); + + foreach (string topicType in topicTypes) + { + await this.Runtime.AddSubscriptionAsync(new TestSubscription(topicType, $"{nameof(ErrorAgent)}{agentNameSuffix ?? string.Empty}")); + } + } + + public async ValueTask RunPublishTestAsync(TopicId sendTarget, object message, string? messageId = null) + { + messageId ??= Guid.NewGuid().ToString(); + + await this.Runtime.StartAsync(); + await this.Runtime.PublishMessageAsync(message, sendTarget, messageId: messageId); + await this.Runtime.RunUntilIdleAsync(); + } + + public async ValueTask RunSendTestAsync(AgentId sendTarget, object message, string? messageId = null) + { + messageId ??= Guid.NewGuid().ToString(); + + await this.Runtime.StartAsync(); + + object? result = await this.Runtime.SendMessageAsync(message, sendTarget, messageId: messageId); + + await this.Runtime.RunUntilIdleAsync(); + + return result; + } +} diff --git a/dotnet/src/Agents/Runtime/InProcess.Tests/PublishMessageTests.cs b/dotnet/src/Agents/Runtime/InProcess.Tests/PublishMessageTests.cs new file mode 100644 index 000000000000..c81a80ba1d86 --- /dev/null +++ b/dotnet/src/Agents/Runtime/InProcess.Tests/PublishMessageTests.cs @@ -0,0 +1,127 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using Xunit; + +namespace Microsoft.SemanticKernel.Agents.Runtime.InProcess.Tests; + +[Trait("Category", "Unit")] +public class PublishMessageTests +{ + [Fact] + public async Task Test_PublishMessage_Success() + { + MessagingTestFixture fixture = new(); + + await fixture.RegisterReceiverAgent(topicTypes: "TestTopic"); + await fixture.RegisterReceiverAgent("2", topicTypes: "TestTopic"); + + await fixture.RunPublishTestAsync(new TopicId("TestTopic"), new BasicMessage { Content = "1" }); + + fixture.GetAgentInstances().Values + .Should().HaveCount(2, "Two agents should have been created") + .And.AllSatisfy(receiverAgent => receiverAgent.Messages + .Should().NotBeNull() + .And.HaveCount(1) + .And.ContainSingle(m => m.Content == "1")); + } + + [Fact] + public async Task Test_PublishMessage_SingleFailure() + { + MessagingTestFixture fixture = new(); + + await fixture.RegisterErrorAgent(topicTypes: "TestTopic"); + + Func publishTask = async () => await fixture.RunPublishTestAsync(new TopicId("TestTopic"), new BasicMessage { Content = "1" }); + + // Test that we wrap single errors appropriately + await publishTask.Should().ThrowAsync(); + + fixture.GetAgentInstances().Values.Should().ContainSingle() + .Which.DidThrow.Should().BeTrue("Agent should have thrown an exception"); + } + + [Fact] + public async Task Test_PublishMessage_MultipleFailures() + { + MessagingTestFixture fixture = new(); + + await fixture.RegisterErrorAgent(topicTypes: "TestTopic"); + await fixture.RegisterErrorAgent("2", topicTypes: "TestTopic"); + + Func publishTask = async () => await fixture.RunPublishTestAsync(new TopicId("TestTopic"), new BasicMessage { Content = "1" }); + + // What we are really testing here is that a single exception does not prevent sending to the remaining agents + (await publishTask.Should().ThrowAsync()) + .Which.Should().Match( + exception => exception.InnerExceptions.Count == 2 && + exception.InnerExceptions.All(exception => exception is TestException)); + + fixture.GetAgentInstances().Values + .Should().HaveCount(2) + .And.AllSatisfy( + agent => agent.DidThrow.Should().BeTrue("Agent should have thrown an exception")); + } + + [Fact] + public async Task Test_PublishMessage_MixedSuccessFailure() + { + MessagingTestFixture fixture = new(); + + await fixture.RegisterReceiverAgent(topicTypes: "TestTopic"); + await fixture.RegisterReceiverAgent("2", topicTypes: "TestTopic"); + + await fixture.RegisterErrorAgent(topicTypes: "TestTopic"); + await fixture.RegisterErrorAgent("2", topicTypes: "TestTopic"); + + Func publicTask = async () => await fixture.RunPublishTestAsync(new TopicId("TestTopic"), new BasicMessage { Content = "1" }); + + // What we are really testing here is that raising exceptions does not prevent sending to the remaining agents + (await publicTask.Should().ThrowAsync()) + .Which.Should().Match( + exception => exception.InnerExceptions.Count == 2 && + exception.InnerExceptions.All( + exception => exception is TestException)); + + fixture.GetAgentInstances().Values + .Should().HaveCount(2, "Two ReceiverAgents should have been created") + .And.AllSatisfy(receiverAgent => receiverAgent.Messages + .Should().NotBeNull() + .And.HaveCount(1) + .And.ContainSingle(m => m.Content == "1"), + "ReceiverAgents should get published message regardless of ErrorAgents throwing exception."); + + fixture.GetAgentInstances().Values + .Should().HaveCount(2, "Two ErrorAgents should have been created") + .And.AllSatisfy(agent => agent.DidThrow.Should().BeTrue("ErrorAgent should have thrown an exception")); + } + + [Fact] + public async Task Test_PublishMessage_RecurrentPublishSucceeds() + { + MessagingTestFixture fixture = new(); + + await fixture.RegisterFactoryMapInstances( + nameof(PublisherAgent), + (id, runtime) => ValueTask.FromResult(new PublisherAgent(id, runtime, string.Empty, [new TopicId("TestTopic")]))); + + await fixture.Runtime.AddSubscriptionAsync(new TestSubscription("RunTest", nameof(PublisherAgent))); + + await fixture.RegisterReceiverAgent(topicTypes: "TestTopic"); + await fixture.RegisterReceiverAgent("2", topicTypes: "TestTopic"); + + await fixture.RunPublishTestAsync(new TopicId("RunTest"), new BasicMessage { Content = "1" }); + + TopicId testTopicId = new("TestTopic"); + fixture.GetAgentInstances().Values + .Should().HaveCount(2, "Two ReceiverAgents should have been created") + .And.AllSatisfy(receiverAgent => receiverAgent.Messages + .Should().NotBeNull() + .And.HaveCount(1) + .And.ContainSingle(m => m.Content == $"@{testTopicId}: 1")); + } +} diff --git a/dotnet/src/Agents/Runtime/InProcess.Tests/ResultSinkTests.cs b/dotnet/src/Agents/Runtime/InProcess.Tests/ResultSinkTests.cs new file mode 100644 index 000000000000..d1647450c765 --- /dev/null +++ b/dotnet/src/Agents/Runtime/InProcess.Tests/ResultSinkTests.cs @@ -0,0 +1,105 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading.Tasks; +using System.Threading.Tasks.Sources; +using Xunit; + +namespace Microsoft.SemanticKernel.Agents.Runtime.InProcess.Tests; + +[Trait("Category", "Unit")] +public class ResultSinkTests +{ + [Fact] + public void GetResultTest() + { + // Arrange + ResultSink sink = new(); + const int expectedResult = 42; + + // Act + sink.SetResult(expectedResult); + int result = sink.GetResult(0); + + // Assert + Assert.Equal(expectedResult, result); + Assert.Equal(ValueTaskSourceStatus.Succeeded, sink.GetStatus(0)); + } + + [Fact] + public async Task FutureResultTest() + { + // Arrange + ResultSink sink = new(); + const string expectedResult = "test"; + + // Act + sink.SetResult(expectedResult); + string result = await sink.Future; + + // Assert + Assert.Equal(expectedResult, result); + Assert.Equal(ValueTaskSourceStatus.Succeeded, sink.GetStatus(0)); + } + + [Fact] + public async Task SetExceptionTest() + { + // Arrange + ResultSink sink = new(); + InvalidOperationException expectedException = new("Test exception"); + + // Act + sink.SetException(expectedException); + + // Assert + Exception exception = await Assert.ThrowsAsync(async () => await sink.Future); + Assert.Equal(expectedException.Message, exception.Message); + exception = Assert.Throws(() => sink.GetResult(0)); + Assert.Equal(expectedException.Message, exception.Message); + Assert.Equal(ValueTaskSourceStatus.Faulted, sink.GetStatus(0)); + } + + [Fact] + public async Task SetCancelledTest() + { + // Arrange + ResultSink sink = new(); + + // Act + sink.SetCancelled(); + + // Assert + Assert.True(sink.IsCancelled); + Assert.Throws(() => sink.GetResult(0)); + await Assert.ThrowsAsync(async () => await sink.Future); + Assert.Equal(ValueTaskSourceStatus.Canceled, sink.GetStatus(0)); + } + + [Fact] + public void OnCompletedTest() + { + // Arrange + ResultSink sink = new(); + bool continuationCalled = false; + const int expectedResult = 42; + + // Register the continuation + sink.OnCompleted( + state => continuationCalled = true, + state: null, + token: 0, + ValueTaskSourceOnCompletedFlags.None); + + // Assert + Assert.False(continuationCalled, "Continuation should have been called"); + + // Act + sink.SetResult(expectedResult); + + // Assert + Assert.Equal(expectedResult, sink.GetResult(0)); + Assert.Equal(ValueTaskSourceStatus.Succeeded, sink.GetStatus(0)); + Assert.True(continuationCalled, "Continuation should have been called"); + } +} diff --git a/dotnet/src/Agents/Runtime/InProcess.Tests/Runtime.InProcess.Tests.csproj b/dotnet/src/Agents/Runtime/InProcess.Tests/Runtime.InProcess.Tests.csproj new file mode 100644 index 000000000000..8ad6013b61c0 --- /dev/null +++ b/dotnet/src/Agents/Runtime/InProcess.Tests/Runtime.InProcess.Tests.csproj @@ -0,0 +1,31 @@ + + + + Microsoft.SemanticKernel.Agents.Runtime.InProcess.Tests + Microsoft.SemanticKernel.Agents.Runtime.InProcess.Tests + net8.0 + True + $(NoWarn);CA1707;CA2007;CA1812;CA1861;CA1063;CS0618;CS1591;IDE1006;VSTHRD111;SKEXP0001;SKEXP0050;SKEXP0110;OPENAI001 + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + diff --git a/dotnet/src/Agents/Runtime/InProcess.Tests/SendMessageTests.cs b/dotnet/src/Agents/Runtime/InProcess.Tests/SendMessageTests.cs new file mode 100644 index 000000000000..7c7f3b15ae77 --- /dev/null +++ b/dotnet/src/Agents/Runtime/InProcess.Tests/SendMessageTests.cs @@ -0,0 +1,96 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading.Tasks; +using FluentAssertions; +using Xunit; + +namespace Microsoft.SemanticKernel.Agents.Runtime.InProcess.Tests; + +[Trait("Category", "Unit")] +public class SendMessageTests +{ + [Fact] + public async Task Test_SendMessage_ReturnsValue() + { + static string ProcessFunc(string s) => $"Processed({s})"; + + MessagingTestFixture fixture = new(); + + await fixture.RegisterFactoryMapInstances(nameof(ProcessorAgent), + (id, runtime) => ValueTask.FromResult(new ProcessorAgent(id, runtime, ProcessFunc, string.Empty))); + + AgentId targetAgent = new(nameof(ProcessorAgent), Guid.NewGuid().ToString()); + object? maybeResult = await fixture.RunSendTestAsync(targetAgent, new BasicMessage { Content = "1" }); + + maybeResult.Should().NotBeNull() + .And.BeOfType() + .And.Match(m => m.Content == "Processed(1)"); + } + + [Fact] + public async Task Test_SendMessage_Cancellation() + { + MessagingTestFixture fixture = new(); + + await fixture.RegisterFactoryMapInstances(nameof(CancelAgent), + (id, runtime) => ValueTask.FromResult(new CancelAgent(id, runtime, string.Empty))); + + AgentId targetAgent = new(nameof(CancelAgent), Guid.NewGuid().ToString()); + Func testAction = () => fixture.RunSendTestAsync(targetAgent, new BasicMessage { Content = "1" }).AsTask(); + + await testAction.Should().ThrowAsync(); + } + + [Fact] + public async Task Test_SendMessage_Error() + { + MessagingTestFixture fixture = new(); + + await fixture.RegisterFactoryMapInstances(nameof(ErrorAgent), + (id, runtime) => ValueTask.FromResult(new ErrorAgent(id, runtime, string.Empty))); + + AgentId targetAgent = new(nameof(ErrorAgent), Guid.NewGuid().ToString()); + Func testAction = () => fixture.RunSendTestAsync(targetAgent, new BasicMessage { Content = "1" }).AsTask(); + + await testAction.Should().ThrowAsync(); + } + + [Fact] + public async Task Test_SendMessage_FromSendMessageHandler() + { + Guid[] targetGuids = [Guid.NewGuid(), Guid.NewGuid()]; + + MessagingTestFixture fixture = new(); + + Dictionary sendAgents = fixture.GetAgentInstances(); + Dictionary receiverAgents = fixture.GetAgentInstances(); + + await fixture.RegisterFactoryMapInstances(nameof(SendOnAgent), + (id, runtime) => ValueTask.FromResult(new SendOnAgent(id, runtime, string.Empty, targetGuids))); + + await fixture.RegisterFactoryMapInstances(nameof(ReceiverAgent), + (id, runtime) => ValueTask.FromResult(new ReceiverAgent(id, runtime, string.Empty))); + + AgentId targetAgent = new(nameof(SendOnAgent), Guid.NewGuid().ToString()); + BasicMessage input = new() { Content = "Hello" }; + Task testTask = fixture.RunSendTestAsync(targetAgent, input).AsTask(); + + // We do not actually expect to wait the timeout here, but it is still better than waiting the 10 min + // timeout that the tests default to. A failure will fail regardless of what timeout value we set. + TimeSpan timeout = Debugger.IsAttached ? TimeSpan.FromSeconds(120) : TimeSpan.FromSeconds(10); + Task timeoutTask = Task.Delay(timeout); + + Task completedTask = await Task.WhenAny([testTask, timeoutTask]); + completedTask.Should().Be(testTask, "SendOnAgent should complete before timeout"); + + // Check that each of the target agents received the message + foreach (Guid targetKey in targetGuids) + { + AgentId targetId = new(nameof(ReceiverAgent), targetKey.ToString()); + receiverAgents[targetId].Messages.Should().ContainSingle(m => m.Content == $"@{targetKey}: {input.Content}"); + } + } +} diff --git a/dotnet/src/Agents/Runtime/InProcess.Tests/TestAgents.cs b/dotnet/src/Agents/Runtime/InProcess.Tests/TestAgents.cs new file mode 100644 index 000000000000..0d809d1ed5ea --- /dev/null +++ b/dotnet/src/Agents/Runtime/InProcess.Tests/TestAgents.cs @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.SemanticKernel.Agents.Runtime.Core; + +namespace Microsoft.SemanticKernel.Agents.Runtime.InProcess.Tests; + +public abstract class TestAgent : BaseAgent +{ + internal List ReceivedMessages = []; + + protected TestAgent(AgentId id, IAgentRuntime runtime, string description) + : base(id, runtime, description) + { + } +} + +/// +/// A test agent that captures the messages it receives and +/// is able to save and load its state. +/// +public sealed class MockAgent : TestAgent, IHandle +{ + public MockAgent(AgentId id, IAgentRuntime runtime, string description) + : base(id, runtime, description) { } + + public ValueTask HandleAsync(string item, MessageContext messageContext) + { + this.ReceivedMessages.Add(item); + return ValueTask.CompletedTask; + } + + public override ValueTask SaveStateAsync() + { + JsonElement json = JsonSerializer.SerializeToElement(this.ReceivedMessages); + return ValueTask.FromResult(json); + } + + public override ValueTask LoadStateAsync(JsonElement state) + { + this.ReceivedMessages = JsonSerializer.Deserialize>(state) ?? throw new InvalidOperationException("Failed to deserialize state"); + return ValueTask.CompletedTask; + } +} diff --git a/dotnet/src/Agents/Runtime/InProcess.Tests/TestSubscription.cs b/dotnet/src/Agents/Runtime/InProcess.Tests/TestSubscription.cs new file mode 100644 index 000000000000..67c258aebb95 --- /dev/null +++ b/dotnet/src/Agents/Runtime/InProcess.Tests/TestSubscription.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.SemanticKernel.Agents.Runtime.InProcess.Tests; + +public class TestSubscription(string topicType, string agentType, string? id = null) : ISubscriptionDefinition +{ + public string Id { get; } = id ?? Guid.NewGuid().ToString(); + + public string TopicType { get; } = topicType; + + public AgentId MapToAgent(TopicId topic) + { + if (!this.Matches(topic)) + { + throw new InvalidOperationException("TopicId does not match the subscription."); + } + + return new AgentId(agentType, topic.Source); + } + + public bool Equals(ISubscriptionDefinition? other) => this.Id == other?.Id; + + public override bool Equals([NotNullWhen(true)] object? obj) => obj is TestSubscription other && other.Equals(this); + + public override int GetHashCode() => this.Id.GetHashCode(); + + public bool Matches(TopicId topic) + { + return topic.Type == this.TopicType; + } +} diff --git a/dotnet/src/Agents/Runtime/InProcess/InProcessRuntime.cs b/dotnet/src/Agents/Runtime/InProcess/InProcessRuntime.cs new file mode 100644 index 000000000000..93e3ec89144b --- /dev/null +++ b/dotnet/src/Agents/Runtime/InProcess/InProcessRuntime.cs @@ -0,0 +1,464 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Reflection; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.SemanticKernel.Agents.Runtime.InProcess; + +/// +/// Provides an in-process/in-memory implementation of the agent runtime. +/// +public sealed class InProcessRuntime : IAgentRuntime, IAsyncDisposable +{ + private readonly Dictionary>> _agentFactories = []; + private readonly Dictionary _subscriptions = []; + private readonly ConcurrentQueue _messageDeliveryQueue = new(); + + private CancellationTokenSource? _shutdownSource; + private CancellationTokenSource? _finishSource; + private Task _messageDeliveryTask = Task.CompletedTask; + private Func _shouldContinue = () => true; + + // Exposed for testing purposes. + internal int messageQueueCount; + internal readonly Dictionary agentInstances = []; + + /// + /// Gets or sets a value indicating whether agents should receive messages they send themselves. + /// + public bool DeliverToSelf { get; set; } //= false; + + /// + public async ValueTask DisposeAsync() + { + await this.RunUntilIdleAsync().ConfigureAwait(false); + this._shutdownSource?.Dispose(); + this._finishSource?.Dispose(); + } + + /// + /// Starts the runtime service. + /// + /// Token to monitor for shutdown requests. + /// A task representing the asynchronous operation. + /// Thrown if the runtime is already started. + public Task StartAsync(CancellationToken cancellationToken = default) + { + if (this._shutdownSource != null) + { + throw new InvalidOperationException("Runtime is already running."); + } + + this._shutdownSource = new CancellationTokenSource(); + this._messageDeliveryTask = Task.Run(() => this.RunAsync(this._shutdownSource.Token), cancellationToken); + + return Task.CompletedTask; + } + + /// + /// Stops the runtime service. + /// + /// Token to propagate when stopping the runtime. + /// A task representing the asynchronous operation. + /// Thrown if the runtime is in the process of stopping. + public Task StopAsync(CancellationToken cancellationToken = default) + { + if (this._shutdownSource != null) + { + if (this._finishSource != null) + { + throw new InvalidOperationException("Runtime is already stopping."); + } + + this._finishSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + + this._shutdownSource.Cancel(); + } + + return Task.CompletedTask; + } + + /// + /// This will run until the message queue is empty and then stop the runtime. + /// + public async Task RunUntilIdleAsync() + { + Func oldShouldContinue = this._shouldContinue; + this._shouldContinue = () => !this._messageDeliveryQueue.IsEmpty; + + // TODO: Do we want detach semantics? + await this._messageDeliveryTask.ConfigureAwait(false); + + this._shouldContinue = oldShouldContinue; + } + + /// + public ValueTask PublishMessageAsync(object message, TopicId topic, AgentId? sender = null, string? messageId = null, CancellationToken cancellationToken = default) + { + return this.ExecuteTracedAsync(async () => + { + MessageDelivery delivery = + new MessageEnvelope(message, messageId, cancellationToken) + .WithSender(sender) + .ForPublish(topic, this.PublishMessageServicerAsync); + + this._messageDeliveryQueue.Enqueue(delivery); + Interlocked.Increment(ref this.messageQueueCount); + + await delivery.ResultSink.Future.ConfigureAwait(false); + }); + } + + /// + public async ValueTask SendMessageAsync(object message, AgentId recipient, AgentId? sender = null, string? messageId = null, CancellationToken cancellationToken = default) + { + return await this.ExecuteTracedAsync(async () => + { + MessageDelivery delivery = + new MessageEnvelope(message, messageId, cancellationToken) + .WithSender(sender) + .ForSend(recipient, this.SendMessageServicerAsync); + + this._messageDeliveryQueue.Enqueue(delivery); + Interlocked.Increment(ref this.messageQueueCount); + + try + { + return await delivery.ResultSink.Future.ConfigureAwait(false); + } + catch (TargetInvocationException ex) when (ex.InnerException is OperationCanceledException innerOCEx) + { + throw new OperationCanceledException($"Delivery of message {messageId} was cancelled.", innerOCEx); + } + }).ConfigureAwait(false); + } + + /// + public async ValueTask GetAgentAsync(AgentId agentId, bool lazy = true) + { + if (!lazy) + { + await this.EnsureAgentAsync(agentId).ConfigureAwait(false); + } + + return agentId; + } + + /// + public ValueTask GetAgentAsync(AgentType agentType, string key = AgentId.DefaultKey, bool lazy = true) + => this.GetAgentAsync(new AgentId(agentType, key), lazy); + + /// + public ValueTask GetAgentAsync(string agent, string key = AgentId.DefaultKey, bool lazy = true) + => this.GetAgentAsync(new AgentId(agent, key), lazy); + + /// + public async ValueTask GetAgentMetadataAsync(AgentId agentId) + { + IHostableAgent agent = await this.EnsureAgentAsync(agentId).ConfigureAwait(false); + return agent.Metadata; + } + + /// + public async ValueTask TryGetUnderlyingAgentInstanceAsync(AgentId agentId) where TAgent : IHostableAgent + { + IHostableAgent agent = await this.EnsureAgentAsync(agentId).ConfigureAwait(false); + + if (agent is not TAgent concreteAgent) + { + throw new InvalidOperationException($"Agent with name {agentId.Type} is not of type {typeof(TAgent).Name}."); + } + + return concreteAgent; + } + + /// + public async ValueTask LoadAgentStateAsync(AgentId agentId, JsonElement state) + { + IHostableAgent agent = await this.EnsureAgentAsync(agentId).ConfigureAwait(false); + await agent.LoadStateAsync(state).ConfigureAwait(false); + } + + /// + public async ValueTask SaveAgentStateAsync(AgentId agentId) + { + IHostableAgent agent = await this.EnsureAgentAsync(agentId).ConfigureAwait(false); + return await agent.SaveStateAsync().ConfigureAwait(false); + } + + /// + public ValueTask AddSubscriptionAsync(ISubscriptionDefinition subscription) + { + if (this._subscriptions.ContainsKey(subscription.Id)) + { + throw new InvalidOperationException($"Subscription with id {subscription.Id} already exists."); + } + + this._subscriptions.Add(subscription.Id, subscription); + +#if !NETCOREAPP + return Task.CompletedTask.AsValueTask(); +#else + return ValueTask.CompletedTask; +#endif + } + + /// + public ValueTask RemoveSubscriptionAsync(string subscriptionId) + { + if (!this._subscriptions.ContainsKey(subscriptionId)) + { + throw new InvalidOperationException($"Subscription with id {subscriptionId} does not exist."); + } + + this._subscriptions.Remove(subscriptionId); + +#if !NETCOREAPP + return Task.CompletedTask.AsValueTask(); +#else + return ValueTask.CompletedTask; +#endif + } + + /// + public async ValueTask LoadStateAsync(JsonElement state) + { + foreach (JsonProperty agentIdStr in state.EnumerateObject()) + { + AgentId agentId = AgentId.FromStr(agentIdStr.Name); + + if (this._agentFactories.ContainsKey(agentId.Type)) + { + IHostableAgent agent = await this.EnsureAgentAsync(agentId).ConfigureAwait(false); + await agent.LoadStateAsync(agentIdStr.Value).ConfigureAwait(false); + } + } + } + + /// + public async ValueTask SaveStateAsync() + { + Dictionary state = []; + foreach (AgentId agentId in this.agentInstances.Keys) + { + JsonElement agentState = await this.agentInstances[agentId].SaveStateAsync().ConfigureAwait(false); + state[agentId.ToString()] = agentState; + } + return JsonSerializer.SerializeToElement(state); + } + + /// + /// Registers an agent factory with the runtime, associating it with a specific agent type. + /// + /// The type of agent created by the factory. + /// The agent type to associate with the factory. + /// A function that asynchronously creates the agent instance. + /// A task representing the asynchronous operation, returning the registered agent type. + public ValueTask RegisterAgentFactoryAsync(AgentType type, Func> factoryFunc) where TAgent : IHostableAgent + // Declare the lambda return type explicitly, as otherwise the compiler will infer 'ValueTask' + // and recurse into the same call, causing a stack overflow. + => this.RegisterAgentFactoryAsync(type, async ValueTask (agentId, runtime) => await factoryFunc(agentId, runtime).ConfigureAwait(false)); + + /// + public ValueTask RegisterAgentFactoryAsync(AgentType type, Func> factoryFunc) + { + if (this._agentFactories.ContainsKey(type)) + { + throw new InvalidOperationException($"Agent with type {type} already exists."); + } + + this._agentFactories.Add(type, factoryFunc); + +#if !NETCOREAPP + return type.AsValueTask(); +#else + return ValueTask.FromResult(type); +#endif + } + + /// + public ValueTask TryGetAgentProxyAsync(AgentId agentId) + { + AgentProxy proxy = new(agentId, this); + +#if !NETCOREAPP + return proxy.AsValueTask(); +#else + return ValueTask.FromResult(proxy); +#endif + } + + private ValueTask ProcessNextMessageAsync(CancellationToken cancellation = default) + { + if (this._messageDeliveryQueue.TryDequeue(out MessageDelivery? delivery)) + { + Interlocked.Decrement(ref this.messageQueueCount); + Debug.WriteLine($"Processing message {delivery.Message.MessageId}..."); + return delivery.InvokeAsync(cancellation); + } + +#if !NETCOREAPP + return Task.CompletedTask.AsValueTask(); +#else + return ValueTask.CompletedTask; +#endif + } + + private async Task RunAsync(CancellationToken cancellation) + { + Dictionary pendingTasks = []; + while (!cancellation.IsCancellationRequested && this._shouldContinue()) + { + // Get a unique task id + Guid taskId; + do + { + taskId = Guid.NewGuid(); + } while (pendingTasks.ContainsKey(taskId)); + + // There is potentially a race condition here, but even if we leak a Task, we will + // still catch it on the Finish() pass. + ValueTask processTask = this.ProcessNextMessageAsync(cancellation); + await Task.Yield(); + + // Check if the task is already completed + if (processTask.IsCompleted) + { + continue; + } + + Task actualTask = processTask.AsTask(); + pendingTasks.Add(taskId, actualTask.ContinueWith(t => pendingTasks.Remove(taskId), TaskScheduler.Current)); + } + + // The pending task dictionary may contain null values when a race condition is experienced during + // the prior "ContinueWith" call. This could be solved with a ConcurrentDictionary, but locking + // is entirely undesirable in this context. + await Task.WhenAll([.. pendingTasks.Values.Where(task => task is not null)]).ConfigureAwait(false); + await this.FinishAsync(this._finishSource?.Token ?? CancellationToken.None).ConfigureAwait(false); + } + + private async ValueTask PublishMessageServicerAsync(MessageEnvelope envelope, CancellationToken deliveryToken) + { + if (!envelope.Topic.HasValue) + { + throw new InvalidOperationException("Message must have a topic to be published."); + } + + List exceptions = []; + TopicId topic = envelope.Topic.Value; + foreach (ISubscriptionDefinition subscription in this._subscriptions.Values.Where(subscription => subscription.Matches(topic))) + { + try + { + deliveryToken.ThrowIfCancellationRequested(); + + AgentId? sender = envelope.Sender; + + using CancellationTokenSource combinedSource = CancellationTokenSource.CreateLinkedTokenSource(envelope.Cancellation, deliveryToken); + MessageContext messageContext = new(envelope.MessageId, combinedSource.Token) + { + Sender = sender, + Topic = topic, + IsRpc = false + }; + + AgentId agentId = subscription.MapToAgent(topic); + if (!this.DeliverToSelf && sender.HasValue && sender == agentId) + { + continue; + } + + IHostableAgent agent = await this.EnsureAgentAsync(agentId).ConfigureAwait(false); + + // TODO: Cancellation propagation! + await agent.OnMessageAsync(envelope.Message, messageContext).ConfigureAwait(false); + } + catch (Exception ex) when (!ex.IsCriticalException()) + { + exceptions.Add(ex); + } + } + + if (exceptions.Count > 0) + { + // TODO: Unwrap TargetInvocationException? + throw new AggregateException("One or more exceptions occurred while processing the message.", exceptions); + } + } + + private async ValueTask SendMessageServicerAsync(MessageEnvelope envelope, CancellationToken deliveryToken) + { + if (!envelope.Receiver.HasValue) + { + throw new InvalidOperationException("Message must have a receiver to be sent."); + } + + using CancellationTokenSource combinedSource = CancellationTokenSource.CreateLinkedTokenSource(envelope.Cancellation, deliveryToken); + MessageContext messageContext = new(envelope.MessageId, combinedSource.Token) + { + Sender = envelope.Sender, + IsRpc = false + }; + + AgentId receiver = envelope.Receiver.Value; + IHostableAgent agent = await this.EnsureAgentAsync(receiver).ConfigureAwait(false); + + return await agent.OnMessageAsync(envelope.Message, messageContext).ConfigureAwait(false); + } + + private async ValueTask EnsureAgentAsync(AgentId agentId) + { + if (!this.agentInstances.TryGetValue(agentId, out IHostableAgent? agent)) + { + if (!this._agentFactories.TryGetValue(agentId.Type, out Func>? factoryFunc)) + { + throw new InvalidOperationException($"Agent with name {agentId.Type} not found."); + } + + agent = await factoryFunc(agentId, this).ConfigureAwait(false); + this.agentInstances.Add(agentId, agent); + } + + return this.agentInstances[agentId]; + } + + private async Task FinishAsync(CancellationToken token) + { + foreach (IHostableAgent agent in this.agentInstances.Values) + { + if (!token.IsCancellationRequested) + { + await agent.CloseAsync().ConfigureAwait(false); + } + } + + this._shutdownSource?.Dispose(); + this._finishSource?.Dispose(); + this._finishSource = null; + this._shutdownSource = null; + } + +#pragma warning disable CA1822 // Mark members as static + private ValueTask ExecuteTracedAsync(Func> func) +#pragma warning restore CA1822 // Mark members as static + { + // TODO: Bind tracing + return func(); + } + +#pragma warning disable CA1822 // Mark members as static + private ValueTask ExecuteTracedAsync(Func func) +#pragma warning restore CA1822 // Mark members as static + { + // TODO: Bind tracing + return func(); + } +} diff --git a/dotnet/src/Agents/Runtime/InProcess/MessageDelivery.cs b/dotnet/src/Agents/Runtime/InProcess/MessageDelivery.cs new file mode 100644 index 000000000000..db0fddb2c514 --- /dev/null +++ b/dotnet/src/Agents/Runtime/InProcess/MessageDelivery.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.SemanticKernel.Agents.Runtime.InProcess; + +internal sealed class MessageDelivery(MessageEnvelope message, Func servicer, IResultSink resultSink) +{ + public MessageEnvelope Message { get; } = message; + public Func Servicer { get; } = servicer; + public IResultSink ResultSink { get; } = resultSink; + + public ValueTask InvokeAsync(CancellationToken cancellation) + { + return this.Servicer(this.Message, cancellation); + } +} diff --git a/dotnet/src/Agents/Runtime/InProcess/MessageEnvelope.cs b/dotnet/src/Agents/Runtime/InProcess/MessageEnvelope.cs new file mode 100644 index 000000000000..265d303babca --- /dev/null +++ b/dotnet/src/Agents/Runtime/InProcess/MessageEnvelope.cs @@ -0,0 +1,78 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.SemanticKernel.Agents.Runtime.InProcess; + +internal sealed class MessageEnvelope +{ + public object Message { get; } + public string MessageId { get; } + public TopicId? Topic { get; private set; } + public AgentId? Sender { get; private set; } + public AgentId? Receiver { get; private set; } + public CancellationToken Cancellation { get; } + + public MessageEnvelope(object message, string? messageId = null, CancellationToken cancellation = default) + { + this.Message = message; + this.MessageId = messageId ?? Guid.NewGuid().ToString(); + this.Cancellation = cancellation; + } + + public MessageEnvelope WithSender(AgentId? sender) + { + this.Sender = sender; + return this; + } + + public MessageDelivery ForSend(AgentId receiver, Func> servicer) + { + this.Receiver = receiver; + + ResultSink resultSink = new(); + + return new MessageDelivery(this, BoundServicer, resultSink); + + async ValueTask BoundServicer(MessageEnvelope envelope, CancellationToken cancellation) + { + try + { + object? result = await servicer(envelope, cancellation).ConfigureAwait(false); + resultSink.SetResult(result); + } + catch (OperationCanceledException exception) + { + resultSink.SetCancelled(exception); + } + catch (Exception exception) when (!exception.IsCriticalException()) + { + resultSink.SetException(exception); + } + } + } + + public MessageDelivery ForPublish(TopicId topic, Func servicer) + { + this.Topic = topic; + + ResultSink waitForPublish = new(); + + async ValueTask BoundServicer(MessageEnvelope envelope, CancellationToken cancellation) + { + try + { + await servicer(envelope, cancellation).ConfigureAwait(false); + waitForPublish.SetResult(null); + } + catch (Exception ex) when (!ex.IsCriticalException()) + { + waitForPublish.SetException(ex); + } + } + + return new MessageDelivery(this, BoundServicer, waitForPublish); + } +} diff --git a/dotnet/src/Agents/Runtime/InProcess/ResultSink.cs b/dotnet/src/Agents/Runtime/InProcess/ResultSink.cs new file mode 100644 index 000000000000..5068aa448ab1 --- /dev/null +++ b/dotnet/src/Agents/Runtime/InProcess/ResultSink.cs @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading.Tasks; +using System.Threading.Tasks.Sources; + +namespace Microsoft.SemanticKernel.Agents.Runtime.InProcess; + +internal interface IResultSink : IValueTaskSource +{ + void SetResult(TResult result); + void SetException(Exception exception); + void SetCancelled(OperationCanceledException? exception = null); + + ValueTask Future { get; } +} + +internal sealed class ResultSink : IResultSink +{ + private ManualResetValueTaskSourceCore _core; + + public bool IsCancelled { get; private set; } + + public TResult GetResult(short token) + { + return this._core.GetResult(token); + } + + public ValueTaskSourceStatus GetStatus(short token) + { + return this._core.GetStatus(token); + } + + public void OnCompleted(Action continuation, object? state, short token, ValueTaskSourceOnCompletedFlags flags) + { + this._core.OnCompleted(continuation, state, token, flags); + } + + public void SetCancelled(OperationCanceledException? exception = null) + { + this.IsCancelled = true; + this._core.SetException(exception ?? new OperationCanceledException()); + } + + public void SetException(Exception exception) + { + this._core.SetException(exception); + } + + public void SetResult(TResult result) + { + this._core.SetResult(result); + } + + public ValueTask Future => new(this, this._core.Version); +} diff --git a/dotnet/src/Agents/Runtime/InProcess/Runtime.InProcess.csproj b/dotnet/src/Agents/Runtime/InProcess/Runtime.InProcess.csproj new file mode 100644 index 000000000000..dc585e3b2ef9 --- /dev/null +++ b/dotnet/src/Agents/Runtime/InProcess/Runtime.InProcess.csproj @@ -0,0 +1,35 @@ + + + + Microsoft.SemanticKernel.Agents.Runtime.InProcess + Microsoft.SemanticKernel.Agents.Runtime.InProcess + net8.0;netstandard2.0 + preview + SKIPSKABSTRACTION + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dotnet/src/InternalUtilities/src/Diagnostics/Verify.cs b/dotnet/src/InternalUtilities/src/Diagnostics/Verify.cs index b2aba234fb67..c792d50d13e0 100644 --- a/dotnet/src/InternalUtilities/src/Diagnostics/Verify.cs +++ b/dotnet/src/InternalUtilities/src/Diagnostics/Verify.cs @@ -75,6 +75,7 @@ public static void True(bool condition, string message, [CallerArgumentExpressio } } +#if !SKIPSKABSTRACTION internal static void ValidPluginName([NotNull] string? pluginName, IReadOnlyKernelPluginCollection? plugins = null, [CallerArgumentExpression(nameof(pluginName))] string? paramName = null) { NotNullOrWhiteSpace(pluginName); @@ -88,6 +89,7 @@ internal static void ValidPluginName([NotNull] string? pluginName, IReadOnlyKern throw new ArgumentException($"A plugin with the name '{pluginName}' already exists."); } } +#endif internal static void ValidFunctionName([NotNull] string? functionName, [CallerArgumentExpression(nameof(functionName))] string? paramName = null) { @@ -146,6 +148,7 @@ internal static void DirectoryExists(string path) } } +#if !SKIPSKABSTRACTION /// /// Make sure every function parameter name is unique /// @@ -179,6 +182,7 @@ internal static void ParametersUniqueness(IReadOnlyList } } } +#endif [DoesNotReturn] private static void ThrowArgumentInvalidName(string kind, string name, string? paramName) => diff --git a/dotnet/src/InternalUtilities/src/System/ValueTaskExtensions.cs b/dotnet/src/InternalUtilities/src/System/ValueTaskExtensions.cs new file mode 100644 index 000000000000..1bb738b1ced3 --- /dev/null +++ b/dotnet/src/InternalUtilities/src/System/ValueTaskExtensions.cs @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft. All rights reserved. + +#if !NETCOREAPP + +using System; +using System.Threading.Tasks; + +/// +/// Convenience extensions for ValueTask patterns within .netstandard2.0 projects. +/// +internal static class ValueTaskExtensions +{ + /// + /// Creates a that's completed successfully with the specified result. + /// + /// + /// + /// int value = 33; + /// return value.AsValueTask(); + /// + /// + public static ValueTask AsValueTask(this TValue value) => new(value); + + /// + /// Creates a that's failed and is associated with an exception. + /// + /// + /// + /// int value = 33; + /// return value.AsValueTask(); + /// + /// + public static ValueTask AsValueTask(this Exception exception) => new(Task.FromException(exception)); + + /// + /// Present a regular task as a ValueTask. + /// + /// + /// return Task.CompletedTask.AsValueTask(); + /// + public static ValueTask AsValueTask(this Task task) => new(task); +} + +#endif