Files
agent-framework/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProvider.cs
08541ee5a9 .NET: [Breaking] Refactor AgentSkill API to async resource and script lookup (#6030)
* .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>
2026-05-25 17:16:03 +00:00

400 lines
17 KiB
C#

// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Security;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// An <see cref="AIContextProvider"/> that exposes agent skills from one or more <see cref="AgentSkillsSource"/> instances.
/// </summary>
/// <remarks>
/// <para>
/// This provider implements the progressive disclosure pattern from the
/// <see href="https://agentskills.io/">Agent Skills specification</see>:
/// </para>
/// <list type="number">
/// <item><description><strong>Advertise</strong> — skill names and descriptions are injected into the system prompt.</description></item>
/// <item><description><strong>Load</strong> — the full skill body is returned via the <c>load_skill</c> tool.</description></item>
/// <item><description><strong>Read resources</strong> — supplementary content is read on demand via the <c>read_skill_resource</c> tool.</description></item>
/// <item><description><strong>Run scripts</strong> — scripts are executed via the <c>run_skill_script</c> tool (when scripts exist).</description></item>
/// </list>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed partial class AgentSkillsProvider : AIContextProvider
{
/// <summary>
/// Placeholder token for the generated skills list in the prompt template.
/// </summary>
private const string SkillsPlaceholder = "{skills}";
/// <summary>
/// Placeholder token for the script instructions in the prompt template.
/// </summary>
private const string ScriptInstructionsPlaceholder = "{script_instructions}";
/// <summary>
/// Placeholder token for the resource instructions in the prompt template.
/// </summary>
private const string ResourceInstructionsPlaceholder = "{resource_instructions}";
private const string DefaultSkillsInstructionPrompt =
"""
You have access to skills containing domain-specific knowledge and capabilities.
Each skill provides specialized instructions, reference documents, and assets for specific tasks.
<available_skills>
{skills}
</available_skills>
When a task aligns with a skill's domain, follow these steps in exact order:
- Use `load_skill` to retrieve the skill's instructions.
- Follow the provided guidance.
{resource_instructions}
{script_instructions}
Only load what is needed, when it is needed.
""";
private readonly AgentSkillsSource _source;
private readonly AgentSkillsProviderOptions? _options;
private readonly ILogger<AgentSkillsProvider> _logger;
private Task<AIContext>? _contextTask;
/// <summary>
/// Initializes a new instance of the <see cref="AgentSkillsProvider"/> class
/// that discovers file-based skills from a single directory.
/// Duplicate skill names are automatically deduplicated (first occurrence wins).
/// </summary>
/// <param name="skillPath">Path to search for skills.</param>
/// <param name="scriptRunner">Optional delegate that runs file-based scripts. Required only when skills contain scripts.</param>
/// <param name="fileOptions">Optional options that control skill discovery behavior.</param>
/// <param name="options">Optional provider configuration.</param>
/// <param name="loggerFactory">Optional logger factory.</param>
public AgentSkillsProvider(
string skillPath,
AgentFileSkillScriptRunner? scriptRunner = null,
AgentFileSkillsSourceOptions? fileOptions = null,
AgentSkillsProviderOptions? options = null,
ILoggerFactory? loggerFactory = null)
: this([Throw.IfNull(skillPath)], scriptRunner, fileOptions, options, loggerFactory)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="AgentSkillsProvider"/> class
/// that discovers file-based skills from multiple directories.
/// Duplicate skill names are automatically deduplicated (first occurrence wins).
/// </summary>
/// <param name="skillPaths">Paths to search for skills.</param>
/// <param name="scriptRunner">Optional delegate that runs file-based scripts. Required only when skills contain scripts.</param>
/// <param name="fileOptions">Optional options that control skill discovery behavior.</param>
/// <param name="options">Optional provider configuration.</param>
/// <param name="loggerFactory">Optional logger factory.</param>
public AgentSkillsProvider(
IEnumerable<string> skillPaths,
AgentFileSkillScriptRunner? scriptRunner = null,
AgentFileSkillsSourceOptions? fileOptions = null,
AgentSkillsProviderOptions? options = null,
ILoggerFactory? loggerFactory = null)
: this(
new DeduplicatingAgentSkillsSource(
new AgentFileSkillsSource(skillPaths, scriptRunner, fileOptions, loggerFactory),
loggerFactory),
options,
loggerFactory)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="AgentSkillsProvider"/> class.
/// Duplicate skill names are automatically deduplicated (first occurrence wins).
/// </summary>
/// <param name="skills">The skills to include.</param>
public AgentSkillsProvider(params AgentSkill[] skills)
: this(skills as IEnumerable<AgentSkill>)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="AgentSkillsProvider"/> class.
/// Duplicate skill names are automatically deduplicated (first occurrence wins).
/// </summary>
/// <param name="skills">The skills to include.</param>
/// <param name="options">Optional provider configuration.</param>
/// <param name="loggerFactory">Optional logger factory.</param>
public AgentSkillsProvider(
IEnumerable<AgentSkill> skills,
AgentSkillsProviderOptions? options = null,
ILoggerFactory? loggerFactory = null)
: this(
new DeduplicatingAgentSkillsSource(
new AgentInMemorySkillsSource(Throw.IfNull(skills)),
loggerFactory),
options,
loggerFactory)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="AgentSkillsProvider"/> class
/// from a custom <see cref="AgentSkillsSource"/>. Unlike other constructors, this one does not
/// apply automatic deduplication, allowing callers to customize deduplication behavior via the source pipeline.
/// </summary>
/// <param name="source">The skill source providing skills.</param>
/// <param name="options">Optional configuration.</param>
/// <param name="loggerFactory">Optional logger factory.</param>
public AgentSkillsProvider(AgentSkillsSource source, AgentSkillsProviderOptions? options = null, ILoggerFactory? loggerFactory = null)
{
this._source = Throw.IfNull(source);
this._options = options;
this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger<AgentSkillsProvider>();
if (options?.SkillsInstructionPrompt is string prompt)
{
ValidatePromptTemplate(prompt, nameof(options));
}
}
/// <inheritdoc />
protected override async ValueTask<AIContext> ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default)
{
if (this._options?.DisableCaching == true)
{
return await this.CreateContextAsync(context, cancellationToken).ConfigureAwait(false);
}
return await this.GetOrCreateContextAsync(context, cancellationToken).ConfigureAwait(false);
}
private async Task<AIContext> CreateContextAsync(InvokingContext context, CancellationToken cancellationToken)
{
var skills = await this._source.GetSkillsAsync(cancellationToken).ConfigureAwait(false);
if (skills is not { Count: > 0 })
{
return await base.ProvideAIContextAsync(context, cancellationToken).ConfigureAwait(false);
}
return new AIContext
{
Instructions = this.BuildSkillsInstructions(skills),
Tools = this.BuildTools(skills),
};
}
private async Task<AIContext> GetOrCreateContextAsync(InvokingContext context, CancellationToken cancellationToken)
{
var tcs = new TaskCompletionSource<AIContext>(TaskCreationOptions.RunContinuationsAsynchronously);
if (Interlocked.CompareExchange(ref this._contextTask, tcs.Task, null) is { } existing)
{
return await existing.ConfigureAwait(false);
}
try
{
var result = await this.CreateContextAsync(context, cancellationToken).ConfigureAwait(false);
tcs.SetResult(result);
return result;
}
catch (Exception ex)
{
this._contextTask = null;
tcs.TrySetException(ex);
throw;
}
}
private IList<AIFunction> BuildTools(IList<AgentSkill> skills)
{
IList<AIFunction> tools =
[
AIFunctionFactory.Create(
(string skillName, CancellationToken cancellationToken) => this.LoadSkillAsync(skills, skillName, cancellationToken),
name: "load_skill",
description: "Loads the full content of a specific skill"),
AIFunctionFactory.Create(
(string skillName, string resourceName, IServiceProvider? serviceProvider, CancellationToken cancellationToken = default) =>
this.ReadSkillResourceAsync(skills, skillName, resourceName, serviceProvider, cancellationToken),
name: "read_skill_resource",
description: "Reads a resource associated with a skill, such as references, assets, or dynamic data."),
];
AIFunction scriptFunction = AIFunctionFactory.Create(
(string skillName, string scriptName, JsonElement? arguments = null, IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default) =>
this.RunSkillScriptAsync(skills, skillName, scriptName, arguments, serviceProvider, cancellationToken),
name: "run_skill_script",
description: "Runs a script associated with a skill.");
if (this._options?.ScriptApproval == true)
{
return [.. tools, new ApprovalRequiredAIFunction(scriptFunction)];
}
return [.. tools, scriptFunction];
}
private string? BuildSkillsInstructions(IList<AgentSkill> skills)
{
string promptTemplate = this._options?.SkillsInstructionPrompt ?? DefaultSkillsInstructionPrompt;
var sb = new StringBuilder();
foreach (var skill in skills.OrderBy(s => s.Frontmatter.Name, StringComparer.Ordinal))
{
sb.AppendLine(" <skill>");
sb.AppendLine($" <name>{SecurityElement.Escape(skill.Frontmatter.Name)}</name>");
sb.AppendLine($" <description>{SecurityElement.Escape(skill.Frontmatter.Description)}</description>");
sb.AppendLine(" </skill>");
}
const string ResourceInstruction =
"""
- Use `read_skill_resource` to read any referenced resources, using the name exactly as listed
(e.g. `"style-guide"` not `"style-guide.md"`, `"references/FAQ.md"` not `"FAQ.md"`).
""";
const string ScriptInstruction = "- Use `run_skill_script` to run referenced scripts, using the name exactly as listed.";
return new StringBuilder(promptTemplate)
.Replace(SkillsPlaceholder, sb.ToString().TrimEnd())
.Replace(ResourceInstructionsPlaceholder, ResourceInstruction)
.Replace(ScriptInstructionsPlaceholder, ScriptInstruction)
.ToString();
}
private async Task<string> LoadSkillAsync(IList<AgentSkill> skills, string skillName, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(skillName))
{
return "Error: Skill name cannot be empty.";
}
var skill = skills.FirstOrDefault(skill => skill.Frontmatter.Name == skillName);
if (skill == null)
{
return $"Error: Skill '{skillName}' not found.";
}
LogSkillLoading(this._logger, skillName);
return await skill.GetContentAsync(cancellationToken).ConfigureAwait(false);
}
private async Task<object?> ReadSkillResourceAsync(IList<AgentSkill> skills, string skillName, string resourceName, IServiceProvider? serviceProvider, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(skillName))
{
return "Error: Skill name cannot be empty.";
}
if (string.IsNullOrWhiteSpace(resourceName))
{
return "Error: Resource name cannot be empty.";
}
var skill = skills.FirstOrDefault(skill => skill.Frontmatter.Name == skillName);
if (skill == null)
{
return $"Error: Skill '{skillName}' not found.";
}
try
{
var resource = await skill.GetResourceAsync(resourceName, cancellationToken).ConfigureAwait(false);
if (resource is null)
{
return $"Error: Resource '{resourceName}' not found in skill '{skillName}'.";
}
return await resource.ReadAsync(serviceProvider, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
LogResourceReadError(this._logger, skillName, resourceName, ex);
return $"Error: Failed to read resource '{resourceName}' from skill '{skillName}'.";
}
}
private async Task<object?> RunSkillScriptAsync(IList<AgentSkill> skills, string skillName, string scriptName, JsonElement? arguments = null, IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(skillName))
{
return "Error: Skill name cannot be empty.";
}
if (string.IsNullOrWhiteSpace(scriptName))
{
return "Error: Script name cannot be empty.";
}
var skill = skills.FirstOrDefault(skill => skill.Frontmatter.Name == skillName);
if (skill == null)
{
return $"Error: Skill '{skillName}' not found.";
}
try
{
var script = await skill.GetScriptAsync(scriptName, cancellationToken).ConfigureAwait(false);
if (script is null)
{
return $"Error: Script '{scriptName}' not found in skill '{skillName}'.";
}
return await script.RunAsync(skill, arguments, serviceProvider, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
LogScriptExecutionError(this._logger, skillName, scriptName, ex);
return $"Error: Failed to execute script '{scriptName}' from skill '{skillName}'.";
}
}
/// <summary>
/// Validates that a custom prompt template contains the required placeholder tokens.
/// </summary>
private static void ValidatePromptTemplate(string template, string paramName)
{
if (template.IndexOf(SkillsPlaceholder, StringComparison.Ordinal) < 0)
{
throw new ArgumentException(
$"The custom prompt template must contain the '{SkillsPlaceholder}' placeholder for the generated skills list.",
paramName);
}
if (template.IndexOf(ResourceInstructionsPlaceholder, StringComparison.Ordinal) < 0)
{
throw new ArgumentException(
$"The custom prompt template must contain the '{ResourceInstructionsPlaceholder}' placeholder for resource instructions.",
paramName);
}
if (template.IndexOf(ScriptInstructionsPlaceholder, StringComparison.Ordinal) < 0)
{
throw new ArgumentException(
$"The custom prompt template must contain the '{ScriptInstructionsPlaceholder}' placeholder for script instructions.",
paramName);
}
}
[LoggerMessage(LogLevel.Information, "Loading skill: {SkillName}")]
private static partial void LogSkillLoading(ILogger logger, string skillName);
[LoggerMessage(LogLevel.Error, "Failed to read resource '{ResourceName}' from skill '{SkillName}'")]
private static partial void LogResourceReadError(ILogger logger, string skillName, string resourceName, Exception exception);
[LoggerMessage(LogLevel.Error, "Failed to execute script '{ScriptName}' from skill '{SkillName}'")]
private static partial void LogScriptExecutionError(ILogger logger, string skillName, string scriptName, Exception exception);
}