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