diff --git a/dotnet/AutoGen.sln b/dotnet/AutoGen.sln index 291cb484649..4f82713b5ad 100644 --- a/dotnet/AutoGen.sln +++ b/dotnet/AutoGen.sln @@ -68,6 +68,13 @@ EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{243E768F-EA7D-4AF1-B625-0398440BB1AB}" ProjectSection(SolutionItems) = preProject .editorconfig = .editorconfig + .gitattributes = .gitattributes + .gitignore = .gitignore + Directory.Build.props = Directory.Build.props + Directory.Build.targets = Directory.Build.targets + Directory.Packages.props = Directory.Packages.props + global.json = global.json + NuGet.config = NuGet.config spelling.dic = spelling.dic EndProjectSection EndProject @@ -123,7 +130,7 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HelloAgent", "samples\Hello EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AIModelClientHostingExtensions", "src\Microsoft.AutoGen\Extensions\AIModelClientHostingExtensions\AIModelClientHostingExtensions.csproj", "{97550E87-48C6-4EBF-85E1-413ABAE9DBFD}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.AutoGen.Agents.Tests", "Microsoft.AutoGen.Agents.Tests\Microsoft.AutoGen.Agents.Tests.csproj", "{CF4C92BD-28AE-4B8F-B173-601004AEC9BF}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AutoGen.Agents.Tests", "Microsoft.AutoGen.Agents.Tests\Microsoft.AutoGen.Agents.Tests.csproj", "{CF4C92BD-28AE-4B8F-B173-601004AEC9BF}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "sample", "sample", "{686480D7-8FEC-4ED3-9C5D-CEBE1057A7ED}" EndProject diff --git a/dotnet/Directory.Build.props b/dotnet/Directory.Build.props index bb78c84d14f..ae30d2c48a5 100644 --- a/dotnet/Directory.Build.props +++ b/dotnet/Directory.Build.props @@ -32,10 +32,6 @@ $(NoWarn);CA1829 - - - - diff --git a/dotnet/samples/AutoGen.BasicSamples/AutoGen.BasicSample.csproj b/dotnet/samples/AutoGen.BasicSamples/AutoGen.BasicSample.csproj index 460c95f3743..8d73ca96679 100644 --- a/dotnet/samples/AutoGen.BasicSamples/AutoGen.BasicSample.csproj +++ b/dotnet/samples/AutoGen.BasicSamples/AutoGen.BasicSample.csproj @@ -15,5 +15,7 @@ + + diff --git a/dotnet/samples/AutoGen.BasicSamples/Example03_Agent_FunctionCall.cs b/dotnet/samples/AutoGen.BasicSamples/Example03_Agent_FunctionCall.cs index ca05a42ca36..f09b5645dc2 100644 --- a/dotnet/samples/AutoGen.BasicSamples/Example03_Agent_FunctionCall.cs +++ b/dotnet/samples/AutoGen.BasicSamples/Example03_Agent_FunctionCall.cs @@ -6,6 +6,7 @@ using AutoGen.OpenAI; using AutoGen.OpenAI.Extension; using FluentAssertions; +using Microsoft.Extensions.AI; /// /// This example shows how to add type-safe function call to an agent. @@ -37,13 +38,20 @@ public async Task ConcatString(string[] strings) /// /// price, should be an integer /// tax rate, should be in range (0, 1) - [FunctionAttribute] + [Function] public async Task CalculateTax(int price, float taxRate) { return $"tax is {price * taxRate}"; } - public static async Task RunAsync() + /// + /// This example shows how to add type-safe function call using AutoGen.SourceGenerator. + /// The SourceGenerator will automatically generate FunctionDefinition and FunctionCallWrapper during compiling time. + /// + /// For adding type-safe function call from M.E.A.I tools, please refer to . + /// + /// + public static async Task ToolCallWithSourceGenerator() { var instance = new Example03_Agent_FunctionCall(); var gpt4o = LLMConfiguration.GetOpenAIGPT4o_mini(); @@ -101,4 +109,60 @@ public static async Task RunAsync() // send aggregate message back to llm to get the final result var finalResult = await agent.SendAsync(calculateTaxes); } + + /// + /// This example shows how to add type-safe function call from M.E.A.I tools. + /// + /// For adding type-safe function call from source generator, please refer to . + /// + public static async Task ToolCallWithMEAITools() + { + var gpt4o = LLMConfiguration.GetOpenAIGPT4o_mini(); + var instance = new Example03_Agent_FunctionCall(); + + AIFunction[] tools = [ + AIFunctionFactory.Create(instance.UpperCase), + AIFunctionFactory.Create(instance.ConcatString), + AIFunctionFactory.Create(instance.CalculateTax), + ]; + + var toolCallMiddleware = new FunctionCallMiddleware(tools); + + var agent = new OpenAIChatAgent( + chatClient: gpt4o, + name: "agent", + systemMessage: "You are a helpful AI assistant") + .RegisterMessageConnector() + .RegisterStreamingMiddleware(toolCallMiddleware) + .RegisterPrintMessage(); + + // talk to the assistant agent + var upperCase = await agent.SendAsync("convert to upper case: hello world"); + upperCase.GetContent()?.Should().Be("HELLO WORLD"); + upperCase.Should().BeOfType(); + upperCase.GetToolCalls().Should().HaveCount(1); + upperCase.GetToolCalls().First().FunctionName.Should().Be(nameof(UpperCase)); + + var concatString = await agent.SendAsync("concatenate strings: a, b, c, d, e"); + concatString.GetContent()?.Should().Be("a b c d e"); + concatString.Should().BeOfType(); + concatString.GetToolCalls().Should().HaveCount(1); + concatString.GetToolCalls().First().FunctionName.Should().Be(nameof(ConcatString)); + + var calculateTax = await agent.SendAsync("calculate tax: 100, 0.1"); + calculateTax.GetContent().Should().Be("tax is 10"); + calculateTax.Should().BeOfType(); + calculateTax.GetToolCalls().Should().HaveCount(1); + calculateTax.GetToolCalls().First().FunctionName.Should().Be(nameof(CalculateTax)); + + // parallel function calls + var calculateTaxes = await agent.SendAsync("calculate tax: 100, 0.1; calculate tax: 200, 0.2"); + calculateTaxes.GetContent().Should().Be("tax is 10\ntax is 40"); // "tax is 10\n tax is 40 + calculateTaxes.Should().BeOfType(); + calculateTaxes.GetToolCalls().Should().HaveCount(2); + calculateTaxes.GetToolCalls().First().FunctionName.Should().Be(nameof(CalculateTax)); + + // send aggregate message back to llm to get the final result + var finalResult = await agent.SendAsync(calculateTaxes); + } } diff --git a/dotnet/samples/AutoGen.BasicSamples/Program.cs b/dotnet/samples/AutoGen.BasicSamples/Program.cs index 3a2edbb585f..16a79e75cff 100644 --- a/dotnet/samples/AutoGen.BasicSamples/Program.cs +++ b/dotnet/samples/AutoGen.BasicSamples/Program.cs @@ -11,7 +11,8 @@ // When a new sample is created please add them to the allSamples collection ("Assistant Agent", Example01_AssistantAgent.RunAsync), ("Two-agent Math Chat", Example02_TwoAgent_MathChat.RunAsync), - ("Agent Function Call", Example03_Agent_FunctionCall.RunAsync), + ("Agent Function Call With Source Generator", Example03_Agent_FunctionCall.ToolCallWithSourceGenerator), + ("Agent Function Call With M.E.A.I AI Functions", Example03_Agent_FunctionCall.ToolCallWithMEAITools), ("Dynamic Group Chat Coding Task", Example04_Dynamic_GroupChat_Coding_Task.RunAsync), ("DALL-E and GPT4v", Example05_Dalle_And_GPT4V.RunAsync), ("User Proxy Agent", Example06_UserProxyAgent.RunAsync), diff --git a/dotnet/src/AutoGen.Core/AutoGen.Core.csproj b/dotnet/src/AutoGen.Core/AutoGen.Core.csproj index c34c03af2b5..f46d48dc844 100644 --- a/dotnet/src/AutoGen.Core/AutoGen.Core.csproj +++ b/dotnet/src/AutoGen.Core/AutoGen.Core.csproj @@ -17,6 +17,7 @@ + diff --git a/dotnet/src/AutoGen.Core/Function/FunctionAttribute.cs b/dotnet/src/AutoGen.Core/Function/FunctionAttribute.cs index bb37f1cb25d..9418dc7fd6a 100644 --- a/dotnet/src/AutoGen.Core/Function/FunctionAttribute.cs +++ b/dotnet/src/AutoGen.Core/Function/FunctionAttribute.cs @@ -3,6 +3,9 @@ using System; using System.Collections.Generic; +using System.Linq; +using System.Text.Json.Serialization; +using Microsoft.Extensions.AI; namespace AutoGen.Core; @@ -22,6 +25,10 @@ public FunctionAttribute(string? functionName = null, string? description = null public class FunctionContract { + private const string NamespaceKey = nameof(Namespace); + + private const string ClassNameKey = nameof(ClassName); + /// /// The namespace of the function. /// @@ -52,6 +59,7 @@ public class FunctionContract /// /// The return type of the function. /// + [JsonIgnore] public Type? ReturnType { get; set; } /// @@ -60,6 +68,39 @@ public class FunctionContract /// Otherwise, the description will be null. /// public string? ReturnDescription { get; set; } + + public static implicit operator FunctionContract(AIFunctionMetadata metadata) + { + return new FunctionContract + { + Namespace = metadata.AdditionalProperties.ContainsKey(NamespaceKey) ? metadata.AdditionalProperties[NamespaceKey] as string : null, + ClassName = metadata.AdditionalProperties.ContainsKey(ClassNameKey) ? metadata.AdditionalProperties[ClassNameKey] as string : null, + Name = metadata.Name, + Description = metadata.Description, + Parameters = metadata.Parameters?.Select(p => (FunctionParameterContract)p).ToList(), + ReturnType = metadata.ReturnParameter.ParameterType, + ReturnDescription = metadata.ReturnParameter.Description, + }; + } + + public static implicit operator AIFunctionMetadata(FunctionContract contract) + { + return new AIFunctionMetadata(contract.Name) + { + Description = contract.Description, + ReturnParameter = new AIFunctionReturnParameterMetadata() + { + Description = contract.ReturnDescription, + ParameterType = contract.ReturnType, + }, + AdditionalProperties = new Dictionary + { + [NamespaceKey] = contract.Namespace, + [ClassNameKey] = contract.ClassName, + }, + Parameters = [.. contract.Parameters?.Select(p => (AIFunctionParameterMetadata)p)], + }; + } } public class FunctionParameterContract @@ -79,6 +120,7 @@ public class FunctionParameterContract /// /// The type of the parameter. /// + [JsonIgnore] public Type? ParameterType { get; set; } /// @@ -90,4 +132,29 @@ public class FunctionParameterContract /// The default value of the parameter. /// public object? DefaultValue { get; set; } + + // convert to/from FunctionParameterMetadata + public static implicit operator FunctionParameterContract(AIFunctionParameterMetadata metadata) + { + return new FunctionParameterContract + { + Name = metadata.Name, + Description = metadata.Description, + ParameterType = metadata.ParameterType, + IsRequired = metadata.IsRequired, + DefaultValue = metadata.DefaultValue, + }; + } + + public static implicit operator AIFunctionParameterMetadata(FunctionParameterContract contract) + { + return new AIFunctionParameterMetadata(contract.Name!) + { + DefaultValue = contract.DefaultValue, + Description = contract.Description, + IsRequired = contract.IsRequired, + ParameterType = contract.ParameterType, + HasDefaultValue = contract.DefaultValue != null, + }; + } } diff --git a/dotnet/src/AutoGen.Core/Middleware/FunctionCallMiddleware.cs b/dotnet/src/AutoGen.Core/Middleware/FunctionCallMiddleware.cs index 21461834dc8..266155316c8 100644 --- a/dotnet/src/AutoGen.Core/Middleware/FunctionCallMiddleware.cs +++ b/dotnet/src/AutoGen.Core/Middleware/FunctionCallMiddleware.cs @@ -5,8 +5,10 @@ using System.Collections.Generic; using System.Linq; using System.Runtime.CompilerServices; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.AI; namespace AutoGen.Core; @@ -43,6 +45,19 @@ public FunctionCallMiddleware( this.functionMap = functionMap; } + /// + /// Create a new instance of with a list of . + /// + /// function list + /// optional middleware name. If not provided, the class name will be used. + public FunctionCallMiddleware(IEnumerable functions, string? name = null) + { + this.Name = name ?? nameof(FunctionCallMiddleware); + this.functions = functions.Select(f => (FunctionContract)f.Metadata).ToArray(); + + this.functionMap = functions.Select(f => (f.Metadata.Name, this.AIToolInvokeWrapper(f.InvokeAsync))).ToDictionary(f => f.Name, f => f.Item2); + } + public string? Name { get; } public async Task InvokeAsync(MiddlewareContext context, IAgent agent, CancellationToken cancellationToken = default) @@ -173,4 +188,20 @@ private async Task InvokeToolCallMessagesAfterInvokingAgentAsync(ToolC return toolCallMsg; } } + + private Func> AIToolInvokeWrapper(Func>?, CancellationToken, Task> lambda) + { + return async (string args) => + { + var arguments = JsonSerializer.Deserialize>(args); + var result = await lambda(arguments, CancellationToken.None); + + return result switch + { + string s => s, + JsonElement e => e.ToString(), + _ => JsonSerializer.Serialize(result), + }; + }; + } } diff --git a/dotnet/test/AutoGen.Tests/AutoGen.Tests.csproj b/dotnet/test/AutoGen.Tests/AutoGen.Tests.csproj index 12c31e1a473..248a9e29b00 100644 --- a/dotnet/test/AutoGen.Tests/AutoGen.Tests.csproj +++ b/dotnet/test/AutoGen.Tests/AutoGen.Tests.csproj @@ -13,6 +13,8 @@ + + diff --git a/dotnet/test/AutoGen.Tests/BasicSampleTest.cs b/dotnet/test/AutoGen.Tests/BasicSampleTest.cs index df02bb3dcd0..5cf4704037f 100644 --- a/dotnet/test/AutoGen.Tests/BasicSampleTest.cs +++ b/dotnet/test/AutoGen.Tests/BasicSampleTest.cs @@ -34,7 +34,8 @@ public async Task TwoAgentMathClassTestAsync() [ApiKeyFact("OPENAI_API_KEY")] public async Task AgentFunctionCallTestAsync() { - await Example03_Agent_FunctionCall.RunAsync(); + await Example03_Agent_FunctionCall.ToolCallWithSourceGenerator(); + await Example03_Agent_FunctionCall.ToolCallWithMEAITools(); } [ApiKeyFact("MISTRAL_API_KEY")] diff --git a/dotnet/test/AutoGen.Tests/Function/ApprovalTests/FunctionTests.CreateGetWeatherFunctionFromAIFunctionFactoryAsync.approved.txt b/dotnet/test/AutoGen.Tests/Function/ApprovalTests/FunctionTests.CreateGetWeatherFunctionFromAIFunctionFactoryAsync.approved.txt new file mode 100644 index 00000000000..f57e0203e35 --- /dev/null +++ b/dotnet/test/AutoGen.Tests/Function/ApprovalTests/FunctionTests.CreateGetWeatherFunctionFromAIFunctionFactoryAsync.approved.txt @@ -0,0 +1,76 @@ +[ + { + "Kind": 0, + "FunctionName": "GetWeather", + "FunctionDescription": "get weather", + "FunctionParameters": { + "type": "object", + "properties": { + "city": { + "type": "string" + }, + "date": { + "type": "string" + } + }, + "required": [ + "city" + ] + } + }, + { + "Kind": 0, + "FunctionName": "GetWeatherStatic", + "FunctionDescription": "get weather from static method", + "FunctionParameters": { + "type": "object", + "properties": { + "city": { + "type": "string" + }, + "date": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "city", + "date" + ] + } + }, + { + "Kind": 0, + "FunctionName": "GetWeather", + "FunctionDescription": "get weather from async method", + "FunctionParameters": { + "type": "object", + "properties": { + "city": { + "type": "string" + } + }, + "required": [ + "city" + ] + } + }, + { + "Kind": 0, + "FunctionName": "GetWeatherAsyncStatic", + "FunctionDescription": "get weather from async static method", + "FunctionParameters": { + "type": "object", + "properties": { + "city": { + "type": "string" + } + }, + "required": [ + "city" + ] + } + } +] \ No newline at end of file diff --git a/dotnet/test/AutoGen.Tests/Function/FunctionTests.cs b/dotnet/test/AutoGen.Tests/Function/FunctionTests.cs new file mode 100644 index 00000000000..768558d35ba --- /dev/null +++ b/dotnet/test/AutoGen.Tests/Function/FunctionTests.cs @@ -0,0 +1,82 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// FunctionTests.cs + +using System; +using System.ComponentModel; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading.Tasks; +using ApprovalTests; +using ApprovalTests.Namers; +using ApprovalTests.Reporters; +using AutoGen.OpenAI.Extension; +using FluentAssertions; +using Microsoft.Extensions.AI; +using Xunit; + +namespace AutoGen.Tests.Function; +public class FunctionTests +{ + private readonly JsonSerializerOptions _jsonSerializerOptions = new() { WriteIndented = true, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; + [Description("get weather")] + public string GetWeather(string city, string date = "today") + { + return $"The weather in {city} is sunny."; + } + + [Description("get weather from static method")] + [return: Description("weather information")] + public static string GetWeatherStatic(string city, string[] date) + { + return $"The weather in {city} is sunny."; + } + + [Description("get weather from async method")] + public async Task GetWeatherAsync(string city) + { + await Task.Delay(100); + return $"The weather in {city} is sunny."; + } + + [Description("get weather from async static method")] + public static async Task GetWeatherAsyncStatic(string city) + { + await Task.Delay(100); + return $"The weather in {city} is sunny."; + } + + [Fact] + [UseReporter(typeof(DiffReporter))] + [UseApprovalSubdirectory("ApprovalTests")] + public async Task CreateGetWeatherFunctionFromAIFunctionFactoryAsync() + { + Delegate[] availableDelegates = [ + GetWeather, + GetWeatherStatic, + GetWeatherAsync, + GetWeatherAsyncStatic, + ]; + + var functionContracts = availableDelegates.Select(function => (FunctionContract)AIFunctionFactory.Create(function).Metadata).ToList(); + + // Verify the function contracts + functionContracts.Should().HaveCount(4); + + var openAIToolContracts = functionContracts.Select(f => + { + var tool = f.ToChatTool(); + + return new + { + tool.Kind, + tool.FunctionName, + tool.FunctionDescription, + FunctionParameters = tool.FunctionParameters.ToObjectFromJson(), + }; + }); + + var json = JsonSerializer.Serialize(openAIToolContracts, _jsonSerializerOptions); + Approvals.Verify(json); + } +} diff --git a/dotnet/test/AutoGen.Tests/MiddlewareTest.cs b/dotnet/test/AutoGen.Tests/MiddlewareTest.cs index d98fa14ec19..61691b22543 100644 --- a/dotnet/test/AutoGen.Tests/MiddlewareTest.cs +++ b/dotnet/test/AutoGen.Tests/MiddlewareTest.cs @@ -7,6 +7,7 @@ using System.Text.Json; using System.Threading.Tasks; using FluentAssertions; +using Microsoft.Extensions.AI; using Xunit; namespace AutoGen.Tests; @@ -72,7 +73,7 @@ public async Task FunctionCallMiddlewareTestAsync() var agent = new EchoAgent("echo"); var args = new EchoSchema { message = "hello" }; var argsJson = JsonSerializer.Serialize(args) ?? throw new InvalidOperationException("Failed to serialize args"); - var functionCall = new ToolCall("echo", argsJson); + var functionCall = new ToolCall("Echo", argsJson); var functionCallAgent = agent.RegisterMiddleware(async (messages, options, agent, ct) => { if (options?.Functions is null) @@ -86,7 +87,7 @@ public async Task FunctionCallMiddlewareTestAsync() // test 1 // middleware should invoke function call if the message is a function call message var mw = new FunctionCallMiddleware( - functionMap: new Dictionary>> { { "echo", EchoWrapper } }); + functionMap: new Dictionary>> { { "Echo", EchoWrapper } }); var testAgent = agent.RegisterMiddleware(mw); var functionCallMessage = new ToolCallMessage(functionCall.FunctionName, functionCall.FunctionArguments, from: "user"); @@ -96,30 +97,38 @@ public async Task FunctionCallMiddlewareTestAsync() reply.From.Should().Be("echo"); // test 2 + // middleware should work with AIFunction from M.E.A.I + var getWeatherTool = AIFunctionFactory.Create(this.Echo); + mw = new FunctionCallMiddleware([getWeatherTool]); + testAgent = agent.RegisterMiddleware(mw); + reply = await testAgent.SendAsync(functionCallMessage); + reply.GetContent()!.Should().Be("[FUNC] hello"); + + // test 3 // middleware should invoke function call if agent reply is a function call message mw = new FunctionCallMiddleware( functions: [this.EchoFunctionContract], - functionMap: new Dictionary>> { { "echo", EchoWrapper } }); + functionMap: new Dictionary>> { { "Echo", EchoWrapper } }); testAgent = functionCallAgent.RegisterMiddleware(mw); reply = await testAgent.SendAsync("hello"); reply.GetContent()!.Should().Be("[FUNC] hello"); reply.From.Should().Be("echo"); - // test 3 + // test 4 // middleware should return original reply if the reply from agent is not a function call message mw = new FunctionCallMiddleware( - functionMap: new Dictionary>> { { "echo", EchoWrapper } }); + functionMap: new Dictionary>> { { "Echo", EchoWrapper } }); testAgent = agent.RegisterMiddleware(mw); reply = await testAgent.SendAsync("hello"); reply.GetContent()!.Should().Be("hello"); reply.From.Should().Be("echo"); - // test 4 + // test 5 // middleware should return an error message if the function name is not available when invoking the function from previous agent reply mw = new FunctionCallMiddleware( - functionMap: new Dictionary>> { { "echo2", EchoWrapper } }); + functionMap: new Dictionary>> { { "Echo2", EchoWrapper } }); testAgent = agent.RegisterMiddleware(mw); reply = await testAgent.SendAsync(functionCallMessage); - reply.GetContent()!.Should().Be("Function echo is not available. Available functions are: echo2"); + reply.GetContent()!.Should().Be("Function Echo is not available. Available functions are: Echo2"); } }