diff --git a/README.md b/README.md index 3b26efd..679f05b 100644 --- a/README.md +++ b/README.md @@ -117,29 +117,91 @@ This ensures that suppressed parameters are handled gracefully without causing r The library allows you to use any CLR type or object as a plugin without requiring `KernelFunction` attribute. This enables you to create plugins from existing objects or types, making it easier to integrate with existing codebases. +### CLR Type Discovery and Method Selection + +When registering CLR types or objects as plugins, the library follows these rules: + +1. **Open Generic Methods**: Open generic methods are filtered out and not exposed as plugin functions. + +2. **TAP Method Normalization**: Methods following the Task-based Asynchronous Pattern (TAP) are normalized by: + - Removing any `CancellationToken` parameter + - Removing the `Async` suffix from method names + - Preferred selection order is: method with `CancellationToken` parameter, then method with `Async` suffix + +3. **Handling Overloaded Methods**: For methods with the same name but different parameters, you can use `FunctionMetadata.OverrideFunctionName` in your metadata provider to give each overload a unique name: + +```csharp +// Handling the Random.Next() overloads +public FunctionMetadata? GetFunctionMetadata(KernelPlugin plugin, KernelFunctionMetadata metadata) +{ + return plugin.Name switch + { + "RandomPlugin" => metadata.Name switch + { + "Next" when metadata.Parameters.Count == 0 => new FunctionMetadata(metadata.Name) + { + Description = "Returns a non-negative random integer." + }, + "Next" when metadata.Parameters.Count == 1 => new FunctionMetadata(metadata.Name) + { + OverrideFunctionName = "NextWithUpperBound", + Description = "Returns a random integer within a specified range.", + Parameters = [ + new ParameterMetadata("maxValue") + { + Description = "The exclusive upper bound of the random number returned." + } + ] + }, + "Next" when metadata.Parameters.Count == 2 => new FunctionMetadata(metadata.Name) + { + OverrideFunctionName = "NextWithRange", + Description = "Returns a random integer within a specified range.", + Parameters = [ + new ParameterMetadata("minValue") { Description = "The inclusive lower bound of the random number returned." }, + new ParameterMetadata("maxValue") { Description = "The exclusive upper bound of the random number returned." } + ] + }, + _ => null + }, + _ => null + }; +} +``` + **Sample Type and Metadata Provider:** ```csharp -public class ShortDate +public class DateTimeWrapper { public string ToShortDateString() { return DateTime.Now.ToShortDateString(); } + + public string ToLongDateString() + { + return DateTime.Now.ToLongDateString(); + } + + public string CurrentTime() + { + return DateTime.Now.ToString("T"); + } } public class CustomMetadataProvider : IPluginMetadataProvider { public PluginMetadata? GetPluginMetadata(KernelPlugin plugin) => - plugin.Name == "ShortDatePlugin" ? new PluginMetadata + plugin.Name == "DateTimeWrapper" ? new PluginMetadata { Description = "This plugin returns date and time information." } : null; public FunctionMetadata? GetFunctionMetadata(KernelPlugin plugin, KernelFunctionMetadata metadata) => - plugin.Name == "ShortDatePlugin" && metadata.Name == "ToShortDateString" ? new FunctionMetadata(metadata.Name) + plugin.Name == "DateTimeWrapper" && metadata.Name == "ToShortDateString" ? new FunctionMetadata(metadata.Name) { - Description = "Returns the date in short format." + Description = "Returns the current date in short format (MM/dd/yyyy)." } : null; } ``` @@ -149,7 +211,7 @@ public class CustomMetadataProvider : IPluginMetadataProvider **Usage Example:** ```csharp -kernelBuilder.Plugins.AddFromClrObjectWithMetadata(new ShortDate(), "ShortDatePlugin"); +kernelBuilder.Plugins.AddFromClrObjectWithMetadata(new DateTimeWrapper(), "DateTimeWrapper"); ``` ### CreateFromClrTypeWithMetadata: Using an existing type to create a plugin @@ -157,7 +219,16 @@ kernelBuilder.Plugins.AddFromClrObjectWithMetadata(new ShortDate(), "ShortDatePl **Usage Example:** ```csharp -kernelBuilder.Plugins.AddFromClrTypeWithMetadata("ShortDatePlugin"); +kernelBuilder.Plugins.AddFromClrTypeWithMetadata("DateTimeWrapper"); +``` + +### Example with Azure SDK Client + +**Usage Example:** + +```csharp +var qc = new QueueClient(new Uri("https://your-storage.queue.core.windows.net/your-queue"), new DefaultAzureCredential()); +kernelBuilder.Plugins.AddFromClrObjectWithMetadata(qc, "Queue"); ``` ## Samples @@ -168,7 +239,7 @@ Explore the [`samples`](https://github.com/lsiddiquee/SemanticPluginForge/sample - **[DefaultValue](https://github.com/lsiddiquee/SemanticPluginForge/samples/DefaultValue/)**: Demonstrates advanced parameter handling including suppression, default values, and context-aware metadata. Shows how to override parameter descriptions and ensure parameters are never resolved from context when suppressed. -- **[UseClrType](https://github.com/lsiddiquee/SemanticPluginForge/samples/UseClrType/)**: Shows how to use existing .NET classes as Semantic Kernel plugins without requiring `KernelFunction` attributes. Demonstrates both type-based and object-based registration approaches. +- **[UseClrType](https://github.com/lsiddiquee/SemanticPluginForge/samples/UseClrType/)**: Shows how to use existing .NET classes as Semantic Kernel plugins without requiring `KernelFunction` attributes. Demonstrates multiple plugin types including custom classes, standard .NET classes like `Random`, and direct Azure SDK integration through `QueueClient`. Features function name overriding for handling method overloads. - **[AzureAiSearchPlugin](https://github.com/lsiddiquee/SemanticPluginForge/samples/AzureAiSearchPlugin/)**: Comprehensive example showing how to create multiple instances of the same plugin class with different metadata configurations for various data sources. Uses mocked data for learning without external dependencies. diff --git a/samples/FunctionLogger.cs b/samples/FunctionLogger.cs index 80ef8f4..c21afd8 100644 --- a/samples/FunctionLogger.cs +++ b/samples/FunctionLogger.cs @@ -5,9 +5,11 @@ public class FunctionLogger : IAutoFunctionInvocationFilter public async Task OnAutoFunctionInvocationAsync(AutoFunctionInvocationContext context, Func next) { var arguments = context.Arguments?.Select(a => $"{a.Key}={a.Value}") ?? Enumerable.Empty(); - Console.WriteLine($"Plugin: {context.Function.PluginName}, Function: {context.Function.Name}({string.Join(',', arguments)})"); - + Console.WriteLine($"[Invoking] Plugin: {context.Function.PluginName}, Function: {context.Function.Name}({string.Join(',', arguments)})"); + await next(context); + + Console.WriteLine($"[Invoked] Plugin: {context.Function.PluginName}, Function: {context.Function.Name}({string.Join(',', arguments)}) with result: {context.Result}"); } public async Task OnFunctionInvocationAsync(FunctionInvocationContext context, Func next) diff --git a/samples/UseClrType/CustomMetadataProvider.cs b/samples/UseClrType/CustomMetadataProvider.cs index 6a82155..1a6a355 100644 --- a/samples/UseClrType/CustomMetadataProvider.cs +++ b/samples/UseClrType/CustomMetadataProvider.cs @@ -3,15 +3,109 @@ public class CustomMetadataProvider : IPluginMetadataProvider { - public PluginMetadata? GetPluginMetadata(KernelPlugin plugin) => - plugin.Name == "ShortDatePlugin" ? new PluginMetadata + public PluginMetadata? GetPluginMetadata(KernelPlugin plugin) { - Description = "This plugin returns date and time information." - } : null; + return plugin.Name switch + { + "DateTimeWrapper" => new PluginMetadata + { + Description = "This plugin returns date and time information." + }, + "RandomPlugin" => new PluginMetadata + { + Description = "This plugin generates random numbers and values." + }, + "Queue" => new PluginMetadata + { + Description = "This plugin interacts with Azure Storage Queues." + }, + _ => null, + }; + } - public FunctionMetadata? GetFunctionMetadata(KernelPlugin plugin, KernelFunctionMetadata metadata) => - plugin.Name == "ShortDatePlugin" && metadata.Name == "ToShortDateString" ? new FunctionMetadata(metadata.Name) + public FunctionMetadata? GetFunctionMetadata(KernelPlugin plugin, KernelFunctionMetadata metadata) { - Description = "Returns the date in short format." - } : null; + return plugin.Name switch + { + "DateTimeWrapper" => metadata.Name switch + { + "ToShortDateString" => new FunctionMetadata(metadata.Name) + { + Description = "Returns the current date in short format (MM/dd/yyyy)." + }, + "ToLongDateString" => new FunctionMetadata(metadata.Name) + { + Description = "Returns the current date in long format (day of week, month day, year)." + }, + "CurrentTime" => new FunctionMetadata(metadata.Name) + { + Description = "Returns the current time in HH:MM:SS format." + }, + "CurrentDateTime" => new FunctionMetadata(metadata.Name) + { + Description = "Returns the current date and time in long format." + }, + "GetDayOfWeek" => new FunctionMetadata(metadata.Name) + { + Description = "Returns the name of the day of the week for the current date." + }, + _ => null + }, + "RandomPlugin" => metadata.Name switch + { + "Next" when metadata.Parameters.Count == 0 => new FunctionMetadata(metadata.Name) + { + Description = "Returns a non-negative random integer." + }, + "Next" when metadata.Parameters.Count == 1 => new FunctionMetadata(metadata.Name) + { + OverrideFunctionName = "NextWithUpperBound", + Description = "Returns a random integer within a specified range.", + Parameters = [ + new ParameterMetadata("maxValue") { Description = "The exclusive upper bound of the random number returned." } + ] + }, + "Next" when metadata.Parameters.Count == 2 => new FunctionMetadata(metadata.Name) + { + OverrideFunctionName = "NextWithRange", + Description = "Returns a random integer within a specified range.", + Parameters = [ + new ParameterMetadata("minValue") { Description = "The inclusive lower bound of the random number returned." }, + new ParameterMetadata("maxValue") { Description = "The exclusive upper bound of the random number returned." } + ] + }, + _ => null + }, + "Queue" => metadata.Name switch + { + "ReceiveMessages" when metadata.Parameters.Count == 2 => new FunctionMetadata(metadata.Name) + { + Description = "Retrieves messages from the Azure Storage Queue.", + Parameters = new List + { + new ParameterMetadata("maxMessages") { Description = "The maximum number of messages to retrieve.", DefaultValue = "5", IsRequired = false }, + new ParameterMetadata("visibilityTimeout") { Description = "Specifies the new visibility timeout value, in seconds, relative to server time", DefaultValue = "60", IsRequired = false }, + }, + }, + "PeekMessages" when metadata.Parameters.Count == 1 => new FunctionMetadata(metadata.Name) + { + Description = "Peeks at messages in the Azure Storage Queue without removing them.", + Parameters = new List + { + new ParameterMetadata("maxMessages") { Description = "The maximum number of messages to peek.", DefaultValue = "5", IsRequired = false }, + }, + }, + "SendMessage" when metadata.Parameters.Count == 1 => new FunctionMetadata(metadata.Name) + { + Description = "Sends a message to the Azure Storage Queue.", + Parameters = new List + { + new ParameterMetadata("messageText") { Description = "The message to send." }, + }, + }, + _ => null + }, + _ => null, + }; + } } diff --git a/samples/UseClrType/DateTimeWrapper.cs b/samples/UseClrType/DateTimeWrapper.cs new file mode 100644 index 0000000..ce20159 --- /dev/null +++ b/samples/UseClrType/DateTimeWrapper.cs @@ -0,0 +1,27 @@ +public class DateTimeWrapper +{ + public string ToShortDateString() + { + return DateTime.Now.ToShortDateString(); + } + + public string ToLongDateString() + { + return DateTime.Now.ToLongDateString(); + } + + public string CurrentTime() + { + return DateTime.Now.ToString("T"); + } + + public string CurrentDateTime() + { + return DateTime.Now.ToString("F"); + } + + public string GetDayOfWeek() + { + return DateTime.Now.DayOfWeek.ToString(); + } +} diff --git a/samples/UseClrType/Program.cs b/samples/UseClrType/Program.cs index 95d970f..dc4c563 100644 --- a/samples/UseClrType/Program.cs +++ b/samples/UseClrType/Program.cs @@ -5,28 +5,54 @@ using SemanticPluginForge.Core; using Microsoft.Extensions.Configuration; using System.Reflection; +using Azure.Storage.Queues; +using Azure.Identity; var builder = Host.CreateApplicationBuilder(args); builder.Configuration.AddUserSecrets(Assembly.GetExecutingAssembly()); var kernelBuilder = builder.Services.AddKernel(); kernelBuilder.AddAzureOpenAIChatCompletion( - builder.Configuration["AzureOpenAI:ChatDeploymentName"], - builder.Configuration["AzureOpenAI:Endpoint"], - builder.Configuration["AzureOpenAI:ApiKey"] + builder.Configuration["AzureOpenAI:ChatDeploymentName"]!, + builder.Configuration["AzureOpenAI:Endpoint"]!, + builder.Configuration["AzureOpenAI:ApiKey"]! ); builder.Services.AddSingleton(); -// var targetObject = new ShortDate(); -// kernelBuilder.Plugins.AddFromClrObjectWithMetadata(targetObject, "ShortDatePlugin"); -kernelBuilder.Plugins.AddFromClrTypeWithMetadata("ShortDatePlugin"); +// Add DateTimeWrapper for date/time operations +kernelBuilder.Plugins.AddFromClrTypeWithMetadata("DateTimeWrapper"); + +// Add Random for random number generation +kernelBuilder.Plugins.AddFromClrTypeWithMetadata("RandomPlugin"); + +// Add queue client for Azure Storage Queue operations +var qc = new QueueClient(new Uri(builder.Configuration["AzureQueueUri"]!), new DefaultAzureCredential()); +kernelBuilder.Plugins.AddFromClrObjectWithMetadata(qc, "Queue"); var host = builder.Build(); var kernel = host.Services.GetRequiredService(); +kernel.AutoFunctionInvocationFilters.Add(new FunctionLogger()); -var result = await kernel.InvokePromptAsync("What is the date in short format?", arguments: new KernelArguments(new PromptExecutionSettings +while (true) { - FunctionChoiceBehavior = FunctionChoiceBehavior.Auto() -})); + Console.Write("User: "); + var input = Console.ReadLine()?.Trim(); + if (string.IsNullOrWhiteSpace(input) || input.Equals("exit", StringComparison.OrdinalIgnoreCase) || input.Equals("quit", StringComparison.OrdinalIgnoreCase)) + { + Console.WriteLine("Exiting..."); + break; + } -Console.WriteLine(result); + try + { + var response = await kernel.InvokePromptAsync(input, arguments: new KernelArguments(new PromptExecutionSettings + { + FunctionChoiceBehavior = FunctionChoiceBehavior.Auto() + })); + Console.WriteLine($"Bot: {response}"); + } + catch (Exception ex) + { + Console.WriteLine($"Error: {ex.Message}"); + } +} \ No newline at end of file diff --git a/samples/UseClrType/README.md b/samples/UseClrType/README.md index 574b742..f8e2325 100644 --- a/samples/UseClrType/README.md +++ b/samples/UseClrType/README.md @@ -1,42 +1,72 @@ # Use CLR Type Plugin Sample -This sample demonstrates how to create Semantic Kernel plugins from **custom CLR (Common Language Runtime) types** using the SemanticPluginForge framework. It shows how to register both type-based and object-based plugins with enhanced metadata providers. +This sample demonstrates how to create Semantic Kernel plugins from **custom CLR (Common Language Runtime) types** using the SemanticPluginForge framework. It shows how to register both type-based and object-based plugins with enhanced metadata providers, and how to use `FunctionMetadata.OverrideFunctionName` to handle overloaded methods. -The sample features a simple **ShortDate** class that provides date formatting functionality, demonstrating how any .NET class can be transformed into a Semantic Kernel plugin with custom metadata. +The sample features: -> **Note**: This sample uses a simple date utility class to demonstrate CLR type plugin concepts. The focus is on showing how to register custom .NET types as plugins and enhance them with metadata providers. +- A **DateTimeWrapper** class that provides date and time functionality +- The standard .NET **Random** class for random number generation with overloaded methods +- Direct integration with the **Azure Storage Queue Client** + +Together, these demonstrate how any .NET class can be transformed into a Semantic Kernel plugin with custom metadata. + +The sample also showcases: + +- How to use `FunctionMetadata.OverrideFunctionName` to handle overloaded methods like `Random.Next()` +- How to enhance CLR types with detailed metadata for improved LLM understanding +- Techniques for integrating Azure SDK clients directly as AI plugins + +> **Note**: This sample demonstrates CLR type plugin concepts with both custom and standard .NET classes, as well as Azure service clients. The focus is on showing how to register custom .NET types as plugins and enhance them with metadata providers. ## Key Features - **CLR Type Registration**: Demonstrates registering .NET types directly as Semantic Kernel plugins - **Object Instance Registration**: Shows alternative registration using object instances - **Custom Metadata Enhancement**: Illustrates how metadata providers can enhance basic CLR type functionality -- **Simple Plugin Design**: Uses a focused, single-purpose class to demonstrate core concepts +- **Multiple Plugin Types**: Demonstrates custom classes, standard .NET classes, and Azure SDK clients as plugins +- **Direct SDK Integration**: Shows how to expose Azure SDK functionality directly to the LLM +- **Function Name Overrides**: Uses custom function names for methods with same name but different parameters - **Type-Safe Integration**: Leverages .NET's type system for plugin registration ## Architecture -### ShortDate Class +### DateTimeWrapper Class -- **Single Responsibility**: Provides date formatting functionality through `ToShortDateString()` method +- **Multiple Date/Time Methods**: Provides date and time functionality through various methods - **Standard .NET Class**: Regular C# class without special plugin attributes -- **Simple Interface**: Single public method that returns formatted date string +- **Simple Interface**: Methods return formatted date and time strings - **No Dependencies**: Standalone class that demonstrates basic CLR type plugin concepts +### Random Class (Standard .NET) + +- **Built-in .NET Class**: Demonstrates using framework classes as plugins +- **Method Overloads**: Shows handling of multiple methods with the same name +- **Function Name Overrides**: Uses custom names to disambiguate overloaded methods + +### Azure Storage Queue Client + +- **Direct SDK Integration**: Uses the Azure SDK QueueClient without a wrapper +- **Real-World Application**: Demonstrates practical cloud service integration +- **Authentication Support**: Uses DefaultAzureCredential for secure authentication + ### Custom Metadata Provider -The `CustomMetadataProvider` enhances the basic CLR type by: +The `CustomMetadataProvider` enhances the CLR types by: -- **Plugin Description**: Provides meaningful description for the date/time functionality -- **Function Enhancement**: Adds descriptive metadata for the date formatting function -- **Semantic Context**: Helps the LLM understand the plugin's purpose and capabilities +- **Plugin Descriptions**: Provides meaningful descriptions for each plugin type +- **Function Enhancement**: Adds descriptive metadata for each function +- **Parameter Documentation**: Documents parameters for complex methods +- **Function Name Overrides**: Provides friendly names for overloaded methods +- **Default Values**: Sets default parameter values where appropriate +- **Semantic Context**: Helps the LLM understand each plugin's purpose and capabilities ### Registration Options -The sample demonstrates two registration approaches: +The sample demonstrates multiple registration approaches: -1. **Type-Based**: `AddFromClrTypeWithMetadata("ShortDatePlugin")` -2. **Object-Based**: `AddFromClrObjectWithMetadata(targetObject, "ShortDatePlugin")` (commented example) +1. **Type-Based for Custom Class**: `AddFromClrTypeWithMetadata("DateTimeWrapper")` +2. **Type-Based for Standard .NET Class**: `AddFromClrTypeWithMetadata("RandomPlugin")` +3. **Object-Based for Azure SDK Client**: `AddFromClrObjectWithMetadata(qc, "Queue")` ## Setup Instructions @@ -47,7 +77,7 @@ The sample demonstrates two registration approaches: ### Configuration -Navigate to the project folder and set up user secrets for Azure OpenAI: +Navigate to the project folder and set up user secrets for Azure OpenAI and Azure Storage Queue: ```console cd samples\UseClrType @@ -55,8 +85,11 @@ dotnet user-secrets init dotnet user-secrets set "AzureOpenAI:ChatDeploymentName" "YOUR_DEPLOYMENT_NAME" dotnet user-secrets set "AzureOpenAI:Endpoint" "https://YOUR_ENDPOINT.openai.azure.com/" dotnet user-secrets set "AzureOpenAI:ApiKey" "YOUR_API_KEY" +dotnet user-secrets set "AzureQueueUri" "https://.queue.core.windows.net/" ``` +The sample uses Azure Identity's DefaultAzureCredential for authentication to Azure Storage. Make sure you are logged in with the Azure CLI or have appropriate credentials configured. + ### Run the Sample ```console @@ -77,33 +110,72 @@ When registering a CLR type as a plugin: ### Plugin Registration Process ```csharp -// Type-based registration (recommended) -kernelBuilder.Plugins.AddFromClrTypeWithMetadata("ShortDatePlugin"); +// Type-based registration for custom classes +kernelBuilder.Plugins.AddFromClrTypeWithMetadata("DateTimeWrapper"); + +// Type-based registration for standard .NET classes +kernelBuilder.Plugins.AddFromClrTypeWithMetadata("RandomPlugin"); -// Alternative object-based registration -// var targetObject = new ShortDate(); -// kernelBuilder.Plugins.AddFromClrObjectWithMetadata(targetObject, "ShortDatePlugin"); +// Object-based registration for Azure SDK clients +var qc = new QueueClient(new Uri(builder.Configuration["AzureQueueUri"]!), new DefaultAzureCredential()); +kernelBuilder.Plugins.AddFromClrObjectWithMetadata(qc, "Queue"); ``` ### Metadata Enhancement -The custom metadata provider adds semantic meaning: +The custom metadata provider adds semantic meaning to each plugin: ```csharp -plugin.Name == "ShortDatePlugin" && metadata.Name == "ToShortDateString" ? - new FunctionMetadata(metadata.Name) +public FunctionMetadata? GetFunctionMetadata(KernelPlugin plugin, KernelFunctionMetadata metadata) +{ + return plugin.Name switch { - Description = "Returns the date in short format." - } : null + "DateTimeWrapper" => metadata.Name switch + { + "ToShortDateString" => new FunctionMetadata(metadata.Name) + { + Description = "Returns the current date in short format (MM/dd/yyyy)." + }, + // Other DateTimeWrapper methods... + }, + "RandomPlugin" => metadata.Name switch + { + "Next" when metadata.Parameters.Count == 1 => new FunctionMetadata(metadata.Name) + { + OverrideFunctionName = "NextWithUpperBound", + Description = "Returns a random integer within a specified range.", + Parameters = [ + new ParameterMetadata("maxValue") + { + Description = "The exclusive upper bound of the random number returned." + } + ] + }, + // Other Random methods... + }, + "Queue" => metadata.Name switch + { + "SendMessage" => new FunctionMetadata(metadata.Name) + { + Description = "Sends a message to the Azure Storage Queue.", + // Parameter definitions... + }, + // Other Queue methods... + }, + _ => null + }; +} ``` ### LLM Integration The sample demonstrates how the LLM can: -- Understand the plugin's purpose through enhanced descriptions -- Automatically invoke the date formatting function when requested -- Integrate the plugin response into natural language conversations +- Understand multiple plugins' purposes through enhanced descriptions +- Automatically invoke the appropriate function based on user requests +- Handle method overloads through function name overrides +- Work with Azure services directly through SDK integration +- Integrate plugin responses into natural language conversations ## Key Concepts Demonstrated @@ -112,52 +184,87 @@ The sample demonstrates how the LLM can: Any .NET class can become a plugin: ```csharp -public class ShortDate +public class DateTimeWrapper { public string ToShortDateString() { return DateTime.Now.ToShortDateString(); } + + public string ToLongDateString() + { + return DateTime.Now.ToLongDateString(); + } + + public string CurrentTime() + { + return DateTime.Now.ToString("T"); + } + + // Additional methods... } ``` ### 2. Metadata Provider Integration -Enhance basic CLR types with semantic information: +Enhance CLR types with semantic information: ```csharp -public FunctionMetadata? GetFunctionMetadata(KernelPlugin plugin, KernelFunctionMetadata metadata) => - plugin.Name == "ShortDatePlugin" && metadata.Name == "ToShortDateString" ? - new FunctionMetadata(metadata.Name) +public class CustomMetadataProvider : IPluginMetadataProvider +{ + public PluginMetadata? GetPluginMetadata(KernelPlugin plugin) + { + return plugin.Name switch { - Description = "Returns the date in short format." - } : null; + "DateTimeWrapper" => new PluginMetadata + { + Description = "This plugin returns date and time information." + }, + "RandomPlugin" => new PluginMetadata + { + Description = "This plugin generates random numbers and values." + }, + "Queue" => new PluginMetadata + { + Description = "This plugin interacts with Azure Storage Queues." + }, + _ => null, + }; + } + + // Function metadata implementation... +} ``` ### 3. Flexible Registration Options -- **Type-based**: Register the type, let the framework create instances -- **Object-based**: Register a specific object instance +- **Type-based for custom classes**: Register your own classes, letting the framework create instances +- **Type-based for standard .NET classes**: Register built-in .NET classes like Random, DateTime, etc. +- **Object-based for service clients**: Register Azure SDK clients and other service objects +- **Method overload handling**: Use function name overrides for method disambiguation ## Extending the Sample You can extend this sample by: -1. **Complex CLR Types**: Register classes with multiple methods and properties -2. **Parameter Handling**: Add classes with method parameters and custom types -3. **State Management**: Use object-based registration for stateful plugins -4. **Dependency Injection**: Integrate with .NET DI container for complex objects -5. **Generic Types**: Explore registration of generic classes and methods -6. **Property Exposure**: Extend to expose properties as plugin functions +1. **Additional Azure Services**: Register other Azure SDK clients like Blob Storage or CosmosDB +2. **Complex CLR Types**: Register classes with more complex methods and properties +3. **Authentication Handlers**: Explore different authentication mechanisms for Azure services +4. **State Management**: Use object-based registration for stateful plugins +5. **Dependency Injection**: Integrate with .NET DI container for complex objects +6. **Generic Types**: Explore registration of generic classes and methods +7. **Property Exposure**: Extend to expose properties as plugin functions ## Real-World Applications This pattern is useful for: +- **Azure SDK Integration**: Exposing Azure services directly to AI models - **Existing Code Integration**: Converting existing .NET libraries into AI plugins - **Utility Functions**: Exposing common utility classes as AI-accessible tools - **Business Logic**: Making domain-specific business logic available to AI models -- **Legacy System Integration**: Bridging older .NET code with modern AI applications +- **Third-Party SDK Integration**: Bridging external libraries with AI applications +- **Legacy System Integration**: Connecting older .NET code with modern AI applications - **Rapid Prototyping**: Quick conversion of existing classes for AI experimentation ## Comparison with Other Registration Methods @@ -167,26 +274,30 @@ This pattern is useful for: | `AddFromType` | Standard plugin classes | Simple, attribute-driven | Requires plugin-specific attributes | | `AddFromClrType` | Existing .NET classes | No attribute requirements | Limited metadata | | `AddFromClrTypeWithMetadata` | Enhanced existing classes | Rich metadata, flexible | Requires metadata provider | -| `AddFromObject` | Stateful instances | Instance control | Manual lifecycle management | +| `AddFromObject` | Stateful instances, SDK clients | Instance control, pre-configured clients | Manual lifecycle management | +| `AddFromClrObjectWithMetadata` | Enhanced SDK clients | Rich metadata, pre-configured instances | Requires metadata provider and manual lifecycle | ## Best Practices When using CLR type registration: -1. **Keep It Simple**: Start with simple, focused classes -2. **Clear Naming**: Use descriptive method names that indicate purpose -3. **Return Value Types**: Use appropriate return types that LLMs can work with -4. **Metadata Enhancement**: Always provide metadata providers for better LLM understanding -5. **Error Handling**: Include proper error handling in your methods -6. **Thread Safety**: Consider thread safety for shared instances +1. **Clear Naming**: Use descriptive plugin and function names that indicate purpose +2. **Meaningful Descriptions**: Provide detailed descriptions for plugins, functions, and parameters +3. **Method Disambiguation**: Use function name overrides for overloaded methods +4. **Parameter Documentation**: Document all parameters, especially for complex methods +5. **Default Values**: Provide default values for optional parameters +6. **Authentication Handling**: Use appropriate authentication for Azure and other services +7. **Error Handling**: Include proper error handling in your methods +8. **Thread Safety**: Consider thread safety for shared instances ## Learning Objectives This sample demonstrates: - How to register existing .NET types as Semantic Kernel plugins +- How to integrate Azure SDK clients directly as plugins - The difference between type-based and object-based plugin registration -- How metadata providers enhance basic CLR type functionality -- Integration patterns for existing codebases with AI applications +- How to handle method overloads using function name overrides +- How metadata providers enhance CLR type functionality +- Integration patterns for cloud services with AI applications - Best practices for converting utility classes into AI-accessible tools -- The flexibility of the SemanticPluginForge framework for various plugin types diff --git a/samples/UseClrType/ShortDate.cs b/samples/UseClrType/ShortDate.cs deleted file mode 100644 index 5b35daa..0000000 --- a/samples/UseClrType/ShortDate.cs +++ /dev/null @@ -1,7 +0,0 @@ -public class ShortDate -{ - public string ToShortDateString() - { - return DateTime.Now.ToShortDateString(); - } -} diff --git a/samples/UseClrType/UseClrType.csproj b/samples/UseClrType/UseClrType.csproj index 297e430..2fea306 100644 --- a/samples/UseClrType/UseClrType.csproj +++ b/samples/UseClrType/UseClrType.csproj @@ -13,6 +13,12 @@ + + + + + + diff --git a/src/SemanticPluginForge.Core/IFunctionBuilder.cs b/src/SemanticPluginForge.Core/IFunctionBuilder.cs new file mode 100644 index 0000000..bd0e028 --- /dev/null +++ b/src/SemanticPluginForge.Core/IFunctionBuilder.cs @@ -0,0 +1,17 @@ +using Microsoft.SemanticKernel; + +namespace SemanticPluginForge.Core; + +/// +/// Interface for a function builder. +/// +public interface IFunctionBuilder +{ + /// + /// Patch a KernelFunction object. + /// + /// The plugin which contains the function. + /// The function which should be patched. + /// Returns null if the function should be suppressed, if the function should not be patched otherwise the patched function. + KernelFunction? PatchKernelFunctionWithMetadata(KernelPlugin plugin, KernelFunction function); +} \ No newline at end of file diff --git a/src/SemanticPluginForge.Core/IPluginBuilder.cs b/src/SemanticPluginForge.Core/IPluginBuilder.cs index 67e2255..ee53d94 100644 --- a/src/SemanticPluginForge.Core/IPluginBuilder.cs +++ b/src/SemanticPluginForge.Core/IPluginBuilder.cs @@ -8,9 +8,9 @@ namespace SemanticPluginForge.Core; public interface IPluginBuilder { /// - /// Patch a KernelPlugin object with external metadata. + /// Patch a KernelPlugin object. /// - /// The plugin which should be patched with external metadata. + /// The plugin which should be patched. /// Returns the patched plugin instance. KernelPlugin PatchKernelPluginWithMetadata(KernelPlugin plugin); -} \ No newline at end of file +} diff --git a/src/SemanticPluginForge.Core/KernelPluginForge.cs b/src/SemanticPluginForge.Core/KernelPluginForge.cs index 48fc95d..197e006 100644 --- a/src/SemanticPluginForge.Core/KernelPluginForge.cs +++ b/src/SemanticPluginForge.Core/KernelPluginForge.cs @@ -52,8 +52,6 @@ public static KernelPlugin CreateFromClrObjectWithMetadata(object target, IServi var loggerFactory = serviceProvider.GetService(); var logger = loggerFactory?.CreateLogger(target.GetType()); - MethodInfo[] methods = target.GetType().GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static); - var metadataProvider = serviceProvider.GetRequiredService(); var temporaryPlugin = KernelPluginFactory.CreateFromFunctions(pluginName); @@ -63,16 +61,23 @@ public static KernelPlugin CreateFromClrObjectWithMetadata(object target, IServi throw new ArgumentException($"The plugin with name '{pluginName}' doesn't have any metadata defined."); } + var methods = target.GetType().GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static).ToList(); + + // Filter out methods with open generic parameters. + FilterMethodsWithOpenGeneric(methods, logger); + // Filter out methods with TAP. + FilterMethodsWithAsyncOverloads(methods, logger); + // Include only functions that have the metadata defined. var functions = new List(); + var pluginBuilder = new PluginBuilder(metadataProvider); foreach (MethodInfo method in methods) { - // TODO: Deal with overloads var kernelFunction = KernelFunctionFactory.CreateFromMethod(method, target, loggerFactory: loggerFactory); - var functionMetadata = metadataProvider.GetFunctionMetadata(temporaryPlugin, kernelFunction.Metadata); - if (functionMetadata is not null) + var patchedFunction = pluginBuilder.PatchKernelFunctionWithMetadata(temporaryPlugin, kernelFunction); + if (patchedFunction is not null && patchedFunction != kernelFunction) { - functions.Add(kernelFunction); + functions.Add(patchedFunction); } } if (functions.Count == 0) @@ -82,11 +87,10 @@ public static KernelPlugin CreateFromClrObjectWithMetadata(object target, IServi if (logger is not null && logger.IsEnabled(LogLevel.Trace)) { - logger.LogTrace("Created plugin {PluginName} with {IncludedFunctions} [KernelFunction] methods out of {TotalMethods} methods found.", pluginName, functions.Count, methods.Length); + logger.LogTrace("Created plugin {PluginName} with {IncludedFunctions} [KernelFunction] methods out of {TotalMethods} methods found.", pluginName, functions.Count, methods.Count); } - var kernelPlugin = KernelPluginFactory.CreateFromFunctions(pluginName, pluginMetadata?.Description, functions); - return PatchKernelPluginWithMetadata(kernelPlugin, metadataProvider); + return KernelPluginFactory.CreateFromFunctions(pluginName, pluginMetadata?.Description, functions); } /// @@ -159,11 +163,101 @@ public static KernelPlugin CreateFromFunctionsWithMetadata(IServiceProvider serv /// /// A Microsoft.SemanticKernel.KernelPlugin containing the original functions wrapped. /// Implementation of IPluginMetadataProvider which can be used to update the plugin. - /// A Microsoft.SemanticKernel.KernelPlugin containing Microsoft.SemanticKernel.KernelFunctions + /// A Microsoft.SemanticKernel.KernelPlugin containing Microsoft.SemanticKernel.KernelFunctions /// for all relevant members of target. - public static KernelPlugin PatchKernelPluginWithMetadata(KernelPlugin plugin, IPluginMetadataProvider metadataProvider) + public static KernelPlugin PatchKernelPluginWithMetadata(KernelPlugin plugin, IPluginMetadataProvider metadataProvider) { var builder = new PluginBuilder(metadataProvider); return builder.PatchKernelPluginWithMetadata(plugin); } + + /// + /// Filters out methods with open generic parameters. + /// + /// The list of methods to filter. + /// The logger. + private static void FilterMethodsWithOpenGeneric(List methods, ILogger? logger) + { + // First, filter out methods with open generic parameters + var methodsToRemove = methods.Where(m => m.ContainsGenericParameters || m.IsGenericMethodDefinition).ToList(); + foreach (var method in methodsToRemove) + { + logger?.LogInformation("Suppressing generic method {MethodName} with generic parameters", method.Name); + methods.Remove(method); + } + } + + /// + /// Filters out methods that have overloads with only difference being a CancellationToken parameter and/or Async suffix. + /// + /// The list of methods to filter. + /// The logger. + private static void FilterMethodsWithAsyncOverloads(List methods, ILogger? logger) + { + // Create a normalized list of methods where we remove the CancellationToken parameter and Async suffix. + var methodMetaList = methods.Select(m => new MethodMeta(m)).ToArray(); + + for (int i = 0; i < methodMetaList.Length; i++) + { + for (int j = i + 1; j < methodMetaList.Length; j++) + { + if (methodMetaList[i].Name == methodMetaList[j].Name && + methodMetaList[i].Parameters.Length == methodMetaList[j].Parameters.Length) + { + int k = 0; + for (; k < methodMetaList[i].Parameters.Length; k++) + { + if (methodMetaList[i].Parameters[k].Name != methodMetaList[j].Parameters[k].Name || + methodMetaList[i].Parameters[k].ParameterType != methodMetaList[j].Parameters[k].ParameterType) + { + // If the parameters are not the same, we can skip this pair. + break; + } + } + + if (k == methodMetaList[i].Parameters.Length) + { + // If we reached here, it means the methods are identical except for the async suffix and/or cancellation token parameter. + // Order of choice, cancellation token parameter then Async suffix. + int removeIndex = j; + if (methodMetaList[i].HasCancellationTokenParameter != methodMetaList[j].HasCancellationTokenParameter) + { + removeIndex = methodMetaList[i].HasCancellationTokenParameter ? j : i; + } + else if (methodMetaList[i].HasAsyncSuffix != methodMetaList[j].HasAsyncSuffix) + { + removeIndex = methodMetaList[i].HasAsyncSuffix ? j : i; + } + + logger?.LogInformation("Suppressing method {MethodName} with parameters: {Parameters}", + methodMetaList[removeIndex].MethodInfo.Name, + string.Join(", ", methodMetaList[removeIndex].MethodInfo.GetParameters().Select(p => p.Name))); + methods.Remove(methodMetaList[removeIndex].MethodInfo); + } + } + } + } + } + + internal class MethodMeta + { + internal string Name { get; private set; } + + internal ParameterInfo[] Parameters { get; private set; } + + internal bool HasAsyncSuffix { get; private set; } + + internal bool HasCancellationTokenParameter { get; private set; } + + internal MethodInfo MethodInfo { get; private set; } + + internal MethodMeta(MethodInfo method) + { + HasAsyncSuffix = method.Name.EndsWith("Async"); + Name = HasAsyncSuffix ? method.Name.Substring(0, method.Name.Length - "Async".Length) : method.Name; + Parameters = [.. method.GetParameters().Where(p => p.ParameterType != typeof(CancellationToken))]; + HasCancellationTokenParameter = method.GetParameters().Any(p => p.ParameterType == typeof(CancellationToken)); + MethodInfo = method; + } + } } \ No newline at end of file diff --git a/src/SemanticPluginForge.Core/PluginBuilder.cs b/src/SemanticPluginForge.Core/PluginBuilder.cs index eb426e8..0bb1e72 100644 --- a/src/SemanticPluginForge.Core/PluginBuilder.cs +++ b/src/SemanticPluginForge.Core/PluginBuilder.cs @@ -6,7 +6,7 @@ namespace SemanticPluginForge.Core; /// Class for building a plugin with metadata. /// /// The metadata provider to retrieve external metadata to use to update the plugin. -public class PluginBuilder(IPluginMetadataProvider metadataProvider) : IPluginBuilder +public class PluginBuilder(IPluginMetadataProvider metadataProvider) : IPluginBuilder, IFunctionBuilder { private readonly IPluginMetadataProvider _metadataProvider = metadataProvider; @@ -18,37 +18,11 @@ public KernelPlugin PatchKernelPluginWithMetadata(KernelPlugin plugin) var functions = new List(); foreach (var function in plugin) { - var functionMeta = _metadataProvider.GetFunctionMetadata(plugin, function.Metadata); - if (functionMeta != null) + var patchedFunction = PatchKernelFunctionWithMetadata(plugin, function); + pluginAltered |= patchedFunction != function; // If the patchedFunction is not the same instance as the original function, it means it has been altered. + if (patchedFunction is not null) { - pluginAltered = true; - - if (functionMeta.Suppress) - { - // This function should be suppressed from the plugin. - continue; - } - - var method = BuildMethodInvocation(function, functionMeta); - - List parameters = BuildKernelParameters(function, functionMeta); - - var options = new KernelFunctionFromMethodOptions - { - FunctionName = functionMeta.OverrideFunctionName ?? functionMeta.Name, - Description = functionMeta.Description ?? function.Metadata.Description, - Parameters = parameters, - ReturnParameter = functionMeta.ReturnParameter == null ? function.Metadata.ReturnParameter : new KernelReturnParameterMetadata(function.Metadata.ReturnParameter) - { - Description = functionMeta.ReturnParameter.Description ?? function.Metadata.Description, - } - }; - - functions.Add(KernelFunctionFactory.CreateFromMethod(method, options)); - } - else - { - functions.Add(function); + functions.Add(patchedFunction); } } @@ -62,6 +36,39 @@ public KernelPlugin PatchKernelPluginWithMetadata(KernelPlugin plugin) return plugin; } + /// > + public KernelFunction? PatchKernelFunctionWithMetadata(KernelPlugin plugin, KernelFunction function) + { + var functionMeta = _metadataProvider.GetFunctionMetadata(plugin, function.Metadata); + if (functionMeta != null) + { + if (functionMeta.Suppress) + { + // This function should be suppressed from the plugin. + return null; + } + + var method = BuildMethodInvocation(function, functionMeta); + + List parameters = BuildKernelParameters(function, functionMeta); + + var options = new KernelFunctionFromMethodOptions + { + FunctionName = functionMeta.OverrideFunctionName ?? functionMeta.Name, + Description = functionMeta.Description ?? function.Metadata.Description, + Parameters = parameters, + ReturnParameter = functionMeta.ReturnParameter == null ? function.Metadata.ReturnParameter : new KernelReturnParameterMetadata(function.Metadata.ReturnParameter) + { + Description = functionMeta.ReturnParameter.Description ?? function.Metadata.Description, + } + }; + + return KernelFunctionFactory.CreateFromMethod(method, options); + } + + return function; + } + private static List BuildKernelParameters(KernelFunction function, FunctionMetadata functionMeta) { var parameters = new List(); diff --git a/src/SemanticPluginForge.UnitTests/AsyncMethodsPlugin.cs b/src/SemanticPluginForge.UnitTests/AsyncMethodsPlugin.cs new file mode 100644 index 0000000..e85e2ff --- /dev/null +++ b/src/SemanticPluginForge.UnitTests/AsyncMethodsPlugin.cs @@ -0,0 +1,57 @@ +using Microsoft.SemanticKernel; +using SemanticPluginForge.Core; + +namespace SemanticPluginForge.UnitTests +{ + public partial class KernelPluginForgeTests + { + private class AsyncMethodsPlugin + { + // Normal method + public string GetData() + { + return "Some data"; + } + + // Normal method with CancellationToken + public string GetData(CancellationToken cancellationToken = default) + { + return "Some data"; + } + + // Async version of the same method + public Task GetDataAsync() + { + return Task.FromResult("Some data"); + } + + // Async version of the same method with CancellationToken + public Task GetDataAsync(CancellationToken cancellationToken = default) + { + return Task.FromResult("Some data"); + } + } + + private class AsyncMethodsMetadataProvider : IPluginMetadataProvider + { + public PluginMetadata? GetPluginMetadata(KernelPlugin plugin) => + plugin.Name == "AsyncMethodsPlugin" ? new PluginMetadata + { + Description = "This plugin tests async method filtering." + } : null; + + public FunctionMetadata? GetFunctionMetadata(KernelPlugin plugin, KernelFunctionMetadata metadata) + { + if (plugin.Name == "AsyncMethodsPlugin" && metadata.Name == "GetData") + { + return new FunctionMetadata(metadata.Name) + { + Description = "Function GetData for testing async filtering." + }; + } + + return null; + } + } + } +} \ No newline at end of file diff --git a/src/SemanticPluginForge.UnitTests/GenericMethodsPlugin.cs b/src/SemanticPluginForge.UnitTests/GenericMethodsPlugin.cs new file mode 100644 index 0000000..9e1c52b --- /dev/null +++ b/src/SemanticPluginForge.UnitTests/GenericMethodsPlugin.cs @@ -0,0 +1,83 @@ +using Microsoft.SemanticKernel; +using SemanticPluginForge.Core; + +namespace SemanticPluginForge.UnitTests +{ + public partial class KernelPluginForgeTests + { + private class GenericMethodsPlugin + { + // Closed generic method + public T? ClosedGenericMethod() + { + return default; + } + + // Open generic method with one type parameter + public string OpenGenericMethodWithParameter(TParam param) + { + return $"Parameter type: {typeof(TParam).Name}, Value: {param}"; + } + + // Open generic method with multiple type parameters + public string OpenGenericMethodWithMultipleParameters(TParam1 param1, TParam2 param2) + { + return $"Parameter types: {typeof(TParam1).Name}, {typeof(TParam2).Name}, Values: {param1}, {param2}"; + } + + // Open generic method with generic return type + public TReturn? OpenGenericMethodWithReturnType() + { + return default; + } + + // Open generic method with generic return type and parameters + public TReturn? OpenGenericMethodWithReturnTypeAndParameters(TParam param) + { + return default; + } + } + + private class GenericMethodsPluginMetadataProvider : IPluginMetadataProvider + { + public PluginMetadata? GetPluginMetadata(KernelPlugin plugin) => + plugin.Name == "GenericMethodsPlugin" ? new PluginMetadata + { + Description = "This plugin tests generic method filtering." + } : null; + + public FunctionMetadata? GetFunctionMetadata(KernelPlugin plugin, KernelFunctionMetadata metadata) + { + if (plugin.Name != "GenericMethodsPlugin") + { + return null; + } + + return metadata.Name switch + { + "ClosedGenericMethod" => new FunctionMetadata(metadata.Name) + { + Description = "Closed generic method that returns a default value of type T." + }, + "OpenGenericMethodWithParameter" => new FunctionMetadata(metadata.Name) + { + Description = "Open generic method with one type parameter." + }, + "OpenGenericMethodWithMultipleParameters" => new FunctionMetadata(metadata.Name) + { + Description = "Open generic method with multiple type parameters." + }, + "OpenGenericMethodWithReturnType" => new FunctionMetadata(metadata.Name) + { + Description = "Open generic method with a generic return type." + }, + "OpenGenericMethodWithReturnTypeAndParameters" => new FunctionMetadata(metadata.Name) + { + Description = "Open generic method with a generic return type and parameters." + }, + _ => null, + }; + } + } + } +} \ No newline at end of file diff --git a/src/SemanticPluginForge.UnitTests/KernelPluginForgeTests.cs b/src/SemanticPluginForge.UnitTests/KernelPluginForgeTests.cs index 9de87fd..8512bd6 100644 --- a/src/SemanticPluginForge.UnitTests/KernelPluginForgeTests.cs +++ b/src/SemanticPluginForge.UnitTests/KernelPluginForgeTests.cs @@ -1,11 +1,10 @@ using FluentAssertions; using Microsoft.Extensions.DependencyInjection; -using Microsoft.SemanticKernel; using SemanticPluginForge.Core; namespace SemanticPluginForge.UnitTests { - public class KernelPluginForgeTests + public partial class KernelPluginForgeTests { [Fact] public void CreateFromClrObjectWithMetadata_ShouldReturnValidPlugin() @@ -88,27 +87,73 @@ public void CreateFromClrObjectWithMetadata_ThrowsException_WhenNoFunctionMetada act.Should().Throw(); } - private class StubPluginWithoutAttribute + [Fact] + public void CreateFromClrObjectWithMetadata_FiltersDuplicateMethods() { - public string ToShortDateString() - { - return DateTime.Now.ToShortDateString(); - } + // Arrange + var serviceProvider = new ServiceCollection() + .AddSingleton() + .BuildServiceProvider(); + + var target = new AsyncMethodsPlugin(); + var pluginName = "AsyncMethodsPlugin"; + + // Act + var plugin = KernelPluginForge.CreateFromClrObjectWithMetadata(target, serviceProvider, pluginName); + + // Assert + plugin.Should().NotBeNull(); + plugin.Name.Should().Be(pluginName); + plugin.FunctionsMetaShouldBe([ + new FunctionMetadata("GetData") + { + Description = "Function GetData for testing async filtering.", + Parameters = [], + ReturnParameter = new ReturnParameterMetadata { Description = string.Empty } + } + ]); } - private class StubPluginMetadataProvider : IPluginMetadataProvider + [Fact] + public void CreateFromClrObjectWithMetadata_FiltersOpenGenericMethods() { - public PluginMetadata? GetPluginMetadata(KernelPlugin plugin) => - plugin.Name == "DateTimePlugin" ? new PluginMetadata - { - Description = "This plugin returns date and time information." - } : null; + // Arrange + var serviceProvider = new ServiceCollection() + .AddSingleton() + .BuildServiceProvider(); + + var pluginName = "GenericMethodsPlugin"; - public FunctionMetadata? GetFunctionMetadata(KernelPlugin plugin, KernelFunctionMetadata metadata) => - plugin.Name == "DateTimePlugin" && metadata.Name == "ToShortDateString" ? new FunctionMetadata(metadata.Name) + // Act + var plugin = KernelPluginForge.CreateFromClrTypeWithMetadata>(serviceProvider, pluginName); + + // Assert + plugin.Should().NotBeNull(); + plugin.Name.Should().Be(pluginName); + plugin.FunctionsMetaShouldBe([ + new FunctionMetadata("ClosedGenericMethod") { - Description = "Returns the date in short format." - } : null; + Description = "Closed generic method that returns a default value of type T.", + Parameters = [], + ReturnParameter = new ReturnParameterMetadata { Description = string.Empty } + }, + // new FunctionMetadata("OpenGenericMethodWithParameter") + // { + // Description = "Open generic method with one type parameter." + // }, + // new FunctionMetadata("OpenGenericMethodWithMultipleParameters") + // { + // Description = "Open generic method with multiple type parameters." + // }, + // new FunctionMetadata("OpenGenericMethodWithReturnType") + // { + // Description = "Open generic method with a generic return type." + // }, + // new FunctionMetadata("OpenGenericMethodWithReturnTypeAndParameters") + // { + // Description = "Open generic method with a generic return type and parameters." + // } + ]); } } } \ No newline at end of file diff --git a/src/SemanticPluginForge.UnitTests/PluginBuilderTest.cs b/src/SemanticPluginForge.UnitTests/PluginBuilderTest.cs index 18ff49a..3c9d3a7 100644 --- a/src/SemanticPluginForge.UnitTests/PluginBuilderTest.cs +++ b/src/SemanticPluginForge.UnitTests/PluginBuilderTest.cs @@ -8,6 +8,139 @@ namespace SemanticPluginForge.UnitTests; public class PluginBuilderTests { + #region PatchKernelFunctionWithMetadata Tests + + [Fact] + public void PatchKernelFunctionWithMetadata_ShouldReturnOriginalFunction_WhenNoFunctionMetadataExists() + { + // Arrange + var metadataProviderMock = new Mock(); + metadataProviderMock.Setup(p => p.GetFunctionMetadata(It.IsAny(), It.IsAny())) + .Returns(null as FunctionMetadata); + + var pluginBuilder = new PluginBuilder(metadataProviderMock.Object); + var kernelPlugin = KernelPluginFactory.CreateFromObject(new SamplePlugin()); + var originalFunction = kernelPlugin[nameof(SamplePlugin.GetCurrentUsername)]; + + // Act + var result = pluginBuilder.PatchKernelFunctionWithMetadata(kernelPlugin, originalFunction); + + // Assert + result.Should().BeSameAs(originalFunction); + } + + [Fact] + public void PatchKernelFunctionWithMetadata_ShouldReturnNull_WhenFunctionIsSuppressed() + { + // Arrange + var metadataProviderMock = new Mock(); + metadataProviderMock.Setup(p => p.GetFunctionMetadata(It.IsAny(), It.IsAny())) + .Returns(new FunctionMetadata("test") { Suppress = true }); + + var pluginBuilder = new PluginBuilder(metadataProviderMock.Object); + var kernelPlugin = KernelPluginFactory.CreateFromObject(new SamplePlugin()); + var originalFunction = kernelPlugin[nameof(SamplePlugin.GetCurrentUsername)]; + + // Act + var result = pluginBuilder.PatchKernelFunctionWithMetadata(kernelPlugin, originalFunction); + + // Assert + result.Should().BeNull(); + } + + [Fact] + public void PatchKernelFunctionWithMetadata_ShouldReturnPatchedFunction_WhenMetadataExists() + { + // Arrange + var metadataProviderMock = new Mock(); + metadataProviderMock.Setup(p => p.GetFunctionMetadata(It.IsAny(), It.IsAny())) + .Returns(new FunctionMetadata("test") { Description = "Patched description" }); + + var pluginBuilder = new PluginBuilder(metadataProviderMock.Object); + var kernelPlugin = KernelPluginFactory.CreateFromObject(new SamplePlugin()); + var originalFunction = kernelPlugin[nameof(SamplePlugin.GetCurrentUsername)]; + + // Act + var result = pluginBuilder.PatchKernelFunctionWithMetadata(kernelPlugin, originalFunction); + + // Assert + result.Should().NotBeSameAs(originalFunction); + result.Should().NotBeNull(); + result!.Metadata.Description.Should().Be("Patched description"); + } + + [Fact] + public void PatchKernelFunctionWithMetadata_ShouldOverrideFunctionName_WhenOverrideNameIsSpecified() + { + // Arrange + var metadataProviderMock = new Mock(); + metadataProviderMock.Setup(p => p.GetFunctionMetadata(It.IsAny(), It.IsAny())) + .Returns(new FunctionMetadata("test") { OverrideFunctionName = "NewFunctionName" }); + + var pluginBuilder = new PluginBuilder(metadataProviderMock.Object); + var kernelPlugin = KernelPluginFactory.CreateFromObject(new SamplePlugin()); + var originalFunction = kernelPlugin[nameof(SamplePlugin.GetCurrentUsername)]; + + // Act + var result = pluginBuilder.PatchKernelFunctionWithMetadata(kernelPlugin, originalFunction); + + // Assert + result.Should().NotBeSameAs(originalFunction); + result.Should().NotBeNull(); + result!.Metadata.Name.Should().Be("NewFunctionName"); + } + + [Fact] + public void PatchKernelFunctionWithMetadata_ShouldThrowException_WhenSuppressingRequiredParameterWithoutDefault() + { + // Arrange + var metadataProviderMock = new Mock(); + metadataProviderMock.Setup(p => p.GetFunctionMetadata(It.IsAny(), It.IsAny())) + .Returns(new FunctionMetadata("test") { + Parameters = new List { + new ParameterMetadata("name") { Suppress = true } + } + }); + + var pluginBuilder = new PluginBuilder(metadataProviderMock.Object); + var kernelPlugin = KernelPluginFactory.CreateFromObject(new SamplePlugin()); + var originalFunction = kernelPlugin[nameof(SamplePlugin.GetTemperatureByCity)]; + + // Act & Assert + pluginBuilder.Invoking(x => x.PatchKernelFunctionWithMetadata(kernelPlugin, originalFunction)) + .Should().Throw() + .WithMessage("Parameter 'name' is required and cannot be suppressed without a default value."); + } + + [Fact] + public async Task PatchKernelFunctionWithMetadata_ShouldUseSuppressedParameterDefaultAsync() + { + // Arrange + var metadataProviderMock = new Mock(); + metadataProviderMock.Setup(p => p.GetFunctionMetadata(It.IsAny(), It.IsAny())) + .Returns(new FunctionMetadata("test") { + Parameters = new List { + new ParameterMetadata("name") { Suppress = true, DefaultValue = "DefaultCity" } + } + }); + + var pluginBuilder = new PluginBuilder(metadataProviderMock.Object); + var kernelPlugin = KernelPluginFactory.CreateFromObject(new SamplePlugin()); + var originalFunction = kernelPlugin[nameof(SamplePlugin.GetTemperatureByCity)]; + + // Act + var result = pluginBuilder.PatchKernelFunctionWithMetadata(kernelPlugin, originalFunction); + var invokeResult = await result!.InvokeAsync(new Kernel()); + + // Assert + result.Should().NotBeSameAs(originalFunction); + result.Metadata.Parameters.Should().NotContain(p => p.Name == "name"); + invokeResult.GetValue().Should().Be("DefaultCity temperature is 20 degrees celsius"); + } + + #endregion + + #region PatchKernelPluginWithMetadata Tests [Fact] public void PatchKernelPluginWithMetadata_ShouldReturnUnalteredPlugin_WhenNoFunctionMetadataExists() { @@ -333,6 +466,10 @@ public async Task PatchKernelPluginWithMetadata_ShouldReturnAlteredPlugin_WhenFu functionResult.GetValue().Should().Be("my_user"); } + #endregion + + #region Helper Methods + private List GetExpectedFunctionsMetadata( string usernameFunctionDescription = "Returns the current username.", string usernameReturnDescription = "", @@ -384,4 +521,6 @@ private List GetExpectedFunctionsMetadata( } ]; } + + #endregion } diff --git a/src/SemanticPluginForge.UnitTests/StubPluginWithoutAttribute.cs b/src/SemanticPluginForge.UnitTests/StubPluginWithoutAttribute.cs new file mode 100644 index 0000000..c25758a --- /dev/null +++ b/src/SemanticPluginForge.UnitTests/StubPluginWithoutAttribute.cs @@ -0,0 +1,31 @@ +using Microsoft.SemanticKernel; +using SemanticPluginForge.Core; + +namespace SemanticPluginForge.UnitTests +{ + public partial class KernelPluginForgeTests + { + private class StubPluginWithoutAttribute + { + public string ToShortDateString() + { + return DateTime.Now.ToShortDateString(); + } + } + + private class StubPluginMetadataProvider : IPluginMetadataProvider + { + public PluginMetadata? GetPluginMetadata(KernelPlugin plugin) => + plugin.Name == "DateTimePlugin" ? new PluginMetadata + { + Description = "This plugin returns date and time information." + } : null; + + public FunctionMetadata? GetFunctionMetadata(KernelPlugin plugin, KernelFunctionMetadata metadata) => + plugin.Name == "DateTimePlugin" && metadata.Name == "ToShortDateString" ? new FunctionMetadata(metadata.Name) + { + Description = "Returns the date in short format." + } : null; + } + } +} \ No newline at end of file