diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step03_ClassBasedSkills/Agent_Step03_ClassBasedSkills.csproj b/dotnet/samples/02-agents/AgentSkills/Agent_Step03_ClassBasedSkills/Agent_Step03_ClassBasedSkills.csproj index fd3d71fe7e..d7233702ac 100644 --- a/dotnet/samples/02-agents/AgentSkills/Agent_Step03_ClassBasedSkills/Agent_Step03_ClassBasedSkills.csproj +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step03_ClassBasedSkills/Agent_Step03_ClassBasedSkills.csproj @@ -6,7 +6,7 @@ enable enable - $(NoWarn);MAAI001 + $(NoWarn);MAAI001;IDE0051 diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step03_ClassBasedSkills/Program.cs b/dotnet/samples/02-agents/AgentSkills/Agent_Step03_ClassBasedSkills/Program.cs index 8be991598a..7f5e356a60 100644 --- a/dotnet/samples/02-agents/AgentSkills/Agent_Step03_ClassBasedSkills/Program.cs +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step03_ClassBasedSkills/Program.cs @@ -1,8 +1,9 @@ // Copyright (c) Microsoft. All rights reserved. -// This sample demonstrates how to define Agent Skills as C# classes using AgentClassSkill. -// Class-based skills bundle all components into a single class implementation. +// This sample demonstrates how to define Agent Skills as C# classes using AgentClassSkill +// with attributes for automatic script and resource discovery. +using System.ComponentModel; using System.Text.Json; using Azure.AI.OpenAI; using Azure.Identity; @@ -44,17 +45,16 @@ Console.WriteLine($"Agent: {response.Text}"); /// -/// A unit-converter skill defined as a C# class. +/// A unit-converter skill defined as a C# class using attributes for discovery. /// /// -/// Class-based skills bundle all components (name, description, body, resources, scripts) -/// into a single class. +/// Properties annotated with are automatically +/// discovered as skill resources, and methods annotated with +/// are automatically discovered as skill scripts. Alternatively, +/// and can be overridden. /// -internal sealed class UnitConverterSkill : AgentClassSkill +internal sealed class UnitConverterSkill : AgentClassSkill { - private IReadOnlyList? _resources; - private IReadOnlyList? _scripts; - /// public override AgentSkillFrontmatter Frontmatter { get; } = new( "unit-converter", @@ -69,31 +69,40 @@ Use this skill when the user asks to convert between units. 3. Present the result clearly with both units. """; - /// - public override IReadOnlyList? Resources => this._resources ??= - [ - CreateResource( - "conversion-table", - """ - # Conversion Tables - - Formula: **result = value × factor** - - | From | To | Factor | - |-------------|-------------|----------| - | miles | kilometers | 1.60934 | - | kilometers | miles | 0.621371 | - | pounds | kilograms | 0.453592 | - | kilograms | pounds | 2.20462 | - """), - ]; - - /// - public override IReadOnlyList? Scripts => this._scripts ??= - [ - CreateScript("convert", ConvertUnits), - ]; + /// + /// Gets the used to marshal parameters and return values + /// for scripts and resources. + /// + /// + /// This override is not necessary for this sample, but can be used to provide custom + /// serialization options, for example a source-generated JsonTypeInfoResolver + /// for Native AOT compatibility. + /// + protected override JsonSerializerOptions? SerializerOptions => null; + + /// + /// A conversion table resource providing multiplication factors. + /// + [AgentSkillResource("conversion-table")] + [Description("Lookup table of multiplication factors for common unit conversions.")] + public string ConversionTable => """ + # Conversion Tables + + Formula: **result = value × factor** + + | From | To | Factor | + |-------------|-------------|----------| + | miles | kilometers | 1.60934 | + | kilometers | miles | 0.621371 | + | pounds | kilograms | 0.453592 | + | kilograms | pounds | 2.20462 | + """; + /// + /// Converts a value by the given factor. + /// + [AgentSkillScript("convert")] + [Description("Multiplies a value by a conversion factor and returns the result as JSON.")] private static string ConvertUnits(double value, double factor) { double result = Math.Round(value * factor, 4); diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step03_ClassBasedSkills/README.md b/dotnet/samples/02-agents/AgentSkills/Agent_Step03_ClassBasedSkills/README.md index 3525bb7a98..028cb05a37 100644 --- a/dotnet/samples/02-agents/AgentSkills/Agent_Step03_ClassBasedSkills/README.md +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step03_ClassBasedSkills/README.md @@ -1,12 +1,16 @@ # Class-Based Agent Skills Sample -This sample demonstrates how to define **Agent Skills as C# classes** using `AgentClassSkill`. +This sample demonstrates how to define **Agent Skills as C# classes** using `AgentClassSkill` +with **attributes** for automatic script and resource discovery. ## What it demonstrates - Creating skills as classes that extend `AgentClassSkill` -- Bundling name, description, body, resources, and scripts into a single class +- Using `[AgentSkillResource]` on properties to define resources +- Using `[AgentSkillScript]` on methods to define scripts +- Automatic discovery (no need to override `Resources`/`Scripts`) - Using the `AgentSkillsProvider` constructor with class-based skills +- Overriding `SerializerOptions` for Native AOT compatibility ## Skills Included diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step04_MixedSkills/Agent_Step04_MixedSkills.csproj b/dotnet/samples/02-agents/AgentSkills/Agent_Step04_MixedSkills/Agent_Step04_MixedSkills.csproj index 7e7e9ef0fa..01abf37da8 100644 --- a/dotnet/samples/02-agents/AgentSkills/Agent_Step04_MixedSkills/Agent_Step04_MixedSkills.csproj +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step04_MixedSkills/Agent_Step04_MixedSkills.csproj @@ -6,7 +6,7 @@ enable enable - $(NoWarn);MAAI001 + $(NoWarn);MAAI001;IDE0051 diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step04_MixedSkills/Program.cs b/dotnet/samples/02-agents/AgentSkills/Agent_Step04_MixedSkills/Program.cs index ab5da71a3c..28d5cb9ee9 100644 --- a/dotnet/samples/02-agents/AgentSkills/Agent_Step04_MixedSkills/Program.cs +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step04_MixedSkills/Program.cs @@ -8,11 +8,12 @@ // Three different skill sources are registered here: // 1. File-based: unit-converter (miles↔km, pounds↔kg) from SKILL.md on disk // 2. Code-defined: volume-converter (gallons↔liters) using AgentInlineSkill -// 3. Class-based: temperature-converter (°F↔°C↔K) using AgentClassSkill +// 3. Class-based: temperature-converter (°F↔°C↔K) using AgentClassSkill with attributes // // For simpler, single-source scenarios, see the earlier steps in this sample series // (e.g., Step01 for file-based, Step02 for code-defined, Step03 for class-based). +using System.ComponentModel; using System.Text.Json; using Azure.AI.OpenAI; using Azure.Identity; @@ -89,13 +90,15 @@ 1. Review the volume-conversion-table resource to find the correct factor. Console.WriteLine($"Agent: {response.Text}"); /// -/// A temperature-converter skill defined as a C# class. +/// A temperature-converter skill defined as a C# class using attributes for discovery. /// -internal sealed class TemperatureConverterSkill : AgentClassSkill +/// +/// Properties annotated with are automatically +/// discovered as skill resources, and methods annotated with +/// are automatically discovered as skill scripts. +/// +internal sealed class TemperatureConverterSkill : AgentClassSkill { - private IReadOnlyList? _resources; - private IReadOnlyList? _scripts; - /// public override AgentSkillFrontmatter Frontmatter { get; } = new( "temperature-converter", @@ -110,29 +113,27 @@ Use this skill when the user asks to convert temperatures. 3. Present the result clearly with both temperature scales. """; - /// - public override IReadOnlyList? Resources => this._resources ??= - [ - CreateResource( - "temperature-conversion-formulas", - """ - # Temperature Conversion Formulas - - | From | To | Formula | - |-------------|-------------|---------------------------| - | Fahrenheit | Celsius | °C = (°F − 32) × 5/9 | - | Celsius | Fahrenheit | °F = (°C × 9/5) + 32 | - | Celsius | Kelvin | K = °C + 273.15 | - | Kelvin | Celsius | °C = K − 273.15 | - """), - ]; - - /// - public override IReadOnlyList? Scripts => this._scripts ??= - [ - CreateScript("convert-temperature", ConvertTemperature), - ]; + /// + /// A reference table of temperature conversion formulas. + /// + [AgentSkillResource("temperature-conversion-formulas")] + [Description("Formulas for converting between Fahrenheit, Celsius, and Kelvin.")] + public string ConversionFormulas => """ + # Temperature Conversion Formulas + + | From | To | Formula | + |-------------|-------------|---------------------------| + | Fahrenheit | Celsius | °C = (°F − 32) × 5/9 | + | Celsius | Fahrenheit | °F = (°C × 9/5) + 32 | + | Celsius | Kelvin | K = °C + 273.15 | + | Kelvin | Celsius | °C = K − 273.15 | + """; + /// + /// Converts a temperature value between scales. + /// + [AgentSkillScript("convert-temperature")] + [Description("Converts a temperature value from one scale to another.")] private static string ConvertTemperature(double value, string from, string to) { double result = (from.ToUpperInvariant(), to.ToUpperInvariant()) switch diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step05_SkillsWithDI/Agent_Step05_SkillsWithDI.csproj b/dotnet/samples/02-agents/AgentSkills/Agent_Step05_SkillsWithDI/Agent_Step05_SkillsWithDI.csproj index 959fa29167..699672ded5 100644 --- a/dotnet/samples/02-agents/AgentSkills/Agent_Step05_SkillsWithDI/Agent_Step05_SkillsWithDI.csproj +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step05_SkillsWithDI/Agent_Step05_SkillsWithDI.csproj @@ -6,7 +6,7 @@ enable enable - $(NoWarn);MAAI001;CA1812 + $(NoWarn);MAAI001;CA1812;IDE0051 diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step05_SkillsWithDI/Program.cs b/dotnet/samples/02-agents/AgentSkills/Agent_Step05_SkillsWithDI/Program.cs index 50b0545be3..251503a918 100644 --- a/dotnet/samples/02-agents/AgentSkills/Agent_Step05_SkillsWithDI/Program.cs +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step05_SkillsWithDI/Program.cs @@ -13,6 +13,7 @@ // showing that DI works identically regardless of how the skill is defined. // When prompted with a question spanning both domains, the agent uses both skills. +using System.ComponentModel; using System.Text.Json; using Azure.AI.OpenAI; using Azure.Identity; @@ -62,8 +63,8 @@ Use this skill when the user asks to convert between distance units (miles and k // Approach 2: Class-Based Skill with DI (AgentClassSkill) // ===================================================================== // Handles weight conversions (pounds ↔ kilograms). -// Resources and scripts are encapsulated in a class. Factory methods -// CreateResource and CreateScript accept delegates with IServiceProvider. +// Resources and scripts are discovered via reflection using attributes. +// Methods with an IServiceProvider parameter receive DI automatically. // // Alternatively, class-based skills can accept dependencies through their // constructor. Register the skill class itself in the ServiceCollection and @@ -113,14 +114,13 @@ Use this skill when the user asks to convert between distance units (miles and k /// /// /// This skill resolves from the DI container -/// in both its resource and script functions. This enables clean separation of -/// concerns and testability while retaining the class-based skill pattern. +/// in both its resource and script methods. Methods with an +/// parameter are automatically injected by the framework. Properties and methods annotated +/// with and +/// are automatically discovered via reflection. /// -internal sealed class WeightConverterSkill : AgentClassSkill +internal sealed class WeightConverterSkill : AgentClassSkill { - private IReadOnlyList? _resources; - private IReadOnlyList? _scripts; - /// public override AgentSkillFrontmatter Frontmatter { get; } = new( "weight-converter", @@ -135,25 +135,27 @@ Use this skill when the user asks to convert between weight units (pounds and ki 3. Present the result clearly with both units. """; - /// - public override IReadOnlyList? Resources => this._resources ??= - [ - CreateResource("weight-table", (IServiceProvider serviceProvider) => - { - var service = serviceProvider.GetRequiredService(); - return service.GetWeightTable(); - }), - ]; + /// + /// Returns the weight conversion table from the DI-registered . + /// + [AgentSkillResource("weight-table")] + [Description("Lookup table of multiplication factors for weight conversions.")] + private static string GetWeightTable(IServiceProvider serviceProvider) + { + var service = serviceProvider.GetRequiredService(); + return service.GetWeightTable(); + } - /// - public override IReadOnlyList? Scripts => this._scripts ??= - [ - CreateScript("convert", (double value, double factor, IServiceProvider serviceProvider) => - { - var service = serviceProvider.GetRequiredService(); - return service.Convert(value, factor); - }), - ]; + /// + /// Converts a value by the given factor using the DI-registered . + /// + [AgentSkillScript("convert")] + [Description("Multiplies a value by a conversion factor and returns the result as JSON.")] + private static string Convert(double value, double factor, IServiceProvider serviceProvider) + { + var service = serviceProvider.GetRequiredService(); + return service.Convert(value, factor); + } } // --------------------------------------------------------------------------- diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProviderBuilder.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProviderBuilder.cs index 0da54d0426..e49c620187 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProviderBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProviderBuilder.cs @@ -21,7 +21,7 @@ namespace Microsoft.Agents.AI; /// /// /// Mixed skill types — combine file-based, code-defined (), -/// and class-based () skills in a single provider. +/// and class-based () skills in a single provider. /// Multiple file script runners — use different script runners for different /// file skill directories via per-source scriptRunner parameters on /// / . diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentClassSkill.cs b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentClassSkill.cs index 4febb8bc79..b44f423bc2 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentClassSkill.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentClassSkill.cs @@ -1,8 +1,12 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Collections.Generic; +using System.ComponentModel; using System.Diagnostics.CodeAnalysis; +using System.Reflection; using System.Text.Json; +using System.Threading; using Microsoft.Extensions.AI; using Microsoft.Shared.DiagnosticIds; @@ -11,17 +15,55 @@ namespace Microsoft.Agents.AI; /// /// Abstract base class for defining skills as C# classes that bundle all components together. /// +/// +/// The concrete skill type. This type parameter is annotated with +/// to ensure that the IL trimmer and Native AOT compiler +/// preserve the members needed for attribute-based discovery. +/// /// /// /// Inherit from this class to create a self-contained skill definition. Override the abstract -/// properties to provide name, description, and instructions. Use , -/// , and to define -/// inline resources and scripts. +/// properties to provide name, description, and instructions. +/// +/// +/// Scripts and resources can be defined in two ways: +/// +/// +/// Attribute-based (recommended): Annotate methods with to define scripts, +/// and properties or methods with to define resources. These are automatically +/// discovered via reflection on . This approach is compatible with Native AOT. +/// +/// +/// Explicit override: Override and , using +/// , , +/// and to define inline resources and scripts. This approach is also compatible with Native AOT. +/// +/// +/// +/// +/// Multi-level inheritance limitation: Discovery reflects only on , +/// so if a further-derived subclass adds new attributed members, they will not be discovered unless +/// that subclass also uses the CRTP pattern +/// (e.g., class SpecialSkill : AgentClassSkill<SpecialSkill>). /// /// /// /// -/// public class PdfFormatterSkill : AgentClassSkill +/// // Attribute-based approach (recommended, AOT-compatible): +/// public class PdfFormatterSkill : AgentClassSkill<PdfFormatterSkill> +/// { +/// public override AgentSkillFrontmatter Frontmatter { get; } = new("pdf-formatter", "Format documents as PDF."); +/// protected override string Instructions => "Use this skill to format documents..."; +/// +/// [AgentSkillResource("template")] +/// public string Template => "Use this template..."; +/// +/// [AgentSkillScript("format-pdf")] +/// private static string FormatPdf(string content) => content; +/// } +/// +/// // Explicit override approach (AOT-compatible): +/// public class ExplicitPdfFormatterSkill : AgentClassSkill<ExplicitPdfFormatterSkill> /// { /// private IReadOnlyList<AgentSkillResource>? _resources; /// private IReadOnlyList<AgentSkillScript>? _scripts; @@ -44,15 +86,41 @@ namespace Microsoft.Agents.AI; /// /// [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] -public abstract class AgentClassSkill : AgentSkill +public abstract class AgentClassSkill< + [DynamicallyAccessedMembers( + DynamicallyAccessedMemberTypes.PublicProperties | + DynamicallyAccessedMemberTypes.NonPublicProperties | + DynamicallyAccessedMemberTypes.PublicMethods | + DynamicallyAccessedMemberTypes.NonPublicMethods)] TSelf> + : AgentSkill + where TSelf : AgentClassSkill { + private const BindingFlags DiscoveryBindingFlags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static; + private string? _content; + private bool _resourcesDiscovered; + private bool _scriptsDiscovered; + private IReadOnlyList? _reflectedResources; + private IReadOnlyList? _reflectedScripts; /// /// Gets the raw instructions text for this skill. /// protected abstract string Instructions { get; } + /// + /// Gets the used to marshal parameters and return values + /// for scripts and resources. + /// + /// + /// Override this property to provide custom serialization options. This value is used by + /// reflection-discovered scripts and resources, and also as a fallback by + /// and when no + /// explicit is passed to those methods. + /// The default value is , which causes to be used. + /// + protected virtual JsonSerializerOptions? SerializerOptions => null; + /// /// /// Returns a synthesized XML document containing name, description, instructions, resources, and scripts. @@ -65,6 +133,48 @@ public abstract class AgentClassSkill : AgentSkill this.Resources, this.Scripts); + /// + /// + /// Returns resources discovered via reflection by scanning for + /// members annotated with . This discovery is + /// compatible with Native AOT because is annotated with + /// . The result is cached after the first access. + /// + public override IReadOnlyList? Resources + { + get + { + if (!this._resourcesDiscovered) + { + this._reflectedResources = this.DiscoverResources(); + this._resourcesDiscovered = true; + } + + return this._reflectedResources; + } + } + + /// + /// + /// Returns scripts discovered via reflection by scanning for + /// methods annotated with . This discovery is + /// compatible with Native AOT because is annotated with + /// . The result is cached after the first access. + /// + public override IReadOnlyList? Scripts + { + get + { + if (!this._scriptsDiscovered) + { + this._reflectedScripts = this.DiscoverScripts(); + this._scriptsDiscovered = true; + } + + return this._reflectedScripts; + } + } + /// /// Creates a skill resource backed by a static value. /// @@ -72,7 +182,7 @@ public abstract class AgentClassSkill : AgentSkill /// The static resource value. /// An optional description of the resource. /// A new instance. - protected static AgentSkillResource CreateResource(string name, object value, string? description = null) + protected AgentSkillResource CreateResource(string name, object value, string? description = null) => new AgentInlineSkillResource(name, value, description); /// @@ -83,11 +193,11 @@ protected static AgentSkillResource CreateResource(string name, object value, st /// An optional description of the resource. /// /// Optional used to marshal the delegate's parameters and return value. - /// When , is used. + /// When , falls back to . /// /// A new instance. - protected static AgentSkillResource CreateResource(string name, Delegate method, string? description = null, JsonSerializerOptions? serializerOptions = null) - => new AgentInlineSkillResource(name, method, description, serializerOptions); + protected AgentSkillResource CreateResource(string name, Delegate method, string? description = null, JsonSerializerOptions? serializerOptions = null) + => new AgentInlineSkillResource(name, method, description, serializerOptions ?? this.SerializerOptions); /// /// Creates a skill script backed by a delegate. @@ -97,9 +207,129 @@ protected static AgentSkillResource CreateResource(string name, Delegate method, /// An optional description of the script. /// /// Optional used to marshal the delegate's parameters and return value. - /// When , is used. + /// When , falls back to . /// /// A new instance. - protected static AgentSkillScript CreateScript(string name, Delegate method, string? description = null, JsonSerializerOptions? serializerOptions = null) - => new AgentInlineSkillScript(name, method, description, serializerOptions); + protected AgentSkillScript CreateScript(string name, Delegate method, string? description = null, JsonSerializerOptions? serializerOptions = null) + => new AgentInlineSkillScript(name, method, description, serializerOptions ?? this.SerializerOptions); + + private List? DiscoverResources() + { + List? resources = null; + + var selfType = typeof(TSelf); + + // Discover resources from properties annotated with [AgentSkillResource]. + foreach (var property in selfType.GetProperties(DiscoveryBindingFlags)) + { + var attr = property.GetCustomAttribute(); + if (attr is null) + { + continue; + } + + var getter = property.GetGetMethod(nonPublic: true); + if (getter is null) + { + continue; + } + + // Indexer properties have getter parameters and cannot be used as resources + // because ReadAsync invokes the underlying AIFunction with no named arguments. + if (getter.GetParameters().Length > 0) + { + throw new InvalidOperationException( + $"Property '{property.Name}' on type '{selfType.Name}' is an indexer and cannot be used as a skill resource. " + + "Remove the [AgentSkillResource] attribute or use a non-indexer property."); + } + + var name = attr.Name ?? property.Name; + if (resources?.Exists(r => r.Name == name) == true) + { + throw new InvalidOperationException($"Skill '{this.Frontmatter.Name}' already has a resource named '{name}'. Ensure each [AgentSkillResource] has a unique name."); + } + + resources ??= []; + resources.Add(new AgentInlineSkillResource( + name: name, + method: getter, + target: getter.IsStatic ? null : this, + description: property.GetCustomAttribute()?.Description, + serializerOptions: this.SerializerOptions)); + } + + // Discover resources from methods annotated with [AgentSkillResource]. + foreach (var method in selfType.GetMethods(DiscoveryBindingFlags)) + { + var attr = method.GetCustomAttribute(); + if (attr is null) + { + continue; + } + + ValidateResourceMethodParameters(method, selfType); + + var name = attr.Name ?? method.Name; + if (resources?.Exists(r => r.Name == name) == true) + { + throw new InvalidOperationException($"Skill '{this.Frontmatter.Name}' already has a resource named '{name}'. Ensure each [AgentSkillResource] has a unique name."); + } + + resources ??= []; + resources.Add(new AgentInlineSkillResource( + name: name, + method: method, + target: method.IsStatic ? null : this, + description: method.GetCustomAttribute()?.Description, + serializerOptions: this.SerializerOptions)); + } + + return resources; + } + + private static void ValidateResourceMethodParameters(MethodInfo method, Type skillType) + { + foreach (var param in method.GetParameters()) + { + if (param.ParameterType != typeof(IServiceProvider) && + param.ParameterType != typeof(CancellationToken)) + { + throw new InvalidOperationException( + $"Method '{method.Name}' on type '{skillType.Name}' has parameter '{param.Name}' of type " + + $"'{param.ParameterType}' which cannot be supplied when reading a resource. " + + "Resource methods may only accept IServiceProvider and/or CancellationToken parameters. " + + "Remove the [AgentSkillResource] attribute or change the method signature."); + } + } + } + + private List? DiscoverScripts() + { + List? scripts = null; + + foreach (var method in typeof(TSelf).GetMethods(DiscoveryBindingFlags)) + { + var attr = method.GetCustomAttribute(); + if (attr is null) + { + continue; + } + + var name = attr.Name ?? method.Name; + if (scripts?.Exists(s => s.Name == name) == true) + { + throw new InvalidOperationException($"Skill '{this.Frontmatter.Name}' already has a script named '{name}'. Ensure each [AgentSkillScript] has a unique name."); + } + + scripts ??= []; + scripts.Add(new AgentInlineSkillScript( + name: name, + method: method, + target: method.IsStatic ? null : this, + description: method.GetCustomAttribute()?.Description, + serializerOptions: this.SerializerOptions)); + } + + return scripts; + } } diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillResource.cs b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillResource.cs index 5e032f073f..556cfdc781 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillResource.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillResource.cs @@ -2,6 +2,7 @@ using System; using System.Diagnostics.CodeAnalysis; +using System.Reflection; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -54,6 +55,28 @@ public AgentInlineSkillResource(string name, Delegate method, string? descriptio this._function = AIFunctionFactory.Create(method, options); } + /// + /// Initializes a new instance of the class from a . + /// The method is invoked via an each time is called, + /// producing a dynamic (computed) value. + /// + /// The resource name. + /// A method that produces the resource value when requested. + /// The target instance for instance methods, or for static methods. + /// An optional description of the resource. + /// + /// Optional used to marshal the method's parameters and return value. + /// When , is used. + /// + public AgentInlineSkillResource(string name, MethodInfo method, object? target, string? description = null, JsonSerializerOptions? serializerOptions = null) + : base(name, description) + { + Throw.IfNull(method); + + var options = new AIFunctionFactoryOptions { Name = this.Name, SerializerOptions = serializerOptions }; + this._function = AIFunctionFactory.Create(method, target, options); + } + /// public override async Task ReadAsync(IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default) { diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillScript.cs b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillScript.cs index 232a2fefce..1e3041aafc 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillScript.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillScript.cs @@ -2,6 +2,7 @@ using System; using System.Diagnostics.CodeAnalysis; +using System.Reflection; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -39,6 +40,27 @@ public AgentInlineSkillScript(string name, Delegate method, string? description this._function = AIFunctionFactory.Create(method, options); } + /// + /// Initializes a new instance of the class from a . + /// The method's parameters and return type are automatically marshaled via . + /// + /// The script name. + /// The method to execute when the script is invoked. + /// The target instance for instance methods, or for static methods. + /// An optional description of the script. + /// + /// Optional used to marshal the method's parameters and return value. + /// When , is used. + /// + public AgentInlineSkillScript(string name, MethodInfo method, object? target, string? description = null, JsonSerializerOptions? serializerOptions = null) + : base(Throw.IfNullOrWhitespace(name), description) + { + Throw.IfNull(method); + + var options = new AIFunctionFactoryOptions { Name = this.Name, SerializerOptions = serializerOptions }; + this._function = AIFunctionFactory.Create(method, target, options); + } + /// /// Gets the JSON schema describing the parameters accepted by this script, or if not available. /// diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentSkillResourceAttribute.cs b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentSkillResourceAttribute.cs new file mode 100644 index 0000000000..a642d6c281 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentSkillResourceAttribute.cs @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI; + +/// +/// Marks a property or method as a skill resource that is automatically discovered by . +/// +/// +/// +/// Apply this attribute to properties or methods in an subclass to register +/// them as skill resources. +/// +/// +/// To provide a description for the resource, apply +/// to the same member. +/// +/// +/// When applied to a property, the property getter is invoked each time the resource is read, +/// enabling dynamic (computed) resources. When applied to a method, the method is invoked each time +/// the resource is read, also enabling dynamic resources. Methods with an +/// parameter support dependency injection. +/// +/// +/// This attribute is compatible with Native AOT when used with . +/// Alternatively, override the property and use +/// instead. +/// +/// +/// +/// +/// public class MySkill : AgentClassSkill<MySkill> +/// { +/// public override AgentSkillFrontmatter Frontmatter { get; } = new("my-skill", "A skill."); +/// protected override string Instructions => "Use this skill to do something."; +/// +/// [AgentSkillResource("reference-data")] +/// [Description("Some reference content for the skill.")] +/// public string ReferenceData => "Some reference content."; +/// } +/// +/// +[AttributeUsage(AttributeTargets.Property | AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class AgentSkillResourceAttribute : Attribute +{ + /// + /// Initializes a new instance of the class. + /// The resource name defaults to the property or method name. + /// + public AgentSkillResourceAttribute() + { + } + + /// + /// Initializes a new instance of the class + /// with an explicit resource name. + /// + /// The resource name used to identify this resource. + public AgentSkillResourceAttribute(string name) + { + this.Name = name; + } + + /// + /// Gets the resource name, or to use the member name. + /// + public string? Name { get; } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentSkillScriptAttribute.cs b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentSkillScriptAttribute.cs new file mode 100644 index 0000000000..30f65cf383 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentSkillScriptAttribute.cs @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI; + +/// +/// Marks a method as a skill script that is automatically discovered by . +/// +/// +/// +/// Apply this attribute to methods in an subclass to register them as +/// skill scripts. The method's parameters and return type are automatically marshaled via +/// AIFunctionFactory. +/// +/// +/// To provide a description for the script, apply +/// to the same method. +/// +/// +/// Methods can be instance or static, and may have any visibility (public, private, etc.). +/// Methods with an parameter support dependency injection. +/// +/// +/// This attribute is compatible with Native AOT when used with . +/// Alternatively, override the property and use +/// instead. +/// +/// +/// +/// +/// public class MySkill : AgentClassSkill<MySkill> +/// { +/// public override AgentSkillFrontmatter Frontmatter { get; } = new("my-skill", "A skill."); +/// protected override string Instructions => "Use this skill to do something."; +/// +/// [AgentSkillScript("do-something")] +/// [Description("Converts the input to upper case.")] +/// private static string DoSomething(string input) => input.ToUpperInvariant(); +/// } +/// +/// +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class AgentSkillScriptAttribute : Attribute +{ + /// + /// Initializes a new instance of the class. + /// The script name defaults to the method name. + /// + public AgentSkillScriptAttribute() + { + } + + /// + /// Initializes a new instance of the class + /// with an explicit script name. + /// + /// The script name used to identify this script. + public AgentSkillScriptAttribute(string name) + { + this.Name = name; + } + + /// + /// Gets the script name, or to use the method name. + /// + public string? Name { get; } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentClassSkillTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentClassSkillTests.cs index f03c2d65fe..1dc7d5b3f9 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentClassSkillTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentClassSkillTests.cs @@ -2,64 +2,60 @@ using System; using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Reflection; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; namespace Microsoft.Agents.AI.UnitTests.AgentSkills; /// -/// Unit tests for and . +/// Unit tests for and . /// public sealed class AgentClassSkillTests { [Fact] - public void Resources_DefaultsToNull_WhenNotOverridden() + public void MinimalClassSkill_HasNullOverrides_AndSynthesizesContent() { // Arrange var skill = new MinimalClassSkill(); - // Act & Assert + // Act & Assert — null overrides + Assert.Equal("minimal", skill.Frontmatter.Name); Assert.Null(skill.Resources); - } - - [Fact] - public void Scripts_DefaultsToNull_WhenNotOverridden() - { - // Arrange - var skill = new MinimalClassSkill(); - - // Act & Assert Assert.Null(skill.Scripts); + + // Act & Assert — synthesized XML content + Assert.Contains("minimal", skill.Content); + Assert.Contains("A minimal skill.", skill.Content); + Assert.Contains("", skill.Content); + Assert.Contains("Minimal skill body.", skill.Content); + Assert.Contains("", skill.Content); } [Fact] - public void Resources_ReturnsOverriddenList_WhenOverridden() + public void FullClassSkill_ReturnsOverriddenLists_AndCachesContent() { // Arrange var skill = new FullClassSkill(); - // Act - var resources = skill.Resources; - - // Assert - Assert.Single(resources!); - Assert.Equal("test-resource", resources![0].Name); - } + // Act & Assert — overridden resources and scripts + Assert.Single(skill.Resources!); + Assert.Equal("test-resource", skill.Resources![0].Name); - [Fact] - public void Scripts_ReturnsOverriddenList_WhenOverridden() - { - // Arrange - var skill = new FullClassSkill(); + Assert.Single(skill.Scripts!); + Assert.Equal("TestScript", skill.Scripts![0].Name); - // Act - var scripts = skill.Scripts; + // Act & Assert — Content is cached + Assert.Same(skill.Content, skill.Content); - // Assert - Assert.Single(scripts!); - Assert.Equal("TestScript", scripts![0].Name); + // Act & Assert — Content includes parameter schema from typed script + Assert.Contains("parameters_schema", skill.Content); + Assert.Contains("value", skill.Content); } [Fact] @@ -86,357 +82,947 @@ public void ResourcesAndScripts_CanBeLazyLoaded_AndCached() } [Fact] - public void Name_Content_ReturnClassDefinedValues() + public async Task AgentInMemorySkillsSource_ReturnsAllSkillsAsync() { // Arrange - var skill = new MinimalClassSkill(); + var skills = new AgentSkill[] { new MinimalClassSkill(), new FullClassSkill() }; + var source = new AgentInMemorySkillsSource(skills); + + // Act + var result = await source.GetSkillsAsync(CancellationToken.None); + // Assert + Assert.Equal(2, result.Count); + Assert.Equal("minimal", result[0].Frontmatter.Name); + Assert.Equal("full", result[1].Frontmatter.Name); + } + + [Fact] + public void AgentClassSkill_InvalidFrontmatter_ThrowsArgumentException() + { // Act & Assert - Assert.Equal("minimal", skill.Frontmatter.Name); - Assert.Contains("", skill.Content); - Assert.Contains("Minimal skill body.", skill.Content); - Assert.Contains("", skill.Content); + Assert.Throws(() => new AgentSkillFrontmatter("INVALID-NAME", "An invalid skill.")); } [Fact] - public void Content_ReturnsSynthesizedXmlDocument() + public void PartialOverrides_OneCollectionNull_OtherHasValues() { // Arrange - var skill = new MinimalClassSkill(); + var resourceOnly = new ResourceOnlySkill(); + var scriptOnly = new ScriptOnlySkill(); // Act & Assert - Assert.Contains("minimal", skill.Content); - Assert.Contains("A minimal skill.", skill.Content); - Assert.Contains("", skill.Content); - Assert.Contains("Minimal skill body.", skill.Content); + Assert.Single(resourceOnly.Resources!); + Assert.Null(resourceOnly.Scripts); + Assert.Null(scriptOnly.Resources); + Assert.Single(scriptOnly.Scripts!); } [Fact] - public async Task AgentInMemorySkillsSource_ReturnsAllSkillsAsync() + public async Task CreateScriptAndResource_WithSerializerOptions_HandleCustomTypesAsync() { // Arrange - var skills = new AgentClassSkill[] { new MinimalClassSkill(), new FullClassSkill() }; - var source = new AgentInMemorySkillsSource(skills); + var skill = new CustomTypeSkill(); + var jso = SkillTestJsonContext.Default.Options; - // Act - var result = await source.GetSkillsAsync(CancellationToken.None); + // Act — script with custom type deserialization + var script = skill.Scripts![0]; + var inputJson = JsonSerializer.SerializeToElement(new LookupRequest { Query = "test", MaxResults = 5 }, jso); + var args = new AIFunctionArguments { ["request"] = inputJson }; + var scriptResult = await script.RunAsync(skill, args, CancellationToken.None); // Assert - Assert.Equal(2, result.Count); - Assert.Equal("minimal", result[0].Frontmatter.Name); - Assert.Equal("full", result[1].Frontmatter.Name); + Assert.NotNull(scriptResult); + var resultText = scriptResult!.ToString()!; + Assert.Contains("result for test", resultText); + Assert.Contains("5", resultText); + + // Act — resource with custom type serialization + var resourceResult = await skill.Resources![0].ReadAsync(); + + // Assert + Assert.NotNull(resourceResult); + Assert.Contains("dark", resourceResult!.ToString()!); } [Fact] - public void AgentClassSkill_InvalidFrontmatter_ThrowsArgumentException() + public void Scripts_DiscoveredViaAttribute_WithCorrectNamesAndDescriptions() { - // Act & Assert - Assert.Throws(() => new AgentSkillFrontmatter("INVALID-NAME", "An invalid skill.")); + // Arrange + var skill = new AttributedScriptsSkill(); + + // Act + var scripts = skill.Scripts; + + // Assert — all scripts discovered with correct metadata + Assert.NotNull(scripts); + Assert.Equal(4, scripts!.Count); + Assert.Contains(scripts, s => s.Name == "do-work"); + Assert.Contains(scripts, s => s.Name == "DefaultNamed"); + Assert.Contains(scripts, s => s.Name == "append"); + + var processScript = scripts.First(s => s.Name == "process"); + Assert.Equal("Processes the input.", processScript.Description); } [Fact] - public void SkillWithOnlyResources_HasNullScripts() + public async Task Scripts_DiscoveredViaAttribute_StaticAndInstance_CanBeInvokedAsync() { // Arrange - var skill = new ResourceOnlySkill(); + var skill = new AttributedScriptsSkill(); - // Act & Assert - Assert.Single(skill.Resources!); - Assert.Null(skill.Scripts); + // Act & Assert — static method + var doWorkScript = skill.Scripts!.First(s => s.Name == "do-work"); + var doWorkResult = await doWorkScript.RunAsync(skill, new AIFunctionArguments { ["input"] = "hello" }, CancellationToken.None); + Assert.Equal("HELLO", doWorkResult?.ToString()); + + // Act & Assert — instance method + var appendScript = skill.Scripts!.First(s => s.Name == "append"); + var appendResult = await appendScript.RunAsync(skill, new AIFunctionArguments { ["input"] = "test" }, CancellationToken.None); + Assert.Equal("test-suffix", appendResult?.ToString()); } [Fact] - public void SkillWithOnlyScripts_HasNullResources() + public void Resources_DiscoveredViaAttribute_OnProperties_WithCorrectMetadata() { // Arrange - var skill = new ScriptOnlySkill(); + var skill = new AttributedResourcePropertiesSkill(); - // Act & Assert - Assert.Null(skill.Resources); - Assert.Single(skill.Scripts!); + // Act + var resources = skill.Resources; + + // Assert — all resources discovered with correct metadata + Assert.NotNull(resources); + Assert.Equal(4, resources!.Count); + Assert.Contains(resources, r => r.Name == "ref-data"); + Assert.Contains(resources, r => r.Name == "DefaultNamed"); + Assert.Contains(resources, r => r.Name == "static-data"); + + var describedResource = resources.First(r => r.Name == "data"); + Assert.Equal("Some important data.", describedResource.Description); } [Fact] - public void Content_ReturnsCachedInstance_OnRepeatedAccess() + public async Task Resources_DiscoveredViaAttribute_OnProperties_CanBeReadAsync() { // Arrange - var skill = new FullClassSkill(); + var skill = new AttributedResourcePropertiesSkill(); + + // Act & Assert — instance property + var refData = skill.Resources!.First(r => r.Name == "ref-data"); + Assert.Equal("Reference content.", (await refData.ReadAsync())?.ToString()); + + // Act & Assert — static property + var staticData = skill.Resources!.First(r => r.Name == "static-data"); + Assert.Equal("Static content.", (await staticData.ReadAsync())?.ToString()); + } + + [Fact] + public async Task Resources_DiscoveredViaAttribute_OnProperty_InvokedEachTimeAsync() + { + // Arrange + var skill = new AttributedResourceDynamicPropertySkill(); + var resource = skill.Resources![0]; + + // Act + var first = await resource.ReadAsync(); + var second = await resource.ReadAsync(); + + // Assert — property getter is called on each ReadAsync, producing different values + Assert.Equal("call-1", first?.ToString()); + Assert.Equal("call-2", second?.ToString()); + Assert.Equal(2, skill.CallCount); + } + + [Fact] + public void Resources_DiscoveredViaAttribute_OnMethods_WithCorrectMetadata() + { + // Arrange + var skill = new AttributedResourceMethodsSkill(); // Act - var first = skill.Content; - var second = skill.Content; + var resources = skill.Resources; // Assert - Assert.Same(first, second); + Assert.NotNull(resources); + Assert.Equal(4, resources!.Count); + Assert.Contains(resources, r => r.Name == "dynamic"); + Assert.Contains(resources, r => r.Name == "GetData"); + Assert.Contains(resources, r => r.Name == "instance-dynamic"); + + var describedResource = resources.First(r => r.Name == "info"); + Assert.Equal("Returns runtime info.", describedResource.Description); } [Fact] - public void Content_IncludesParametersSchema_WhenScriptsHaveParameters() + public async Task Resources_DiscoveredViaAttribute_OnMethods_CanBeReadAsync() { // Arrange - var skill = new FullClassSkill(); + var skill = new AttributedResourceMethodsSkill(); + + // Act & Assert — static method + var dynamicResource = skill.Resources!.First(r => r.Name == "dynamic"); + Assert.Equal("dynamic-value", (await dynamicResource.ReadAsync())?.ToString()); + + // Act & Assert — instance method + var instanceResource = skill.Resources!.First(r => r.Name == "instance-dynamic"); + Assert.Equal("instance-method-value", (await instanceResource.ReadAsync())?.ToString()); + } + + [Fact] + public void AttributedFullSkill_IncludesContentWithSchema_AndCachesMembers() + { + // Arrange + var skill = new AttributedFullSkill(); + + // Act & Assert — Content includes reflected resources and scripts + Assert.Contains("", skill.Content); + Assert.Contains("conversion-table", skill.Content); + Assert.Contains("", skill.Content); + Assert.Contains("convert", skill.Content); + + // Act & Assert — discovered members are cached + Assert.Same(skill.Resources, skill.Resources); + Assert.Same(skill.Scripts, skill.Scripts); + + // Act & Assert — script has parameters schema + var script = skill.Scripts![0]; + Assert.NotNull(script.ParametersSchema); + Assert.Contains("value", script.ParametersSchema!.Value.GetRawText()); + } + + [Fact] + public void NoAttributedMembers_NoOverrides_ReturnsNull() + { + // Arrange — skill with no attributes and no overrides; base discovery returns null (not empty list) + var skill = new NoAttributesNoOverridesSkill(); + var baseType = typeof(AgentClassSkill); + var resourcesDiscoveredField = baseType.GetField("_resourcesDiscovered", BindingFlags.Instance | BindingFlags.NonPublic); + var scriptsDiscoveredField = baseType.GetField("_scriptsDiscovered", BindingFlags.Instance | BindingFlags.NonPublic); + var reflectedResourcesField = baseType.GetField("_reflectedResources", BindingFlags.Instance | BindingFlags.NonPublic); + var reflectedScriptsField = baseType.GetField("_reflectedScripts", BindingFlags.Instance | BindingFlags.NonPublic); + + Assert.NotNull(resourcesDiscoveredField); + Assert.NotNull(scriptsDiscoveredField); + Assert.NotNull(reflectedResourcesField); + Assert.NotNull(reflectedScriptsField); + Assert.False((bool)resourcesDiscoveredField!.GetValue(skill)!); + Assert.False((bool)scriptsDiscoveredField!.GetValue(skill)!); + + // Act & Assert + Assert.Null(skill.Resources); + Assert.Null(skill.Scripts); + Assert.True((bool)resourcesDiscoveredField.GetValue(skill)!); + Assert.True((bool)scriptsDiscoveredField.GetValue(skill)!); + Assert.Null(reflectedResourcesField!.GetValue(skill)); + Assert.Null(reflectedScriptsField!.GetValue(skill)); + + // Repeated access should not re-trigger discovery even when discovered value is null. + Assert.Null(skill.Resources); + Assert.Null(skill.Scripts); + Assert.True((bool)resourcesDiscoveredField.GetValue(skill)!); + Assert.True((bool)scriptsDiscoveredField.GetValue(skill)!); + Assert.Null(reflectedResourcesField.GetValue(skill)); + Assert.Null(reflectedScriptsField.GetValue(skill)); + } + + [Fact] + public void SubclassOverride_TakesPrecedence_OverAttributes() + { + // Arrange — skill has attributes AND overrides Resources/Scripts + var skill = new AttributedWithOverrideSkill(); // Act - var content = skill.Content; + var resources = skill.Resources; + var scripts = skill.Scripts; + + // Assert — overrides win, not reflected members + Assert.NotNull(resources); + Assert.Single(resources!); + Assert.Equal("manual-resource", resources![0].Name); + Assert.NotNull(scripts); + Assert.Single(scripts!); + Assert.Equal("ManualScript", scripts![0].Name); + } + + [Fact] + public async Task MixedStaticAndInstance_AllDiscoveredAndInvocableAsync() + { + // Arrange + var skill = new MixedStaticInstanceSkill(); + + // Act & Assert — correct counts + Assert.NotNull(skill.Resources); + Assert.Equal(2, skill.Resources!.Count); + Assert.NotNull(skill.Scripts); + Assert.Equal(2, skill.Scripts!.Count); - // Assert — scripts with typed parameters should have their schema included - Assert.Contains("parameters_schema", content); - Assert.Contains("value", content); + // Act & Assert — all resources produce values + foreach (var resource in skill.Resources!) + { + var value = await resource.ReadAsync(); + Assert.NotNull(value); + } + + // Act & Assert — all scripts produce values + foreach (var script in skill.Scripts!) + { + var result = await script.RunAsync(skill, new AIFunctionArguments(), CancellationToken.None); + Assert.NotNull(result); + } } [Fact] - public void Content_IncludesDerivedResources_WhenResourcesUseBaseTypeOverrides() + public async Task SerializerOptions_UsedForReflectedMembersAsync() { // Arrange - var skill = new DerivedResourceSkill(); + var skill = new AttributedSkillWithCustomSerializer(); + var jso = SkillTestJsonContext.Default.Options; + + // Act & Assert — script with custom JSO + var script = skill.Scripts![0]; + var inputJson = JsonSerializer.SerializeToElement(new LookupRequest { Query = "test", MaxResults = 3 }, jso); + var args = new AIFunctionArguments { ["request"] = inputJson }; + var scriptResult = await script.RunAsync(skill, args, CancellationToken.None); + Assert.NotNull(scriptResult); + Assert.Contains("test", scriptResult!.ToString()!); + Assert.Contains("3", scriptResult!.ToString()!); + + // Act & Assert — resource with custom JSO + var resourceResult = await skill.Resources![0].ReadAsync(); + Assert.NotNull(resourceResult); + Assert.Contains("light", resourceResult!.ToString()!); + } + + [Fact] + public void Content_IncludesDescription_ForReflectedResources() + { + // Arrange + var skill = new AttributedResourcePropertiesSkill(); // Act var content = skill.Content; + // Assert — descriptions from [Description] attribute appear in synthesized content + Assert.Contains("Some important data.", content); + } + + [Fact] + public void IndexerPropertyWithResourceAttribute_ThrowsInvalidOperationException() + { + // Arrange + var skill = new IndexerResourceSkill(); + + // Act & Assert — accessing Resources triggers discovery which should throw + var ex = Assert.Throws(() => skill.Resources); + Assert.Contains("indexer", ex.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("IndexerResourceSkill", ex.Message); + } + + [Fact] + public void ResourceMethodWithUnsupportedParameters_ThrowsInvalidOperationException() + { + // Arrange + var skill = new UnsupportedParamResourceMethodSkill(); + + // Act & Assert — accessing Resources triggers discovery which should throw + var ex = Assert.Throws(() => skill.Resources); + Assert.Contains("content", ex.Message); + Assert.Contains("String", ex.Message); + } + + [Fact] + public async Task ResourceMethodWithServiceProviderParam_IsDiscoveredSuccessfullyAsync() + { + // Arrange + var skill = new ServiceProviderResourceMethodSkill(); + var sp = new ServiceCollection().BuildServiceProvider(); + + // Act + var resources = skill.Resources; + // Assert - Assert.Contains("", content); - Assert.Contains("custom-resource", content); - Assert.Contains("Custom resource description.", content); + Assert.NotNull(resources); + Assert.Single(resources!); + Assert.Equal("sp-resource", resources![0].Name); + + var value = await resources[0].ReadAsync(sp); + Assert.Equal("from-sp-method", value?.ToString()); } [Fact] - public void Content_IncludesDerivedScripts_WhenScriptsUseBaseTypeOverrides() + public async Task ResourceMethodWithCancellationTokenParam_IsDiscoveredSuccessfullyAsync() { // Arrange - var skill = new DerivedScriptSkill(); + var skill = new CancellationTokenResourceMethodSkill(); // Act - var content = skill.Content; + var resources = skill.Resources; // Assert - Assert.Contains("", content); - Assert.Contains("custom-script", content); - Assert.Contains("Custom script description.", content); + Assert.NotNull(resources); + Assert.Single(resources!); + Assert.Equal("ct-resource", resources![0].Name); + + var value = await resources[0].ReadAsync(); + Assert.Equal("from-ct-method", value?.ToString()); } [Fact] - public void Content_OmitsParametersSchema_WhenDerivedScriptDoesNotProvideOne() + public async Task ResourceMethodWithBothServiceProviderAndCancellationToken_IsDiscoveredSuccessfullyAsync() { // Arrange - var skill = new DerivedScriptSkill(); + var skill = new BothParamsResourceMethodSkill(); + var sp = new ServiceCollection().BuildServiceProvider(); // Act - var content = skill.Content; + var resources = skill.Resources; // Assert - Assert.DoesNotContain("parameters_schema", content); + Assert.NotNull(resources); + Assert.Single(resources!); + Assert.Equal("both-resource", resources![0].Name); + + var value = await resources[0].ReadAsync(sp); + Assert.Equal("from-both-method", value?.ToString()); } - #region Test skill classes + [Fact] + public async Task CreateScript_FallsBackToSerializerOptions_WhenNoExplicitJsoAsync() + { + // Arrange + var skill = new CreateMethodsFallbackSkill(); + + // Act — invoke script that uses custom types, relying on SerializerOptions fallback + var script = skill.Scripts!.First(s => s.Name == "Lookup"); + var jso = SkillTestJsonContext.Default.Options; + var inputJson = JsonSerializer.SerializeToElement(new LookupRequest { Query = "fallback", MaxResults = 7 }, jso); + var args = new AIFunctionArguments { ["request"] = inputJson }; + var result = await script.RunAsync(skill, args, CancellationToken.None); - private sealed class MinimalClassSkill : AgentClassSkill + // Assert + Assert.NotNull(result); + Assert.Contains("fallback", result!.ToString()!); + Assert.Contains("7", result!.ToString()!); + } + + [Fact] + public async Task CreateResource_FallsBackToSerializerOptions_WhenNoExplicitJsoAsync() { - public override AgentSkillFrontmatter Frontmatter { get; } = new("minimal", "A minimal skill."); + // Arrange + var skill = new CreateMethodsFallbackSkill(); - protected override string Instructions => "Minimal skill body."; + // Act — read resource that uses custom types, relying on SerializerOptions fallback + var resource = skill.Resources!.First(r => r.Name == "config"); + var result = await resource.ReadAsync(); + + // Assert + Assert.NotNull(result); + Assert.Contains("dark", result!.ToString()!); + } + + [Fact] + public async Task CreateScript_UsesExplicitJso_OverSerializerOptionsAsync() + { + // Arrange + var skill = new CreateMethodsExplicitJsoSkill(); + + // Act — invoke script that passes explicit JSO (should take precedence over SerializerOptions) + var script = skill.Scripts!.First(s => s.Name == "Lookup"); + var jso = SkillTestJsonContext.Default.Options; + var inputJson = JsonSerializer.SerializeToElement(new LookupRequest { Query = "explicit", MaxResults = 2 }, jso); + var args = new AIFunctionArguments { ["request"] = inputJson }; + var result = await script.RunAsync(skill, args, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.Contains("explicit", result!.ToString()!); + Assert.Contains("2", result!.ToString()!); + } + + [Fact] + public async Task CreateResource_UsesExplicitJso_OverSerializerOptionsAsync() + { + // Arrange + var skill = new CreateMethodsExplicitJsoSkill(); - public override IReadOnlyList? Resources => null; + // Act — read resource that passes explicit JSO (should take precedence over SerializerOptions) + var resource = skill.Resources!.First(r => r.Name == "config"); + var result = await resource.ReadAsync(); - public override IReadOnlyList? Scripts => null; + // Assert + Assert.NotNull(result); + Assert.Contains("explicit-theme", result!.ToString()!); } - private sealed class FullClassSkill : AgentClassSkill + [Fact] + public void DuplicateResourceNames_FromProperties_ThrowsInvalidOperationException() { - private IReadOnlyList? _resources; - private IReadOnlyList? _scripts; + // Arrange + var skill = new DuplicateResourcePropertiesSkill(); + + // Act & Assert + var ex = Assert.Throws(() => _ = skill.Resources); + Assert.Contains("data", ex.Message); + Assert.Contains("already has a resource", ex.Message); + } + + [Fact] + public void DuplicateResourceNames_FromPropertyAndMethod_ThrowsInvalidOperationException() + { + // Arrange + var skill = new DuplicateResourcePropertyAndMethodSkill(); + + // Act & Assert + var ex = Assert.Throws(() => _ = skill.Resources); + Assert.Contains("data", ex.Message); + Assert.Contains("already has a resource", ex.Message); + } + + [Fact] + public void DuplicateResourceNames_FromMethods_ThrowsInvalidOperationException() + { + // Arrange + var skill = new DuplicateResourceMethodsSkill(); + + // Act & Assert + var ex = Assert.Throws(() => _ = skill.Resources); + Assert.Contains("data", ex.Message); + Assert.Contains("already has a resource", ex.Message); + } + + [Fact] + public void DuplicateScriptNames_ThrowsInvalidOperationException() + { + // Arrange + var skill = new DuplicateScriptsSkill(); + + // Act & Assert + var ex = Assert.Throws(() => _ = skill.Scripts); + Assert.Contains("do-work", ex.Message); + Assert.Contains("already has a script", ex.Message); + } + + #region Test skill classes + + private sealed class MinimalClassSkill : AgentClassSkill + { + public override AgentSkillFrontmatter Frontmatter { get; } = new("minimal", "A minimal skill."); + + protected override string Instructions => "Minimal skill body."; + } + private sealed class FullClassSkill : AgentClassSkill + { public override AgentSkillFrontmatter Frontmatter { get; } = new("full", "A full skill with resources and scripts."); protected override string Instructions => "Full skill body."; - public override IReadOnlyList? Resources => this._resources ??= + public override IReadOnlyList? Resources => [ - CreateResource("test-resource", "resource content"), + this.CreateResource("test-resource", "resource content"), ]; - public override IReadOnlyList? Scripts => this._scripts ??= + public override IReadOnlyList? Scripts => [ - CreateScript("TestScript", TestScript), + this.CreateScript("TestScript", TestScript), ]; private static string TestScript(double value) => JsonSerializer.Serialize(new { result = value * 2 }); } - private sealed class ResourceOnlySkill : AgentClassSkill + private sealed class ResourceOnlySkill : AgentClassSkill { - private IReadOnlyList? _resources; - public override AgentSkillFrontmatter Frontmatter { get; } = new("resource-only", "Skill with resources only."); protected override string Instructions => "Body."; - public override IReadOnlyList? Resources => this._resources ??= + public override IReadOnlyList? Resources => [ - CreateResource("data", "some data"), + this.CreateResource("data", "some data"), ]; - - public override IReadOnlyList? Scripts => null; } - private sealed class ScriptOnlySkill : AgentClassSkill + private sealed class ScriptOnlySkill : AgentClassSkill { - private IReadOnlyList? _scripts; - public override AgentSkillFrontmatter Frontmatter { get; } = new("script-only", "Skill with scripts only."); protected override string Instructions => "Body."; - public override IReadOnlyList? Resources => null; - - public override IReadOnlyList? Scripts => this._scripts ??= + public override IReadOnlyList? Scripts => [ - CreateScript("ToUpper", (string input) => input.ToUpperInvariant()), + this.CreateScript("ToUpper", (string input) => input.ToUpperInvariant()), ]; } - private sealed class DerivedResourceSkill : AgentClassSkill + private sealed class LazyLoadedSkill : AgentClassSkill { + public override AgentSkillFrontmatter Frontmatter { get; } = new("lazy-loaded", "Skill with lazily created resources and scripts."); + + protected override string Instructions => "Body."; + + public int ResourceCreationCount { get; private set; } + + public int ScriptCreationCount { get; private set; } + private IReadOnlyList? _resources; + private IReadOnlyList? _scripts; + + public override IReadOnlyList? Resources => this._resources ??= this.CreateResources(); + + public override IReadOnlyList? Scripts => this._scripts ??= this.CreateScripts(); + + private IReadOnlyList CreateResources() + { + this.ResourceCreationCount++; + return [this.CreateResource("lazy-resource", "resource content")]; + } + + private IReadOnlyList CreateScripts() + { + this.ScriptCreationCount++; + return [this.CreateScript("LazyScript", () => "done")]; + } + } - public override AgentSkillFrontmatter Frontmatter { get; } = new("derived-resource", "Skill with a derived resource type."); + private sealed class CustomTypeSkill : AgentClassSkill + { + public override AgentSkillFrontmatter Frontmatter { get; } = new("custom-type-skill", "Skill with custom-typed scripts and resources."); protected override string Instructions => "Body."; - public override IReadOnlyList? Resources => this._resources ??= + public override IReadOnlyList? Resources => [ - new CustomResource("custom-resource", "Custom resource description."), + this.CreateResource("config", () => new SkillConfig + { + Theme = "dark", + Verbose = true + }, serializerOptions: SkillTestJsonContext.Default.Options), ]; - public override IReadOnlyList? Scripts => null; + public override IReadOnlyList? Scripts => + [ + this.CreateScript("Lookup", (LookupRequest request) => new LookupResponse + { + Items = [$"result for {request.Query}"], + TotalCount = request.MaxResults, + }, serializerOptions: SkillTestJsonContext.Default.Options), + ]; } - private sealed class DerivedScriptSkill : AgentClassSkill +#pragma warning disable IDE0051 // Remove unused private members + private sealed class AttributedScriptsSkill : AgentClassSkill { - private IReadOnlyList? _scripts; - - public override AgentSkillFrontmatter Frontmatter { get; } = new("derived-script", "Skill with a derived script type."); + public override AgentSkillFrontmatter Frontmatter { get; } = new("attributed-scripts", "Skill with various attributed scripts."); protected override string Instructions => "Body."; - public override IReadOnlyList? Resources => null; + [AgentSkillScript("do-work")] + private static string DoWork(string input) => input.ToUpperInvariant(); - public override IReadOnlyList? Scripts => this._scripts ??= - [ - new CustomScript("custom-script", "Custom script description."), - ]; + [AgentSkillScript] + private static string DefaultNamed(string input) => input.ToUpperInvariant(); + + [AgentSkillScript("process")] + [Description("Processes the input.")] + private static string Process(string input) => input; + + [AgentSkillScript("append")] + private string Append(string input) => input + "-suffix"; } - private sealed class LazyLoadedSkill : AgentClassSkill + private sealed class AttributedResourcePropertiesSkill : AgentClassSkill { - private IReadOnlyList? _resources; - private IReadOnlyList? _scripts; + public override AgentSkillFrontmatter Frontmatter { get; } = new("attributed-resource-props", "Skill with various attributed resource properties."); - public override AgentSkillFrontmatter Frontmatter { get; } = new("lazy-loaded", "Skill with lazily created resources and scripts."); + protected override string Instructions => "Body."; + + [AgentSkillResource("ref-data")] + public string ReferenceData => "Reference content."; + + [AgentSkillResource] + public string DefaultNamed => "Some data."; + + [AgentSkillResource("data")] + [Description("Some important data.")] + public string DescribedData => "content"; + + [AgentSkillResource("static-data")] + public static string StaticData => "Static content."; + } + + private sealed class AttributedResourceMethodsSkill : AgentClassSkill + { + public override AgentSkillFrontmatter Frontmatter { get; } = new("attributed-resource-methods", "Skill with various attributed resource methods."); protected override string Instructions => "Body."; - public int ResourceCreationCount { get; private set; } + [AgentSkillResource("dynamic")] + private static string GetDynamic() => "dynamic-value"; - public int ScriptCreationCount { get; private set; } + [AgentSkillResource] + private static string GetData() => "data"; - public override IReadOnlyList? Resources => this._resources ??= this.CreateResources(); + [AgentSkillResource("info")] + [Description("Returns runtime info.")] + private static string GetInfo() => "runtime-info"; - public override IReadOnlyList? Scripts => this._scripts ??= this.CreateScripts(); + [AgentSkillResource("instance-dynamic")] + private string GetValue() => "instance-method-value"; + } - private IReadOnlyList CreateResources() - { - this.ResourceCreationCount++; - return [CreateResource("lazy-resource", "resource content")]; - } + private sealed class AttributedFullSkill : AgentClassSkill + { + public override AgentSkillFrontmatter Frontmatter { get; } = new("attributed-full", "Full skill with attributed resources and scripts."); - private IReadOnlyList CreateScripts() - { - this.ScriptCreationCount++; - return [CreateScript("LazyScript", () => "done")]; - } + protected override string Instructions => "Convert units using the table."; + + [AgentSkillResource("conversion-table")] + public string ConversionTable => "miles -> km: 1.60934"; + + [AgentSkillScript("convert")] + private static string Convert(double value, double factor) => + JsonSerializer.Serialize(new { result = value * factor }); } - private sealed class CustomResource : AgentSkillResource + private sealed class NoAttributesNoOverridesSkill : AgentClassSkill { - public CustomResource(string name, string? description = null) - : base(name, description) - { - } + public override AgentSkillFrontmatter Frontmatter { get; } = new("no-attrs", "Skill with no attributes or overrides."); - public override Task ReadAsync(IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default) - => Task.FromResult("resource-value"); + protected override string Instructions => "Body."; } - private sealed class CustomScript : AgentSkillScript + private sealed class AttributedWithOverrideSkill : AgentClassSkill { - public CustomScript(string name, string? description = null) - : base(name, description) - { - } + public override AgentSkillFrontmatter Frontmatter { get; } = new("attributed-override", "Skill with attributes and overrides."); + + protected override string Instructions => "Body."; + + // These attributes should be ignored because Resources/Scripts are overridden. + [AgentSkillResource("ignored-resource")] + public string IgnoredData => "ignored"; - public override Task RunAsync(AgentSkill skill, AIFunctionArguments arguments, CancellationToken cancellationToken = default) - => Task.FromResult("script-result"); + [AgentSkillScript("ignored-script")] + private static string IgnoredScript() => "ignored"; + + public override IReadOnlyList? Resources => + [ + this.CreateResource("manual-resource", "manual content"), + ]; + + public override IReadOnlyList? Scripts => + [ + this.CreateScript("ManualScript", () => "manual result"), + ]; } - #endregion + private sealed class AttributedResourceDynamicPropertySkill : AgentClassSkill + { + public override AgentSkillFrontmatter Frontmatter { get; } = new("attributed-resource-dynamic-prop", "Skill with dynamic property resource."); - [Fact] - public async Task CreateScript_WithSerializerOptions_DeserializesCustomInputTypeAsync() + protected override string Instructions => "Body."; + + public int CallCount { get; private set; } + + [AgentSkillResource("counter")] + public string Counter => $"call-{++this.CallCount}"; + } + + private sealed class AttributedSkillWithCustomSerializer : AgentClassSkill { - // Arrange - var skill = new CustomTypeSkill(); - var jso = SkillTestJsonContext.Default.Options; + public override AgentSkillFrontmatter Frontmatter { get; } = new("attributed-custom-jso", "Skill with custom serializer options."); - // Act — pass a custom type as JSON; the JSO enables deserialization - var script = skill.Scripts![0]; - var inputJson = JsonSerializer.SerializeToElement(new LookupRequest { Query = "test", MaxResults = 5 }, jso); - var args = new AIFunctionArguments { ["request"] = inputJson }; - var result = await script.RunAsync(skill, args, CancellationToken.None); + protected override string Instructions => "Body."; - // Assert — the custom input type was deserialized and the response was produced - Assert.NotNull(result); - var resultText = result!.ToString()!; - Assert.Contains("result for test", resultText); - Assert.Contains("5", resultText); + protected override JsonSerializerOptions? SerializerOptions => SkillTestJsonContext.Default.Options; + + [AgentSkillResource("config")] + public SkillConfig Config => new() { Theme = "light", Verbose = false }; + + [AgentSkillScript("lookup")] + private static LookupResponse Lookup(LookupRequest request) => new() + { + Items = [$"result for {request.Query}"], + TotalCount = request.MaxResults, + }; } - [Fact] - public async Task CreateResource_WithSerializerOptions_SerializesReturnsCustomTypeAsync() + private sealed class MixedStaticInstanceSkill : AgentClassSkill { - // Arrange - var skill = new CustomTypeSkill(); + public override AgentSkillFrontmatter Frontmatter { get; } = new("mixed-static-instance", "Skill with both static and instance members."); - // Act - var result = await skill.Resources![0].ReadAsync(); + protected override string Instructions => "Body."; - // Assert — the custom type was returned successfully - Assert.NotNull(result); - Assert.Contains("dark", result!.ToString()!); + [AgentSkillResource("static-resource")] + public static string StaticResource => "static-value"; + + [AgentSkillResource("instance-resource")] + public string InstanceResource => "instance-data"; + + [AgentSkillScript("static-script")] + private static string StaticScript() => "static-result"; + + [AgentSkillScript("instance-script")] + private string InstanceScript() => "instance-data"; } - private sealed class CustomTypeSkill : AgentClassSkill + private sealed class CreateMethodsFallbackSkill : AgentClassSkill { - public override AgentSkillFrontmatter Frontmatter { get; } = new("custom-type-skill", "Skill with custom-typed scripts and resources."); + public override AgentSkillFrontmatter Frontmatter { get; } = new("create-fallback", "Skill testing SerializerOptions fallback for CreateScript/CreateResource."); protected override string Instructions => "Body."; + protected override JsonSerializerOptions? SerializerOptions => SkillTestJsonContext.Default.Options; + public override IReadOnlyList? Resources => [ - CreateResource("config", () => new SkillConfig + this.CreateResource("config", () => new SkillConfig { Theme = "dark", - Verbose = true + Verbose = true, + }), + ]; + + public override IReadOnlyList? Scripts => + [ + this.CreateScript("Lookup", (LookupRequest request) => new LookupResponse + { + Items = [$"result for {request.Query}"], + TotalCount = request.MaxResults, + }), + ]; + } + + private sealed class CreateMethodsExplicitJsoSkill : AgentClassSkill + { + public override AgentSkillFrontmatter Frontmatter { get; } = new("create-explicit-jso", "Skill testing explicit JSO overrides SerializerOptions."); + + protected override string Instructions => "Body."; + + // SerializerOptions is intentionally null — explicit JSO passed to CreateScript/CreateResource should be used. + public override IReadOnlyList? Resources => + [ + this.CreateResource("config", () => new SkillConfig + { + Theme = "explicit-theme", + Verbose = false, }, serializerOptions: SkillTestJsonContext.Default.Options), ]; public override IReadOnlyList? Scripts => [ - CreateScript("Lookup", (LookupRequest request) => new LookupResponse + this.CreateScript("Lookup", (LookupRequest request) => new LookupResponse { Items = [$"result for {request.Query}"], TotalCount = request.MaxResults, }, serializerOptions: SkillTestJsonContext.Default.Options), ]; } + + private sealed class IndexerResourceSkill : AgentClassSkill + { + public override AgentSkillFrontmatter Frontmatter { get; } = new("indexer-skill", "Skill with indexer resource."); + + protected override string Instructions => "Body."; + + private readonly Dictionary _data = new() { ["key"] = "value" }; + + [AgentSkillResource("indexed")] + public string this[string key] => this._data[key]; + } + + private sealed class UnsupportedParamResourceMethodSkill : AgentClassSkill + { + public override AgentSkillFrontmatter Frontmatter { get; } = new("unsupported-param-skill", "Skill with unsupported param resource method."); + + protected override string Instructions => "Body."; + + [AgentSkillResource("bad-resource")] + private static string GetData(string content) => content; + } + + private sealed class ServiceProviderResourceMethodSkill : AgentClassSkill + { + public override AgentSkillFrontmatter Frontmatter { get; } = new("sp-param-skill", "Skill with IServiceProvider param resource method."); + + protected override string Instructions => "Body."; + + [AgentSkillResource("sp-resource")] + private static string GetData(IServiceProvider? sp) => "from-sp-method"; + } + + private sealed class CancellationTokenResourceMethodSkill : AgentClassSkill + { + public override AgentSkillFrontmatter Frontmatter { get; } = new("ct-param-skill", "Skill with CancellationToken param resource method."); + + protected override string Instructions => "Body."; + + [AgentSkillResource("ct-resource")] + private static string GetData(CancellationToken ct) => "from-ct-method"; + } + + private sealed class BothParamsResourceMethodSkill : AgentClassSkill + { + public override AgentSkillFrontmatter Frontmatter { get; } = new("both-param-skill", "Skill with both IServiceProvider and CancellationToken param resource method."); + + protected override string Instructions => "Body."; + + [AgentSkillResource("both-resource")] + private static string GetData(IServiceProvider? sp, CancellationToken ct) => "from-both-method"; + } + private sealed class DuplicateResourcePropertiesSkill : AgentClassSkill + { + public override AgentSkillFrontmatter Frontmatter { get; } = new("dup-res-props", "Skill with duplicate resource property names."); + + protected override string Instructions => "Body."; + + [AgentSkillResource("data")] + public string Data1 => "value1"; + + [AgentSkillResource("data")] + public string Data2 => "value2"; + } + + private sealed class DuplicateResourcePropertyAndMethodSkill : AgentClassSkill + { + public override AgentSkillFrontmatter Frontmatter { get; } = new("dup-res-prop-method", "Skill with duplicate resource from property and method."); + + protected override string Instructions => "Body."; + + [AgentSkillResource("data")] + public string Data => "property-value"; + + [AgentSkillResource("data")] + private static string GetData() => "method-value"; + } + + private sealed class DuplicateResourceMethodsSkill : AgentClassSkill + { + public override AgentSkillFrontmatter Frontmatter { get; } = new("dup-res-methods", "Skill with duplicate resource method names."); + + protected override string Instructions => "Body."; + + [AgentSkillResource("data")] + private static string GetData1() => "value1"; + + [AgentSkillResource("data")] + private static string GetData2() => "value2"; + } + + private sealed class DuplicateScriptsSkill : AgentClassSkill + { + public override AgentSkillFrontmatter Frontmatter { get; } = new("dup-scripts", "Skill with duplicate script names."); + + protected override string Instructions => "Body."; + + [AgentSkillScript("do-work")] + private static string DoWork1(string input) => input.ToUpperInvariant(); + + [AgentSkillScript("do-work")] + private static string DoWork2(string input) => input + "-suffix"; + } +#pragma warning restore IDE0051 // Remove unused private members + + #endregion } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentInlineSkillResourceTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentInlineSkillResourceTests.cs index 3901e61c7c..46724ca9b5 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentInlineSkillResourceTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentInlineSkillResourceTests.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Reflection; using System.Threading; using System.Threading.Tasks; @@ -167,4 +168,58 @@ public async Task ReadAsync_SupportsCancellationTokenAsync() // Assert Assert.Equal("value", result); } + + [Fact] + public void Constructor_MethodInfo_SetsNameAndDescription() + { + // Arrange + var method = typeof(AgentInlineSkillResourceTests).GetMethod(nameof(StaticResourceHelper), BindingFlags.NonPublic | BindingFlags.Static)!; + + // Act + var resource = new AgentInlineSkillResource("method-resource", method, target: null, description: "A method resource."); + + // Assert + Assert.Equal("method-resource", resource.Name); + Assert.Equal("A method resource.", resource.Description); + } + + [Fact] + public async Task ReadAsync_MethodInfo_StaticMethod_ReturnsValueAsync() + { + // Arrange + var method = typeof(AgentInlineSkillResourceTests).GetMethod(nameof(StaticResourceHelper), BindingFlags.NonPublic | BindingFlags.Static)!; + var resource = new AgentInlineSkillResource("static-method-res", method, target: null); + + // Act + var result = await resource.ReadAsync(); + + // Assert + Assert.Equal("static-resource-value", result?.ToString()); + } + + [Fact] + public async Task ReadAsync_MethodInfo_InstanceMethod_ReturnsValueAsync() + { + // Arrange + var method = typeof(AgentInlineSkillResourceTests).GetMethod(nameof(InstanceResourceHelper), BindingFlags.NonPublic | BindingFlags.Instance)!; + var resource = new AgentInlineSkillResource("instance-method-res", method, target: this); + + // Act + var result = await resource.ReadAsync(); + + // Assert + Assert.Equal("instance-resource-value", result?.ToString()); + } + + [Fact] + public void Constructor_MethodInfo_NullMethod_Throws() + { + // Act & Assert + Assert.Throws(() => + new AgentInlineSkillResource("my-res", null!, target: null)); + } + + private static string StaticResourceHelper() => "static-resource-value"; + + private string InstanceResourceHelper() => "instance-resource-value"; } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentInlineSkillScriptTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentInlineSkillScriptTests.cs index 5d5dc5bd02..efab3b2c7d 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentInlineSkillScriptTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentInlineSkillScriptTests.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Reflection; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -152,4 +153,77 @@ public async Task RunAsync_StringParameter_WorksAsync() // Assert Assert.Equal("hello world", result?.ToString()); } + + [Fact] + public void Constructor_MethodInfo_SetsNameAndDescription() + { + // Arrange + var method = typeof(AgentInlineSkillScriptTests).GetMethod(nameof(StaticScriptHelper), BindingFlags.NonPublic | BindingFlags.Static)!; + + // Act + var script = new AgentInlineSkillScript("method-script", method, target: null, description: "A method script."); + + // Assert + Assert.Equal("method-script", script.Name); + Assert.Equal("A method script.", script.Description); + } + + [Fact] + public async Task RunAsync_MethodInfo_StaticMethod_InvokesAndReturnsAsync() + { + // Arrange + var method = typeof(AgentInlineSkillScriptTests).GetMethod(nameof(StaticScriptHelper), BindingFlags.NonPublic | BindingFlags.Static)!; + var script = new AgentInlineSkillScript("static-method-script", method, target: null); + var skill = new AgentInlineSkill("test-skill", "Test.", "Instructions."); + var args = new AIFunctionArguments { ["input"] = "hello" }; + + // Act + var result = await script.RunAsync(skill, args, CancellationToken.None); + + // Assert + Assert.Equal("HELLO", result?.ToString()); + } + + [Fact] + public async Task RunAsync_MethodInfo_InstanceMethod_InvokesAndReturnsAsync() + { + // Arrange + var method = typeof(AgentInlineSkillScriptTests).GetMethod(nameof(InstanceScriptHelper), BindingFlags.NonPublic | BindingFlags.Instance)!; + var script = new AgentInlineSkillScript("instance-method-script", method, target: this); + var skill = new AgentInlineSkill("test-skill", "Test.", "Instructions."); + var args = new AIFunctionArguments { ["input"] = "test" }; + + // Act + var result = await script.RunAsync(skill, args, CancellationToken.None); + + // Assert + Assert.Equal("test-suffix", result?.ToString()); + } + + [Fact] + public void Constructor_MethodInfo_NullMethod_Throws() + { + // Act & Assert + Assert.Throws(() => + new AgentInlineSkillScript("my-script", null!, target: null)); + } + + [Fact] + public void ParametersSchema_MethodInfo_ContainsParameterNames() + { + // Arrange + var method = typeof(AgentInlineSkillScriptTests).GetMethod(nameof(StaticScriptHelper), BindingFlags.NonPublic | BindingFlags.Static)!; + var script = new AgentInlineSkillScript("param-script", method, target: null); + + // Act + var schema = script.ParametersSchema; + + // Assert + Assert.NotNull(schema); + Assert.Contains("input", schema!.Value.GetRawText()); + } + + private static string StaticScriptHelper(string input) => input.ToUpperInvariant(); + + private string InstanceScriptHelper(string input) => input + "-suffix"; } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillResourceAttributeTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillResourceAttributeTests.cs new file mode 100644 index 0000000000..e4dee59c88 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillResourceAttributeTests.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.UnitTests.AgentSkills; + +/// +/// Unit tests for . +/// +public sealed class AgentSkillResourceAttributeTests +{ + [Fact] + public void DefaultConstructor_NameIsNull() + { + // Arrange & Act + var attr = new AgentSkillResourceAttribute(); + + // Assert + Assert.Null(attr.Name); + } + + [Fact] + public void NamedConstructor_SetsName() + { + // Arrange & Act + var attr = new AgentSkillResourceAttribute("my-resource"); + + // Assert + Assert.Equal("my-resource", attr.Name); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillScriptAttributeTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillScriptAttributeTests.cs new file mode 100644 index 0000000000..937c05e8a1 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillScriptAttributeTests.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.UnitTests.AgentSkills; + +/// +/// Unit tests for . +/// +public sealed class AgentSkillScriptAttributeTests +{ + [Fact] + public void DefaultConstructor_NameIsNull() + { + // Arrange & Act + var attr = new AgentSkillScriptAttribute(); + + // Assert + Assert.Null(attr.Name); + } + + [Fact] + public void NamedConstructor_SetsName() + { + // Arrange & Act + var attr = new AgentSkillScriptAttribute("my-script"); + + // Assert + Assert.Equal("my-script", attr.Name); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderTests.cs index e86eb0894a..23c2745247 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderTests.cs @@ -871,7 +871,7 @@ public async Task Constructor_ClassSkillsParams_ProvidesSkillsAsync() public async Task Constructor_ClassSkillsEnumerable_ProvidesSkillsAsync() { // Arrange - var skills = new List + var skills = new List { new TestClassSkill("enum-class-a", "Class A", "Instructions A."), new TestClassSkill("enum-class-b", "Class B", "Instructions B."), @@ -928,7 +928,7 @@ public override Task> GetSkillsAsync(CancellationToken cancell } } - private sealed class TestClassSkill : AgentClassSkill + private sealed class TestClassSkill : AgentClassSkill { private readonly string _instructions;