From ecdedccf8dd162e957837a620cdabc408b302cc6 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Date: Thu, 9 Apr 2026 10:50:02 +0000 Subject: [PATCH 1/5] support reflection for discovery of resources and scripts in class-based skills --- .../Agent_Step03_ClassBasedSkills.csproj | 2 +- .../Agent_Step03_ClassBasedSkills/Program.cs | 75 +- .../Agent_Step03_ClassBasedSkills/README.md | 8 +- .../Agent_Step04_MixedSkills/Program.cs | 6 +- .../Agent_Step05_SkillsWithDI/Program.cs | 6 +- .../Skills/AgentSkillsProviderBuilder.cs | 2 +- .../Skills/Programmatic/AgentClassSkill.cs | 208 ++++- .../Programmatic/AgentInlineSkillResource.cs | 23 + .../Programmatic/AgentInlineSkillScript.cs | 22 + .../AgentSkillResourceAttribute.cs | 73 ++ .../Programmatic/AgentSkillScriptAttribute.cs | 72 ++ .../AgentSkills/AgentClassSkillTests.cs | 729 +++++++++++++----- .../AgentInlineSkillResourceTests.cs | 55 ++ .../AgentInlineSkillScriptTests.cs | 74 ++ .../AgentSkillResourceAttributeTests.cs | 29 + .../AgentSkillScriptAttributeTests.cs | 29 + .../AgentSkills/AgentSkillsProviderTests.cs | 4 +- 17 files changed, 1172 insertions(+), 245 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentSkillResourceAttribute.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentSkillScriptAttribute.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillResourceAttributeTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillScriptAttributeTests.cs 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/Program.cs b/dotnet/samples/02-agents/AgentSkills/Agent_Step04_MixedSkills/Program.cs index ab5da71a3c..04c4386e6c 100644 --- a/dotnet/samples/02-agents/AgentSkills/Agent_Step04_MixedSkills/Program.cs +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step04_MixedSkills/Program.cs @@ -91,7 +91,7 @@ 1. Review the volume-conversion-table resource to find the correct factor. /// /// A temperature-converter skill defined as a C# class. /// -internal sealed class TemperatureConverterSkill : AgentClassSkill +internal sealed class TemperatureConverterSkill : AgentClassSkill { private IReadOnlyList? _resources; private IReadOnlyList? _scripts; @@ -113,7 +113,7 @@ 3. Present the result clearly with both temperature scales. /// public override IReadOnlyList? Resources => this._resources ??= [ - CreateResource( + this.CreateResource( "temperature-conversion-formulas", """ # Temperature Conversion Formulas @@ -130,7 +130,7 @@ 3. Present the result clearly with both temperature scales. /// public override IReadOnlyList? Scripts => this._scripts ??= [ - CreateScript("convert-temperature", ConvertTemperature), + this.CreateScript("convert-temperature", ConvertTemperature), ]; private static string ConvertTemperature(double value, string from, string to) 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..33225eeabc 100644 --- a/dotnet/samples/02-agents/AgentSkills/Agent_Step05_SkillsWithDI/Program.cs +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step05_SkillsWithDI/Program.cs @@ -116,7 +116,7 @@ Use this skill when the user asks to convert between distance units (miles and k /// in both its resource and script functions. This enables clean separation of /// concerns and testability while retaining the class-based skill pattern. /// -internal sealed class WeightConverterSkill : AgentClassSkill +internal sealed class WeightConverterSkill : AgentClassSkill { private IReadOnlyList? _resources; private IReadOnlyList? _scripts; @@ -138,7 +138,7 @@ 3. Present the result clearly with both units. /// public override IReadOnlyList? Resources => this._resources ??= [ - CreateResource("weight-table", (IServiceProvider serviceProvider) => + this.CreateResource("weight-table", (IServiceProvider serviceProvider) => { var service = serviceProvider.GetRequiredService(); return service.GetWeightTable(); @@ -148,7 +148,7 @@ 3. Present the result clearly with both units. /// public override IReadOnlyList? Scripts => this._scripts ??= [ - CreateScript("convert", (double value, double factor, IServiceProvider serviceProvider) => + this.CreateScript("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..e07f1d3f09 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentClassSkill.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentClassSkill.cs @@ -1,7 +1,10 @@ // 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 Microsoft.Extensions.AI; using Microsoft.Shared.DiagnosticIds; @@ -11,17 +14,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 +85,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 +132,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 +181,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 +192,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 +206,84 @@ 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; + } + + resources ??= []; + resources.Add(new AgentInlineSkillResource( + name: attr.Name ?? property.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; + } + + resources ??= []; + resources.Add(new AgentInlineSkillResource( + name: attr.Name ?? method.Name, + method: method, + target: method.IsStatic ? null : this, + description: method.GetCustomAttribute()?.Description, + serializerOptions: this.SerializerOptions)); + } + + return resources; + } + + private List? DiscoverScripts() + { + List? scripts = null; + + foreach (var method in typeof(TSelf).GetMethods(DiscoveryBindingFlags)) + { + var attr = method.GetCustomAttribute(); + if (attr is null) + { + continue; + } + + scripts ??= []; + scripts.Add(new AgentInlineSkillScript( + name: attr.Name ?? method.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..31aa6ab04a 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentClassSkillTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentClassSkillTests.cs @@ -2,6 +2,9 @@ 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; @@ -10,56 +13,48 @@ 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,175 +81,420 @@ 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 = skill.Content; - var second = skill.Content; + 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 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); + + // 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); + } + } - // Assert — scripts with typed parameters should have their schema included - Assert.Contains("parameters_schema", content); - Assert.Contains("value", content); + [Fact] + public async Task SerializerOptions_UsedForReflectedMembersAsync() + { + // Arrange + 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_IncludesDerivedResources_WhenResourcesUseBaseTypeOverrides() + public void Content_IncludesDescription_ForReflectedResources() { // Arrange - var skill = new DerivedResourceSkill(); + 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 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); + // Assert - Assert.Contains("", content); - Assert.Contains("custom-resource", content); - Assert.Contains("Custom resource description.", content); + Assert.NotNull(result); + Assert.Contains("fallback", result!.ToString()!); + Assert.Contains("7", result!.ToString()!); } [Fact] - public void Content_IncludesDerivedScripts_WhenScriptsUseBaseTypeOverrides() + public async Task CreateResource_FallsBackToSerializerOptions_WhenNoExplicitJsoAsync() { // Arrange - var skill = new DerivedScriptSkill(); + var skill = new CreateMethodsFallbackSkill(); - // Act - var content = skill.Content; + // 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.Contains("", content); - Assert.Contains("custom-script", content); - Assert.Contains("Custom script description.", content); + Assert.NotNull(result); + Assert.Contains("explicit", result!.ToString()!); + Assert.Contains("2", result!.ToString()!); } [Fact] - public void Content_OmitsParametersSchema_WhenDerivedScriptDoesNotProvideOne() + public async Task CreateResource_UsesExplicitJso_OverSerializerOptionsAsync() { // Arrange - var skill = new DerivedScriptSkill(); + var skill = new CreateMethodsExplicitJsoSkill(); - // Act - var content = skill.Content; + // 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(); // Assert - Assert.DoesNotContain("parameters_schema", content); + Assert.NotNull(result); + Assert.Contains("explicit-theme", result!.ToString()!); } #region Test skill classes - private sealed class MinimalClassSkill : AgentClassSkill + private sealed class MinimalClassSkill : AgentClassSkill { public override AgentSkillFrontmatter Frontmatter { get; } = new("minimal", "A minimal skill."); protected override string Instructions => "Minimal skill body."; - - public override IReadOnlyList? Resources => null; - - public override IReadOnlyList? Scripts => null; } - private sealed class FullClassSkill : AgentClassSkill + private sealed class FullClassSkill : AgentClassSkill { - private IReadOnlyList? _resources; - private IReadOnlyList? _scripts; - 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"), ]; - public override IReadOnlyList? Scripts => this._scripts ??= + public override IReadOnlyList? Scripts => [ CreateScript("TestScript", TestScript), ]; @@ -263,170 +503,281 @@ 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"), ]; - - 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()), ]; } - 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 [CreateResource("lazy-resource", "resource content")]; + } - public override AgentSkillFrontmatter Frontmatter { get; } = new("derived-resource", "Skill with a derived resource type."); + private IReadOnlyList CreateScripts() + { + this.ScriptCreationCount++; + return [CreateScript("LazyScript", () => "done")]; + } + } + + 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."), + CreateResource("config", () => new SkillConfig + { + Theme = "dark", + Verbose = true + }, serializerOptions: SkillTestJsonContext.Default.Options), ]; - public override IReadOnlyList? Scripts => null; + public override IReadOnlyList? Scripts => + [ + 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."); - public override Task RunAsync(AgentSkill skill, AIFunctionArguments arguments, CancellationToken cancellationToken = default) - => Task.FromResult("script-result"); + protected override string Instructions => "Body."; + + // These attributes should be ignored because Resources/Scripts are overridden. + [AgentSkillResource("ignored-resource")] + public string IgnoredData => "ignored"; + + [AgentSkillScript("ignored-script")] + private static string IgnoredScript() => "ignored"; + + public override IReadOnlyList? Resources => + [ + CreateResource("manual-resource", "manual content"), + ]; + + public override IReadOnlyList? Scripts => + [ + 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 { Theme = "dark", - Verbose = true + Verbose = true, + }), + ]; + + public override IReadOnlyList? Scripts => + [ + 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 => + [ + CreateResource("config", () => new SkillConfig + { + Theme = "explicit-theme", + Verbose = false, }, serializerOptions: SkillTestJsonContext.Default.Options), ]; @@ -439,4 +790,6 @@ private sealed class CustomTypeSkill : AgentClassSkill }, serializerOptions: SkillTestJsonContext.Default.Options), ]; } + + #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..8fc1e4d716 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", (MethodInfo)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; From f7a37c7440f4f8ea80f6e4de9e3dd10d4062010f Mon Sep 17 00:00:00 2001 From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Date: Thu, 9 Apr 2026 11:08:43 +0000 Subject: [PATCH 2/5] fix format issues --- .../AgentSkills/AgentClassSkillTests.cs | 28 +++++++++---------- .../AgentInlineSkillResourceTests.cs | 2 +- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentClassSkillTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentClassSkillTests.cs index 31aa6ab04a..8cb149139b 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentClassSkillTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentClassSkillTests.cs @@ -491,12 +491,12 @@ private sealed class FullClassSkill : AgentClassSkill public override IReadOnlyList? Resources => [ - CreateResource("test-resource", "resource content"), + this.CreateResource("test-resource", "resource content"), ]; public override IReadOnlyList? Scripts => [ - CreateScript("TestScript", TestScript), + this.CreateScript("TestScript", TestScript), ]; private static string TestScript(double value) => @@ -511,7 +511,7 @@ private sealed class ResourceOnlySkill : AgentClassSkill public override IReadOnlyList? Resources => [ - CreateResource("data", "some data"), + this.CreateResource("data", "some data"), ]; } @@ -523,7 +523,7 @@ private sealed class ScriptOnlySkill : AgentClassSkill public override IReadOnlyList? Scripts => [ - CreateScript("ToUpper", (string input) => input.ToUpperInvariant()), + this.CreateScript("ToUpper", (string input) => input.ToUpperInvariant()), ]; } @@ -547,13 +547,13 @@ private sealed class LazyLoadedSkill : AgentClassSkill private IReadOnlyList CreateResources() { this.ResourceCreationCount++; - return [CreateResource("lazy-resource", "resource content")]; + return [this.CreateResource("lazy-resource", "resource content")]; } private IReadOnlyList CreateScripts() { this.ScriptCreationCount++; - return [CreateScript("LazyScript", () => "done")]; + return [this.CreateScript("LazyScript", () => "done")]; } } @@ -565,7 +565,7 @@ private sealed class CustomTypeSkill : AgentClassSkill public override IReadOnlyList? Resources => [ - CreateResource("config", () => new SkillConfig + this.CreateResource("config", () => new SkillConfig { Theme = "dark", Verbose = true @@ -574,7 +574,7 @@ private sealed class CustomTypeSkill : AgentClassSkill 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, @@ -679,12 +679,12 @@ private sealed class AttributedWithOverrideSkill : AgentClassSkill? Resources => [ - CreateResource("manual-resource", "manual content"), + this.CreateResource("manual-resource", "manual content"), ]; public override IReadOnlyList? Scripts => [ - CreateScript("ManualScript", () => "manual result"), + this.CreateScript("ManualScript", () => "manual result"), ]; } @@ -748,7 +748,7 @@ private sealed class CreateMethodsFallbackSkill : AgentClassSkill? Resources => [ - CreateResource("config", () => new SkillConfig + this.CreateResource("config", () => new SkillConfig { Theme = "dark", Verbose = true, @@ -757,7 +757,7 @@ private sealed class CreateMethodsFallbackSkill : AgentClassSkill? Scripts => [ - CreateScript("Lookup", (LookupRequest request) => new LookupResponse + this.CreateScript("Lookup", (LookupRequest request) => new LookupResponse { Items = [$"result for {request.Query}"], TotalCount = request.MaxResults, @@ -774,7 +774,7 @@ private sealed class CreateMethodsExplicitJsoSkill : AgentClassSkill? Resources => [ - CreateResource("config", () => new SkillConfig + this.CreateResource("config", () => new SkillConfig { Theme = "explicit-theme", Verbose = false, @@ -783,7 +783,7 @@ private sealed class CreateMethodsExplicitJsoSkill : AgentClassSkill? Scripts => [ - CreateScript("Lookup", (LookupRequest request) => new LookupResponse + this.CreateScript("Lookup", (LookupRequest request) => new LookupResponse { Items = [$"result for {request.Query}"], TotalCount = request.MaxResults, diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentInlineSkillResourceTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentInlineSkillResourceTests.cs index 8fc1e4d716..46724ca9b5 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentInlineSkillResourceTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentInlineSkillResourceTests.cs @@ -216,7 +216,7 @@ public void Constructor_MethodInfo_NullMethod_Throws() { // Act & Assert Assert.Throws(() => - new AgentInlineSkillResource("my-res", (MethodInfo)null!, target: null)); + new AgentInlineSkillResource("my-res", null!, target: null)); } private static string StaticResourceHelper() => "static-resource-value"; From 5d8e5d52cd91dffd06049905b0aef9743882e4e1 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Date: Thu, 9 Apr 2026 11:21:59 +0000 Subject: [PATCH 3/5] refactor samples to use reflection --- .../Agent_Step04_MixedSkills.csproj | 2 +- .../Agent_Step04_MixedSkills/Program.cs | 55 ++++++++++--------- .../Agent_Step05_SkillsWithDI.csproj | 2 +- .../Agent_Step05_SkillsWithDI/Program.cs | 52 +++++++++--------- 4 files changed, 57 insertions(+), 54 deletions(-) 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 04c4386e6c..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. /// +/// +/// 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 ??= - [ - this.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 ??= - [ - this.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 33225eeabc..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 { - 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 ??= - [ - this.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 ??= - [ - this.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); + } } // --------------------------------------------------------------------------- From 6848c900f3d266174efd02cfefbdb8c3de587765 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Date: Thu, 9 Apr 2026 12:04:21 +0000 Subject: [PATCH 4/5] Validate resource member signatures during discovery Add discovery-time validation in AgentClassSkill.DiscoverResources() to fail fast when [AgentSkillResource] is applied to members with incompatible signatures: - Reject indexer properties (getter has parameters) - Reject methods with parameters other than IServiceProvider or CancellationToken Throws InvalidOperationException with actionable error messages instead of allowing silent runtime failures when ReadAsync invokes the AIFunction with no named arguments. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Skills/Programmatic/AgentClassSkill.cs | 28 ++++ .../AgentSkills/AgentClassSkillTests.cs | 133 ++++++++++++++++++ 2 files changed, 161 insertions(+) diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentClassSkill.cs b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentClassSkill.cs index e07f1d3f09..3a4c1e30c8 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentClassSkill.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentClassSkill.cs @@ -6,6 +6,7 @@ using System.Diagnostics.CodeAnalysis; using System.Reflection; using System.Text.Json; +using System.Threading; using Microsoft.Extensions.AI; using Microsoft.Shared.DiagnosticIds; @@ -233,6 +234,15 @@ protected AgentSkillScript CreateScript(string name, Delegate method, string? de 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."); + } + resources ??= []; resources.Add(new AgentInlineSkillResource( name: attr.Name ?? property.Name, @@ -251,6 +261,8 @@ protected AgentSkillScript CreateScript(string name, Delegate method, string? de continue; } + ValidateResourceMethodParameters(method, selfType); + resources ??= []; resources.Add(new AgentInlineSkillResource( name: attr.Name ?? method.Name, @@ -263,6 +275,22 @@ protected AgentSkillScript CreateScript(string name, Delegate method, string? de 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; diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentClassSkillTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentClassSkillTests.cs index 8cb149139b..869d9b4343 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentClassSkillTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentClassSkillTests.cs @@ -9,6 +9,7 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; namespace Microsoft.Agents.AI.UnitTests.AgentSkills; @@ -406,6 +407,86 @@ public void Content_IncludesDescription_ForReflectedResources() 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.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 async Task ResourceMethodWithCancellationTokenParam_IsDiscoveredSuccessfullyAsync() + { + // Arrange + var skill = new CancellationTokenResourceMethodSkill(); + + // Act + var resources = skill.Resources; + + // Assert + 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 async Task ResourceMethodWithBothServiceProviderAndCancellationToken_IsDiscoveredSuccessfullyAsync() + { + // Arrange + var skill = new BothParamsResourceMethodSkill(); + var sp = new ServiceCollection().BuildServiceProvider(); + + // Act + var resources = skill.Resources; + + // Assert + 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()); + } + [Fact] public async Task CreateScript_FallsBackToSerializerOptions_WhenNoExplicitJsoAsync() { @@ -791,5 +872,57 @@ private sealed class CreateMethodsExplicitJsoSkill : 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"; + } + #endregion } From 6a2de81c7001eb731e9aca2d73675c2fb9ff5794 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Date: Thu, 9 Apr 2026 12:57:44 +0000 Subject: [PATCH 5/5] prevent duplicates --- .../Skills/Programmatic/AgentClassSkill.cs | 24 ++++- .../AgentSkills/AgentClassSkillTests.cs | 100 ++++++++++++++++++ 2 files changed, 121 insertions(+), 3 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentClassSkill.cs b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentClassSkill.cs index 3a4c1e30c8..b44f423bc2 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentClassSkill.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentClassSkill.cs @@ -243,9 +243,15 @@ protected AgentSkillScript CreateScript(string name, Delegate method, string? de "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: attr.Name ?? property.Name, + name: name, method: getter, target: getter.IsStatic ? null : this, description: property.GetCustomAttribute()?.Description, @@ -263,9 +269,15 @@ protected AgentSkillScript CreateScript(string name, Delegate method, string? de 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: attr.Name ?? method.Name, + name: name, method: method, target: method.IsStatic ? null : this, description: method.GetCustomAttribute()?.Description, @@ -303,9 +315,15 @@ private static void ValidateResourceMethodParameters(MethodInfo method, Type ski 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: attr.Name ?? method.Name, + name: name, method: method, target: method.IsStatic ? null : this, description: method.GetCustomAttribute()?.Description, diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentClassSkillTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentClassSkillTests.cs index 869d9b4343..1dc7d5b3f9 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentClassSkillTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentClassSkillTests.cs @@ -555,6 +555,54 @@ public async Task CreateResource_UsesExplicitJso_OverSerializerOptionsAsync() Assert.Contains("explicit-theme", result!.ToString()!); } + [Fact] + public void DuplicateResourceNames_FromProperties_ThrowsInvalidOperationException() + { + // 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 @@ -923,6 +971,58 @@ private sealed class BothParamsResourceMethodSkill : AgentClassSkill "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 }