mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
* .NET: Refactor AgentSkill API to async resource and script lookup Replace property-based AgentSkill.Content, Resources, and Scripts with async-by-name lookup methods plus boolean availability flags: - Content (string getter) -> GetContentAsync(CancellationToken) - Resources (full list) -> HasResources + GetResourceAsync(name, ct) - Scripts (full list) -> HasScripts + GetScriptAsync(name, ct) This makes the API friendlier for sources like MCP where enumerating all resources up front is expensive or impossible, and allows skill implementations to fetch content lazily. Subclass changes: - AgentFileSkill and AgentInlineSkill implement the new async API while preserving content caching. - AgentClassSkill<TSelf> keeps virtual Resources/Scripts properties for reflection-based discovery and seals the new HasResources/HasScripts/ GetResourceAsync/GetScriptAsync overrides. Its previously non-thread-safe lazy initialization is replaced with Lazy<T> (default thread-safety) wired up in a new protected constructor, so concurrent first-access from multiple threads is safe. - AgentSkillsProvider calls the new async API and exposes ead_skill_resource / load_skill / un_skill_script tools that await the per-name lookups. Includes baseline CompatibilitySuppressions.xml entries for the removed property getters. Tests: - Direct coverage for HasResources, HasScripts, GetResourceAsync, and GetScriptAsync on all three skill implementations (positive, missing-name, and no-resources/no-scripts cases). - Thread-safety regression test for AgentClassSkill<TSelf> that exercises concurrent first-access to Resources, Scripts, and GetContentAsync from many tasks and asserts all observers see the same cached instance. - Provider-level coverage for the ead_skill_resource tool (invocation + error paths) and for the previously untested error paths of load_skill and un_skill_script (empty names, skill/resource/script not found). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address PR review comments - Move GetScriptAsync inside try/catch in RunSkillScriptAsync for error-handling parity - Remove dead _reflectedResources branch from AgentSkillTestExtensions - Fix XML docs to reference virtual Resources/Scripts properties (not sealed methods) - Add Async suffix to async test methods per naming convention - Make no-await tests synchronous to eliminate CS1998 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix formatting: add UTF-8 BOM and remove unused using Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix XML cref: Resources/Scripts are on AgentClassSkill<TSelf> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Remove HasResources and HasScripts properties from AgentSkill Drop the virtual HasResources and HasScripts properties from AgentSkill and all concrete subclasses (AgentFileSkill, AgentInlineSkill, AgentClassSkill). AgentSkillsProvider now always includes all three tools (load_skill, read_skill_resource, run_skill_script) and both instruction blocks, since the tools already handle missing resources/scripts gracefully. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add blank line for readability in file-based skills sample Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix HostedAgentSkillsPatternTests for always-included tools Update assertions to expect read_skill_resource and run_skill_script tools are always present, matching the new behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
342 lines
16 KiB
C#
342 lines
16 KiB
C#
// 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;
|
|
|
|
/// <summary>
|
|
/// Abstract base class for defining skills as C# classes that bundle all components together.
|
|
/// </summary>
|
|
/// <typeparam name="TSelf">
|
|
/// The concrete skill type. This type parameter is annotated with
|
|
/// <see cref="DynamicallyAccessedMembersAttribute"/> to ensure that the IL trimmer and Native AOT compiler
|
|
/// preserve the members needed for attribute-based discovery.
|
|
/// </typeparam>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Inherit from this class to create a self-contained skill definition. Override the abstract
|
|
/// properties to provide name, description, and instructions.
|
|
/// </para>
|
|
/// <para>
|
|
/// Scripts and resources can be defined in two ways:
|
|
/// <list type="bullet">
|
|
/// <item>
|
|
/// <b>Attribute-based (recommended):</b> Annotate methods with <see cref="AgentSkillScriptAttribute"/> to define scripts,
|
|
/// and properties or methods with <see cref="AgentSkillResourceAttribute"/> to define resources. These are automatically
|
|
/// discovered via reflection on <typeparamref name="TSelf"/>. This approach is compatible with Native AOT.
|
|
/// </item>
|
|
/// <item>
|
|
/// <b>Explicit override:</b> Override <see cref="Resources"/> and <see cref="Scripts"/>, using <see cref="CreateResource(string, object, string?)"/>,
|
|
/// <see cref="CreateResource(string, Delegate, string?, JsonSerializerOptions?)"/>, and <see cref="CreateScript"/> to define
|
|
/// inline resources and scripts. This approach is also compatible with Native AOT.
|
|
/// </item>
|
|
/// </list>
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Multi-level inheritance limitation:</b> Discovery reflects only on <typeparamref name="TSelf"/>,
|
|
/// 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., <c>class SpecialSkill : AgentClassSkill<SpecialSkill></c>).
|
|
/// </para>
|
|
/// </remarks>
|
|
/// <example>
|
|
/// <code>
|
|
/// // 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;
|
|
/// }
|
|
/// </code>
|
|
/// </example>
|
|
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
|
public abstract class AgentClassSkill<
|
|
[DynamicallyAccessedMembers(
|
|
DynamicallyAccessedMemberTypes.PublicProperties |
|
|
DynamicallyAccessedMemberTypes.NonPublicProperties |
|
|
DynamicallyAccessedMemberTypes.PublicMethods |
|
|
DynamicallyAccessedMemberTypes.NonPublicMethods)] TSelf>
|
|
: AgentSkill
|
|
where TSelf : AgentClassSkill<TSelf>
|
|
{
|
|
private const BindingFlags DiscoveryBindingFlags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static;
|
|
|
|
private readonly Lazy<IReadOnlyList<AgentSkillResource>?> _resources;
|
|
private readonly Lazy<IReadOnlyList<AgentSkillScript>?> _scripts;
|
|
private readonly Lazy<string> _content;
|
|
|
|
/// <summary>
|
|
/// Initializes a new instance of the <see cref="AgentClassSkill{TSelf}"/> class.
|
|
/// </summary>
|
|
protected AgentClassSkill()
|
|
{
|
|
this._resources = new Lazy<IReadOnlyList<AgentSkillResource>?>(this.DiscoverResources);
|
|
this._scripts = new Lazy<IReadOnlyList<AgentSkillScript>?>(this.DiscoverScripts);
|
|
this._content = new Lazy<string>(() => AgentInlineSkillContentBuilder.Build(
|
|
this.Frontmatter.Name,
|
|
this.Frontmatter.Description,
|
|
this.Instructions,
|
|
this.Resources,
|
|
this.Scripts));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the raw instructions text for this skill.
|
|
/// </summary>
|
|
protected abstract string Instructions { get; }
|
|
|
|
/// <summary>
|
|
/// Gets the <see cref="JsonSerializerOptions"/> used to marshal parameters and return values
|
|
/// for scripts and resources.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Override this property to provide custom serialization options. This value is used by
|
|
/// reflection-discovered scripts and resources, and also as a fallback by <see cref="CreateScript"/>
|
|
/// and <see cref="CreateResource(string, Delegate, string?, JsonSerializerOptions?)"/> when no
|
|
/// explicit <see cref="JsonSerializerOptions"/> is passed to those methods.
|
|
/// The default value is <see langword="null"/>, which causes <see cref="AIJsonUtilities.DefaultOptions"/> to be used.
|
|
/// </remarks>
|
|
protected virtual JsonSerializerOptions? SerializerOptions => null;
|
|
|
|
/// <inheritdoc/>
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
public override ValueTask<string> GetContentAsync(CancellationToken cancellationToken = default) => new(this._content.Value);
|
|
|
|
/// <summary>
|
|
/// Gets the resources associated with this skill, or <see langword="null"/> if none.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The default implementation returns resources discovered via reflection by scanning
|
|
/// <typeparamref name="TSelf"/> for members annotated with <see cref="AgentSkillResourceAttribute"/>.
|
|
/// This discovery is compatible with Native AOT because <typeparamref name="TSelf"/> is annotated with
|
|
/// <see cref="DynamicallyAccessedMembersAttribute"/>. The result is cached after the first access.
|
|
/// Override this property in derived classes to provide skill-specific resources.
|
|
/// </remarks>
|
|
public virtual IReadOnlyList<AgentSkillResource>? Resources => this._resources.Value;
|
|
|
|
/// <summary>
|
|
/// Gets the scripts associated with this skill, or <see langword="null"/> if none.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The default implementation returns scripts discovered via reflection by scanning
|
|
/// <typeparamref name="TSelf"/> for methods annotated with <see cref="AgentSkillScriptAttribute"/>.
|
|
/// This discovery is compatible with Native AOT because <typeparamref name="TSelf"/> is annotated with
|
|
/// <see cref="DynamicallyAccessedMembersAttribute"/>. The result is cached after the first access.
|
|
/// Override this property in derived classes to provide skill-specific scripts.
|
|
/// </remarks>
|
|
public virtual IReadOnlyList<AgentSkillScript>? Scripts => this._scripts.Value;
|
|
|
|
/// <inheritdoc/>
|
|
public sealed override ValueTask<AgentSkillResource?> GetResourceAsync(string name, CancellationToken cancellationToken = default)
|
|
{
|
|
var resource = this.Resources?.FirstOrDefault(r => r.Name == name);
|
|
return new(resource);
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public sealed override ValueTask<AgentSkillScript?> GetScriptAsync(string name, CancellationToken cancellationToken = default)
|
|
{
|
|
var script = this.Scripts?.FirstOrDefault(s => s.Name == name);
|
|
return new(script);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a skill resource backed by a static value.
|
|
/// </summary>
|
|
/// <param name="name">The resource name.</param>
|
|
/// <param name="value">The static resource value.</param>
|
|
/// <param name="description">An optional description of the resource.</param>
|
|
/// <returns>A new <see cref="AgentSkillResource"/> instance.</returns>
|
|
protected AgentSkillResource CreateResource(string name, object value, string? description = null)
|
|
=> new AgentInlineSkillResource(name, value, description);
|
|
|
|
/// <summary>
|
|
/// Creates a skill resource backed by a delegate that produces a dynamic value.
|
|
/// </summary>
|
|
/// <param name="name">The resource name.</param>
|
|
/// <param name="method">A method that produces the resource value when requested.</param>
|
|
/// <param name="description">An optional description of the resource.</param>
|
|
/// <param name="serializerOptions">
|
|
/// Optional <see cref="JsonSerializerOptions"/> used to marshal the delegate's parameters and return value.
|
|
/// When <see langword="null"/>, falls back to <see cref="SerializerOptions"/>.
|
|
/// </param>
|
|
/// <returns>A new <see cref="AgentSkillResource"/> instance.</returns>
|
|
protected AgentSkillResource CreateResource(string name, Delegate method, string? description = null, JsonSerializerOptions? serializerOptions = null)
|
|
=> new AgentInlineSkillResource(name, method, description, serializerOptions ?? this.SerializerOptions);
|
|
|
|
/// <summary>
|
|
/// Creates a skill script backed by a delegate.
|
|
/// </summary>
|
|
/// <param name="name">The script name.</param>
|
|
/// <param name="method">A method to execute when the script is invoked.</param>
|
|
/// <param name="description">An optional description of the script.</param>
|
|
/// <param name="serializerOptions">
|
|
/// Optional <see cref="JsonSerializerOptions"/> used to marshal the delegate's parameters and return value.
|
|
/// When <see langword="null"/>, falls back to <see cref="SerializerOptions"/>.
|
|
/// </param>
|
|
/// <returns>A new <see cref="AgentSkillScript"/> instance.</returns>
|
|
protected AgentSkillScript CreateScript(string name, Delegate method, string? description = null, JsonSerializerOptions? serializerOptions = null)
|
|
=> new AgentInlineSkillScript(name, method, description, serializerOptions ?? this.SerializerOptions);
|
|
|
|
private List<AgentSkillResource>? DiscoverResources()
|
|
{
|
|
List<AgentSkillResource>? resources = null;
|
|
|
|
var selfType = typeof(TSelf);
|
|
|
|
// Discover resources from properties annotated with [AgentSkillResource].
|
|
foreach (var property in selfType.GetProperties(DiscoveryBindingFlags))
|
|
{
|
|
var attr = property.GetCustomAttribute<AgentSkillResourceAttribute>();
|
|
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<DescriptionAttribute>()?.Description,
|
|
serializerOptions: this.SerializerOptions));
|
|
}
|
|
|
|
// Discover resources from methods annotated with [AgentSkillResource].
|
|
foreach (var method in selfType.GetMethods(DiscoveryBindingFlags))
|
|
{
|
|
var attr = method.GetCustomAttribute<AgentSkillResourceAttribute>();
|
|
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<DescriptionAttribute>()?.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<AgentSkillScript>? DiscoverScripts()
|
|
{
|
|
List<AgentSkillScript>? scripts = null;
|
|
|
|
foreach (var method in typeof(TSelf).GetMethods(DiscoveryBindingFlags))
|
|
{
|
|
var attr = method.GetCustomAttribute<AgentSkillScriptAttribute>();
|
|
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<DescriptionAttribute>()?.Description,
|
|
serializerOptions: this.SerializerOptions));
|
|
}
|
|
|
|
return scripts;
|
|
}
|
|
}
|