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
///
/// 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
}