// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Reflection;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
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.
///
///
/// 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>).
///
///
///
///
/// // 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;
///
/// public override AgentSkillFrontmatter Frontmatter { get; } = new("pdf-formatter", "Format documents as PDF.");
/// protected override string Instructions => "Use this skill to format documents...";
///
/// public override IReadOnlyList<AgentSkillResource>? Resources => this._resources ??=
/// [
/// CreateResource("template", "Use this template..."),
/// ];
///
/// public override IReadOnlyList<AgentSkillScript>? Scripts => this._scripts ??=
/// [
/// CreateScript("format-pdf", FormatPdf),
/// ];
///
/// private static string FormatPdf(string content) => content;
/// }
///
///
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
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 readonly Lazy?> _resources;
private readonly Lazy?> _scripts;
private readonly Lazy _content;
///
/// Initializes a new instance of the class.
///
protected AgentClassSkill()
{
this._resources = new Lazy?>(this.DiscoverResources);
this._scripts = new Lazy?>(this.DiscoverScripts);
this._content = new Lazy(() => AgentInlineSkillContentBuilder.Build(
this.Frontmatter.Name,
this.Frontmatter.Description,
this.Instructions,
this.Resources,
this.Scripts));
}
///
/// 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.
/// The result is cached after the first access. Override to provide custom content.
///
public override ValueTask GetContentAsync(CancellationToken cancellationToken = default) => new(this._content.Value);
///
/// Gets the resources associated with this skill, or if none.
///
///
/// The default implementation 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.
/// Override this property in derived classes to provide skill-specific resources.
///
public virtual IReadOnlyList? Resources => this._resources.Value;
///
/// Gets the scripts associated with this skill, or if none.
///
///
/// The default implementation 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.
/// Override this property in derived classes to provide skill-specific scripts.
///
public virtual IReadOnlyList? Scripts => this._scripts.Value;
///
public sealed override ValueTask GetResourceAsync(string name, CancellationToken cancellationToken = default)
{
var resource = this.Resources?.FirstOrDefault(r => r.Name == name);
return new(resource);
}
///
public sealed override ValueTask GetScriptAsync(string name, CancellationToken cancellationToken = default)
{
var script = this.Scripts?.FirstOrDefault(s => s.Name == name);
return new(script);
}
///
/// Creates a skill resource backed by a static value.
///
/// The resource name.
/// The static resource value.
/// An optional description of the resource.
/// A new instance.
protected AgentSkillResource CreateResource(string name, object value, string? description = null)
=> new AgentInlineSkillResource(name, value, description);
///
/// Creates a skill resource backed by a delegate that produces a dynamic value.
///
/// The resource name.
/// A method that produces the resource value when requested.
/// An optional description of the resource.
///
/// Optional used to marshal the delegate's parameters and return value.
/// When , falls back to .
///
/// A new instance.
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.
///
/// The script name.
/// A method to execute when the script is invoked.
/// An optional description of the script.
///
/// Optional used to marshal the delegate's parameters and return value.
/// When , falls back to .
///
/// A new instance.
protected AgentSkillScript CreateScript(string name, Delegate method, string? description = null, JsonSerializerOptions? serializerOptions = null)
=> new AgentInlineSkillScript(name, method, description, serializerOptions ?? this.SerializerOptions);
private List? DiscoverResources()
{
List? resources = null;
var selfType = typeof(TSelf);
// Discover resources from properties annotated with [AgentSkillResource].
foreach (var property in selfType.GetProperties(DiscoveryBindingFlags))
{
var attr = property.GetCustomAttribute();
if (attr is null)
{
continue;
}
var getter = property.GetGetMethod(nonPublic: true);
if (getter is null)
{
continue;
}
// Indexer properties have getter parameters and cannot be used as resources
// because ReadAsync invokes the underlying AIFunction with no named arguments.
if (getter.GetParameters().Length > 0)
{
throw new InvalidOperationException(
$"Property '{property.Name}' on type '{selfType.Name}' is an indexer and cannot be used as a skill resource. " +
"Remove the [AgentSkillResource] attribute or use a non-indexer property.");
}
var name = attr.Name ?? property.Name;
if (resources?.Exists(r => r.Name == name) == true)
{
throw new InvalidOperationException($"Skill '{this.Frontmatter.Name}' already has a resource named '{name}'. Ensure each [AgentSkillResource] has a unique name.");
}
resources ??= [];
resources.Add(new AgentInlineSkillResource(
name: name,
method: getter,
target: getter.IsStatic ? null : this,
description: property.GetCustomAttribute()?.Description,
serializerOptions: this.SerializerOptions));
}
// Discover resources from methods annotated with [AgentSkillResource].
foreach (var method in selfType.GetMethods(DiscoveryBindingFlags))
{
var attr = method.GetCustomAttribute();
if (attr is null)
{
continue;
}
ValidateResourceMethodParameters(method, selfType);
var name = attr.Name ?? method.Name;
if (resources?.Exists(r => r.Name == name) == true)
{
throw new InvalidOperationException($"Skill '{this.Frontmatter.Name}' already has a resource named '{name}'. Ensure each [AgentSkillResource] has a unique name.");
}
resources ??= [];
resources.Add(new AgentInlineSkillResource(
name: name,
method: method,
target: method.IsStatic ? null : this,
description: method.GetCustomAttribute()?.Description,
serializerOptions: this.SerializerOptions));
}
return resources;
}
private static void ValidateResourceMethodParameters(MethodInfo method, Type skillType)
{
foreach (var param in method.GetParameters())
{
if (param.ParameterType != typeof(IServiceProvider) &&
param.ParameterType != typeof(CancellationToken))
{
throw new InvalidOperationException(
$"Method '{method.Name}' on type '{skillType.Name}' has parameter '{param.Name}' of type " +
$"'{param.ParameterType}' which cannot be supplied when reading a resource. " +
"Resource methods may only accept IServiceProvider and/or CancellationToken parameters. " +
"Remove the [AgentSkillResource] attribute or change the method signature.");
}
}
}
private List? DiscoverScripts()
{
List? scripts = null;
foreach (var method in typeof(TSelf).GetMethods(DiscoveryBindingFlags))
{
var attr = method.GetCustomAttribute();
if (attr is null)
{
continue;
}
var name = attr.Name ?? method.Name;
if (scripts?.Exists(s => s.Name == name) == true)
{
throw new InvalidOperationException($"Skill '{this.Frontmatter.Name}' already has a script named '{name}'. Ensure each [AgentSkillScript] has a unique name.");
}
scripts ??= [];
scripts.Add(new AgentInlineSkillScript(
name: name,
method: method,
target: method.IsStatic ? null : this,
description: method.GetCustomAttribute()?.Description,
serializerOptions: this.SerializerOptions));
}
return scripts;
}
}