.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>
This commit is contained in:
SergeyMenshykh
2026-05-25 17:16:03 +00:00
committed by GitHub
co-authored by Copilot
parent dc4bafbc1e
commit 08541ee5a9
19 changed files with 979 additions and 302 deletions
@@ -24,6 +24,7 @@ string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYM
var skillsProvider = new AgentSkillsProvider(
Path.Combine(AppContext.BaseDirectory, "skills"),
SubprocessScriptRunner.RunAsync);
// --- Agent Setup ---
AIAgent agent = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential())
.GetResponsesClient()
@@ -51,7 +51,7 @@ Console.WriteLine($"Agent: {response.Text}");
/// Properties annotated with <see cref="AgentSkillResourceAttribute"/> are automatically
/// discovered as skill resources, and methods annotated with <see cref="AgentSkillScriptAttribute"/>
/// are automatically discovered as skill scripts. Alternatively,
/// <see cref="AgentSkill.Resources"/> and <see cref="AgentSkill.Scripts"/> can be overridden.
/// <see cref="AgentClassSkill{TSelf}.Resources"/> and <see cref="AgentClassSkill{TSelf}.Scripts"/> can be overridden.
/// </remarks>
internal sealed class UnitConverterSkill : AgentClassSkill<UnitConverterSkill>
{
@@ -43,6 +43,27 @@
<Right>lib/net10.0/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.AgentSkill.get_Content</Target>
<Left>lib/net10.0/Microsoft.Agents.AI.dll</Left>
<Right>lib/net10.0/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.AgentSkill.get_Resources</Target>
<Left>lib/net10.0/Microsoft.Agents.AI.dll</Left>
<Right>lib/net10.0/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.AgentSkill.get_Scripts</Target>
<Left>lib/net10.0/Microsoft.Agents.AI.dll</Left>
<Right>lib/net10.0/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken)</Target>
@@ -106,6 +127,27 @@
<Right>lib/net472/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.AgentSkill.get_Content</Target>
<Left>lib/net472/Microsoft.Agents.AI.dll</Left>
<Right>lib/net472/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.AgentSkill.get_Resources</Target>
<Left>lib/net472/Microsoft.Agents.AI.dll</Left>
<Right>lib/net472/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.AgentSkill.get_Scripts</Target>
<Left>lib/net472/Microsoft.Agents.AI.dll</Left>
<Right>lib/net472/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken)</Target>
@@ -169,6 +211,27 @@
<Right>lib/net8.0/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.AgentSkill.get_Content</Target>
<Left>lib/net8.0/Microsoft.Agents.AI.dll</Left>
<Right>lib/net8.0/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.AgentSkill.get_Resources</Target>
<Left>lib/net8.0/Microsoft.Agents.AI.dll</Left>
<Right>lib/net8.0/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.AgentSkill.get_Scripts</Target>
<Left>lib/net8.0/Microsoft.Agents.AI.dll</Left>
<Right>lib/net8.0/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken)</Target>
@@ -232,6 +295,27 @@
<Right>lib/net9.0/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.AgentSkill.get_Content</Target>
<Left>lib/net9.0/Microsoft.Agents.AI.dll</Left>
<Right>lib/net9.0/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.AgentSkill.get_Resources</Target>
<Left>lib/net9.0/Microsoft.Agents.AI.dll</Left>
<Right>lib/net9.0/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.AgentSkill.get_Scripts</Target>
<Left>lib/net9.0/Microsoft.Agents.AI.dll</Left>
<Right>lib/net9.0/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken)</Target>
@@ -295,6 +379,27 @@
<Right>lib/netstandard2.0/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.AgentSkill.get_Content</Target>
<Left>lib/netstandard2.0/Microsoft.Agents.AI.dll</Left>
<Right>lib/netstandard2.0/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.AgentSkill.get_Resources</Target>
<Left>lib/netstandard2.0/Microsoft.Agents.AI.dll</Left>
<Right>lib/netstandard2.0/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.AgentSkill.get_Scripts</Target>
<Left>lib/netstandard2.0/Microsoft.Agents.AI.dll</Left>
<Right>lib/netstandard2.0/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken)</Target>
@@ -316,6 +421,13 @@
<Right>lib/netstandard2.0/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0005</DiagnosticId>
<Target>M:Microsoft.Agents.AI.AgentSkill.GetContentAsync(System.Threading.CancellationToken)</Target>
<Left>lib/net10.0/Microsoft.Agents.AI.dll</Left>
<Right>lib/net10.0/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0005</DiagnosticId>
<Target>M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,System.Nullable{System.Text.Json.JsonElement},System.IServiceProvider,System.Threading.CancellationToken)</Target>
@@ -323,6 +435,13 @@
<Right>lib/net10.0/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0005</DiagnosticId>
<Target>M:Microsoft.Agents.AI.AgentSkill.GetContentAsync(System.Threading.CancellationToken)</Target>
<Left>lib/net472/Microsoft.Agents.AI.dll</Left>
<Right>lib/net472/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0005</DiagnosticId>
<Target>M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,System.Nullable{System.Text.Json.JsonElement},System.IServiceProvider,System.Threading.CancellationToken)</Target>
@@ -330,6 +449,13 @@
<Right>lib/net472/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0005</DiagnosticId>
<Target>M:Microsoft.Agents.AI.AgentSkill.GetContentAsync(System.Threading.CancellationToken)</Target>
<Left>lib/net8.0/Microsoft.Agents.AI.dll</Left>
<Right>lib/net8.0/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0005</DiagnosticId>
<Target>M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,System.Nullable{System.Text.Json.JsonElement},System.IServiceProvider,System.Threading.CancellationToken)</Target>
@@ -337,6 +463,13 @@
<Right>lib/net8.0/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0005</DiagnosticId>
<Target>M:Microsoft.Agents.AI.AgentSkill.GetContentAsync(System.Threading.CancellationToken)</Target>
<Left>lib/net9.0/Microsoft.Agents.AI.dll</Left>
<Right>lib/net9.0/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0005</DiagnosticId>
<Target>M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,System.Nullable{System.Text.Json.JsonElement},System.IServiceProvider,System.Threading.CancellationToken)</Target>
@@ -344,6 +477,13 @@
<Right>lib/net9.0/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0005</DiagnosticId>
<Target>M:Microsoft.Agents.AI.AgentSkill.GetContentAsync(System.Threading.CancellationToken)</Target>
<Left>lib/netstandard2.0/Microsoft.Agents.AI.dll</Left>
<Right>lib/netstandard2.0/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0005</DiagnosticId>
<Target>M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,System.Nullable{System.Text.Json.JsonElement},System.IServiceProvider,System.Threading.CancellationToken)</Target>
@@ -1,7 +1,8 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
@@ -34,29 +35,44 @@ public abstract class AgentSkill
/// <summary>
/// Gets the full skill content.
/// </summary>
/// <remarks>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>
/// For file-based skills this is the raw SKILL.md file content, optionally
/// augmented with a synthesized scripts block when scripts are present.
/// For code-defined skills this is a synthesized XML document
/// containing name, description, and body (instructions, resources, scripts).
/// </remarks>
public abstract string Content { get; }
/// </returns>
public abstract ValueTask<string> GetContentAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Gets the resources associated with this skill, or <see langword="null"/> if none.
/// Gets a resource owned by this skill by name.
/// </summary>
/// <param name="name">The resource name (e.g. an identifier or a relative path referenced inside the skill content).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>
/// The <see cref="AgentSkillResource"/>, or <see langword="null"/> when no resource with the given name exists.
/// </returns>
/// <remarks>
/// The default implementation returns <see langword="null"/>.
/// Override this property in derived classes to provide skill-specific resources.
/// The default implementation returns <see langword="null"/>. Override in derived classes that
/// expose resources.
/// </remarks>
public virtual IReadOnlyList<AgentSkillResource>? Resources => null;
public virtual ValueTask<AgentSkillResource?> GetResourceAsync(
string name,
CancellationToken cancellationToken = default) => default;
/// <summary>
/// Gets the scripts associated with this skill, or <see langword="null"/> if none.
/// Gets a script owned by this skill by name.
/// </summary>
/// <param name="name">The script name.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>
/// The <see cref="AgentSkillScript"/>, or <see langword="null"/> when no script with the given name exists.
/// </returns>
/// <remarks>
/// The default implementation returns <see langword="null"/>.
/// Override this property in derived classes to provide skill-specific scripts.
/// The default implementation returns <see langword="null"/>. Override in derived classes that
/// expose scripts.
/// </remarks>
public virtual IReadOnlyList<AgentSkillScript>? Scripts => null;
public virtual ValueTask<AgentSkillScript?> GetScriptAsync(
string name,
CancellationToken cancellationToken = default) => default;
}
@@ -186,13 +186,10 @@ public sealed partial class AgentSkillsProvider : AIContextProvider
return await base.ProvideAIContextAsync(context, cancellationToken).ConfigureAwait(false);
}
bool hasScripts = skills.Any(s => s.Scripts is { Count: > 0 });
bool hasResources = skills.Any(s => s.Resources is { Count: > 0 });
return new AIContext
{
Instructions = this.BuildSkillsInstructions(skills, includeScriptInstructions: hasScripts, hasResources),
Tools = this.BuildTools(skills, hasScripts, hasResources),
Instructions = this.BuildSkillsInstructions(skills),
Tools = this.BuildTools(skills),
};
}
@@ -219,29 +216,20 @@ public sealed partial class AgentSkillsProvider : AIContextProvider
}
}
private IList<AIFunction> BuildTools(IList<AgentSkill> skills, bool hasScripts, bool hasResources)
private IList<AIFunction> BuildTools(IList<AgentSkill> skills)
{
IList<AIFunction> tools =
[
AIFunctionFactory.Create(
(string skillName) => this.LoadSkill(skills, skillName),
(string skillName, CancellationToken cancellationToken) => this.LoadSkillAsync(skills, skillName, cancellationToken),
name: "load_skill",
description: "Loads the full content of a specific skill"),
];
if (hasResources)
{
tools.Add(AIFunctionFactory.Create(
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."));
}
if (!hasScripts)
{
return tools;
}
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) =>
@@ -257,7 +245,7 @@ public sealed partial class AgentSkillsProvider : AIContextProvider
return [.. tools, scriptFunction];
}
private string? BuildSkillsInstructions(IList<AgentSkill> skills, bool includeScriptInstructions, bool includeResourceInstructions)
private string? BuildSkillsInstructions(IList<AgentSkill> skills)
{
string promptTemplate = this._options?.SkillsInstructionPrompt ?? DefaultSkillsInstructionPrompt;
@@ -270,32 +258,29 @@ public sealed partial class AgentSkillsProvider : AIContextProvider
sb.AppendLine(" </skill>");
}
string resourceInstruction = includeResourceInstructions
? """
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"`).
"""
: string.Empty;
""";
string scriptInstruction = includeScriptInstructions
? "- Use `run_skill_script` to run referenced scripts, using the name exactly as listed."
: string.Empty;
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)
.Replace(ResourceInstructionsPlaceholder, ResourceInstruction)
.Replace(ScriptInstructionsPlaceholder, ScriptInstruction)
.ToString();
}
private string LoadSkill(IList<AgentSkill> skills, string skillName)
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);
var skill = skills.FirstOrDefault(skill => skill.Frontmatter.Name == skillName);
if (skill == null)
{
return $"Error: Skill '{skillName}' not found.";
@@ -303,7 +288,7 @@ public sealed partial class AgentSkillsProvider : AIContextProvider
LogSkillLoading(this._logger, skillName);
return skill.Content;
return await skill.GetContentAsync(cancellationToken).ConfigureAwait(false);
}
private async Task<object?> ReadSkillResourceAsync(IList<AgentSkill> skills, string skillName, string resourceName, IServiceProvider? serviceProvider, CancellationToken cancellationToken = default)
@@ -318,20 +303,20 @@ public sealed partial class AgentSkillsProvider : AIContextProvider
return "Error: Resource name cannot be empty.";
}
var skill = skills?.FirstOrDefault(skill => skill.Frontmatter.Name == skillName);
var skill = skills.FirstOrDefault(skill => skill.Frontmatter.Name == skillName);
if (skill == null)
{
return $"Error: Skill '{skillName}' not found.";
}
var resource = skill.Resources?.FirstOrDefault(resource => resource.Name == resourceName);
if (resource is null)
{
return $"Error: Resource '{resourceName}' not found in skill '{skillName}'.";
}
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)
@@ -353,20 +338,20 @@ public sealed partial class AgentSkillsProvider : AIContextProvider
return "Error: Script name cannot be empty.";
}
var skill = skills?.FirstOrDefault(skill => skill.Frontmatter.Name == skillName);
var skill = skills.FirstOrDefault(skill => skill.Frontmatter.Name == skillName);
if (skill == null)
{
return $"Error: Skill '{skillName}' not found.";
}
var script = skill.Scripts?.FirstOrDefault(resource => resource.Name == scriptName);
if (script is null)
{
return $"Error: Script '{scriptName}' not found in skill '{skillName}'.";
}
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)
@@ -2,6 +2,9 @@
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
@@ -50,11 +53,12 @@ public sealed class AgentFileSkill : AgentSkill
/// block is appended with a per-script entry describing the expected argument format.
/// The result is cached after the first access.
/// </remarks>
public override string Content
public override ValueTask<string> GetContentAsync(CancellationToken cancellationToken = default)
{
get => this._content ??= this._scripts is { Count: > 0 }
var content = this._content ??= this._scripts is { Count: > 0 }
? this._originalContent + AgentInlineSkillContentBuilder.BuildScriptsBlock(this._scripts)
: this._originalContent;
return new(content);
}
/// <summary>
@@ -63,8 +67,16 @@ public sealed class AgentFileSkill : AgentSkill
public string Path { get; }
/// <inheritdoc/>
public override IReadOnlyList<AgentSkillResource> Resources => this._resources;
public override ValueTask<AgentSkillResource?> GetResourceAsync(string name, CancellationToken cancellationToken = default)
{
var resource = this._resources.FirstOrDefault(r => r.Name == name);
return new(resource);
}
/// <inheritdoc/>
public override IReadOnlyList<AgentSkillScript> Scripts => this._scripts;
public override ValueTask<AgentSkillScript?> GetScriptAsync(string name, CancellationToken cancellationToken = default)
{
var script = this._scripts.FirstOrDefault(s => s.Name == name);
return new(script);
}
}
@@ -4,9 +4,11 @@ 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;
@@ -34,9 +36,9 @@ namespace Microsoft.Agents.AI;
/// discovered via reflection on <typeparamref name="TSelf"/>. This approach is compatible with Native AOT.
/// </item>
/// <item>
/// <b>Explicit override:</b> Override <see cref="AgentSkill.Resources"/> and <see cref="AgentSkill.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.
/// <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>
@@ -97,11 +99,24 @@ public abstract class AgentClassSkill<
{
private const BindingFlags DiscoveryBindingFlags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static;
private string? _content;
private bool _resourcesDiscovered;
private bool _scriptsDiscovered;
private IReadOnlyList<AgentSkillResource>? _reflectedResources;
private IReadOnlyList<AgentSkillScript>? _reflectedScripts;
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.
@@ -126,53 +141,44 @@ public abstract class AgentClassSkill<
/// 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 string Content => this._content ??= AgentInlineSkillContentBuilder.Build(
this.Frontmatter.Name,
this.Frontmatter.Description,
this.Instructions,
this.Resources,
this.Scripts);
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/>
/// <remarks>
/// 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.
/// </remarks>
public override IReadOnlyList<AgentSkillResource>? Resources
public sealed override ValueTask<AgentSkillResource?> GetResourceAsync(string name, CancellationToken cancellationToken = default)
{
get
{
if (!this._resourcesDiscovered)
{
this._reflectedResources = this.DiscoverResources();
this._resourcesDiscovered = true;
}
return this._reflectedResources;
}
var resource = this.Resources?.FirstOrDefault(r => r.Name == name);
return new(resource);
}
/// <inheritdoc/>
/// <remarks>
/// 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.
/// </remarks>
public override IReadOnlyList<AgentSkillScript>? Scripts
public sealed override ValueTask<AgentSkillScript?> GetScriptAsync(string name, CancellationToken cancellationToken = default)
{
get
{
if (!this._scriptsDiscovered)
{
this._reflectedScripts = this.DiscoverScripts();
this._scriptsDiscovered = true;
}
return this._reflectedScripts;
}
var script = this.Scripts?.FirstOrDefault(s => s.Name == name);
return new(script);
}
/// <summary>
@@ -3,7 +3,10 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
@@ -16,9 +19,9 @@ namespace Microsoft.Agents.AI;
/// <remarks>
/// All calls to <see cref="AddResource(string, object, string?)"/>,
/// <see cref="AddResource(string, Delegate, string?, JsonSerializerOptions?)"/>, and <see cref="AddScript"/>
/// must be made before the skill's <see cref="Content"/> is first accessed.
/// must be made before the skill's <see cref="GetContentAsync"/> is first called.
/// Calls made after that point will not be reflected in the generated
/// <see cref="Content"/>. In typical usage, this means configuring all
/// content. In typical usage, this means configuring all
/// resources and scripts before registering the skill with an
/// <see cref="AgentSkillsProvider"/> or <see cref="AgentSkillsProviderBuilder"/>.
/// </remarks>
@@ -90,13 +93,24 @@ public sealed class AgentInlineSkill : AgentSkill
public override AgentSkillFrontmatter Frontmatter { get; }
/// <inheritdoc/>
public override string Content => this._cachedContent ??= AgentInlineSkillContentBuilder.Build(this.Frontmatter.Name, this.Frontmatter.Description, this._instructions, this._resources, this._scripts);
public override ValueTask<string> GetContentAsync(CancellationToken cancellationToken = default)
{
return new(this._cachedContent ??= AgentInlineSkillContentBuilder.Build(this.Frontmatter.Name, this.Frontmatter.Description, this._instructions, this._resources, this._scripts));
}
/// <inheritdoc/>
public override IReadOnlyList<AgentSkillResource>? Resources => this._resources;
public override ValueTask<AgentSkillResource?> GetResourceAsync(string name, CancellationToken cancellationToken = default)
{
var resource = this._resources?.FirstOrDefault(r => r.Name == name);
return new(resource);
}
/// <inheritdoc/>
public override IReadOnlyList<AgentSkillScript>? Scripts => this._scripts;
public override ValueTask<AgentSkillScript?> GetScriptAsync(string name, CancellationToken cancellationToken = default)
{
var script = this._scripts?.FirstOrDefault(s => s.Name == name);
return new(script);
}
/// <summary>
/// Registers a static resource with this skill.
@@ -27,7 +27,7 @@ namespace Microsoft.Agents.AI;
/// </para>
/// <para>
/// This attribute is compatible with Native AOT when used with <see cref="AgentClassSkill{TSelf}"/>.
/// Alternatively, override the <see cref="AgentSkill.Resources"/> property and use
/// Alternatively, override <see cref="AgentClassSkill{TSelf}.Resources"/> and use
/// <see cref="AgentClassSkill{TSelf}.CreateResource(string, object, string?)"/> instead.
/// </para>
/// </remarks>
@@ -26,7 +26,7 @@ namespace Microsoft.Agents.AI;
/// </para>
/// <para>
/// This attribute is compatible with Native AOT when used with <see cref="AgentClassSkill{TSelf}"/>.
/// Alternatively, override the <see cref="AgentSkill.Scripts"/> property and use
/// Alternatively, override <see cref="AgentClassSkill{TSelf}.Scripts"/> and use
/// <see cref="AgentClassSkill{TSelf}.CreateScript"/> instead.
/// </para>
/// </remarks>
@@ -18,7 +18,7 @@ namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
public sealed class AgentClassSkillTests
{
[Fact]
public void MinimalClassSkill_HasNullOverrides_AndSynthesizesContent()
public async Task MinimalClassSkill_HasNullOverrides_AndSynthesizesContentAsync()
{
// Arrange
var skill = new MinimalClassSkill();
@@ -26,18 +26,17 @@ public sealed class AgentClassSkillTests
// Act & Assert — null overrides
Assert.Equal("minimal", skill.Frontmatter.Name);
Assert.Null(skill.Resources);
Assert.Null(skill.Scripts);
// Act & Assert — synthesized XML content
Assert.Contains("<name>minimal</name>", skill.Content);
Assert.Contains("<description>A minimal skill.</description>", skill.Content);
Assert.Contains("<instructions>", skill.Content);
Assert.Contains("Minimal skill body.", skill.Content);
Assert.Contains("</instructions>", skill.Content);
Assert.Contains("<name>minimal</name>", await skill.GetContentAsync());
Assert.Contains("<description>A minimal skill.</description>", await skill.GetContentAsync());
Assert.Contains("<instructions>", await skill.GetContentAsync());
Assert.Contains("Minimal skill body.", await skill.GetContentAsync());
Assert.Contains("</instructions>", await skill.GetContentAsync());
}
[Fact]
public void FullClassSkill_ReturnsOverriddenLists_AndCachesContent()
public async Task FullClassSkill_ReturnsOverriddenLists_AndCachesContentAsync()
{
// Arrange
var skill = new FullClassSkill();
@@ -50,11 +49,11 @@ public sealed class AgentClassSkillTests
Assert.Equal("TestScript", skill.Scripts![0].Name);
// Act & Assert — Content is cached
Assert.Same(skill.Content, skill.Content);
Assert.Same(await skill.GetContentAsync(), await skill.GetContentAsync());
// Act & Assert — Content includes parameter schema from typed script
Assert.Contains("parameters_schema", skill.Content);
Assert.Contains("value", skill.Content);
Assert.Contains("parameters_schema", await skill.GetContentAsync());
Assert.Contains("value", await skill.GetContentAsync());
}
[Fact]
@@ -117,6 +116,116 @@ public sealed class AgentClassSkillTests
Assert.Single(scriptOnly.Scripts!);
}
[Fact]
public async Task GetResourceAsync_ExistingName_ReturnsResourceAsync()
{
// Arrange
var skill = new FullClassSkill();
// Act
var resource = await skill.GetResourceAsync("test-resource");
// Assert
Assert.NotNull(resource);
Assert.Equal("test-resource", resource!.Name);
}
[Fact]
public async Task GetResourceAsync_NonExistingName_ReturnsNullAsync()
{
// Arrange
var skill = new FullClassSkill();
// Act
var resource = await skill.GetResourceAsync("missing");
// Assert
Assert.Null(resource);
}
[Fact]
public async Task GetResourceAsync_NoResources_ReturnsNullAsync()
{
// Arrange
var skill = new MinimalClassSkill();
// Act
var resource = await skill.GetResourceAsync("anything");
// Assert
Assert.Null(resource);
}
[Fact]
public async Task GetScriptAsync_ExistingName_ReturnsScriptAsync()
{
// Arrange
var skill = new FullClassSkill();
// Act
var script = await skill.GetScriptAsync("TestScript");
// Assert
Assert.NotNull(script);
Assert.Equal("TestScript", script!.Name);
}
[Fact]
public async Task GetScriptAsync_NonExistingName_ReturnsNullAsync()
{
// Arrange
var skill = new FullClassSkill();
// Act
var script = await skill.GetScriptAsync("missing");
// Assert
Assert.Null(script);
}
[Fact]
public async Task GetScriptAsync_NoScripts_ReturnsNullAsync()
{
// Arrange
var skill = new MinimalClassSkill();
// Act
var script = await skill.GetScriptAsync("anything");
// Assert
Assert.Null(script);
}
[Fact]
public async Task ConcurrentAccess_ToReflectedResourcesScriptsAndContent_InvokesDiscoveryOnceAsync()
{
// Regression test for thread-safety of Lazy<T> initialization in AgentClassSkill<TSelf>.
// AttributedFullSkill uses attribute-based discovery (no override), so it exercises
// the base class's Lazy<T> fields rather than a subclass's own caching.
var skill = new AttributedFullSkill();
const int Concurrency = 32;
var resourcesResults = new IReadOnlyList<AgentSkillResource>?[Concurrency];
var scriptsResults = new IReadOnlyList<AgentSkillScript>?[Concurrency];
var contentResults = new string[Concurrency];
// Act — invoke all three accessors concurrently from many threads.
await Task.WhenAll(Enumerable.Range(0, Concurrency).Select(i => Task.Run(async () =>
{
resourcesResults[i] = skill.Resources;
scriptsResults[i] = skill.Scripts;
contentResults[i] = await skill.GetContentAsync();
})));
// Assert — every thread observed the same cached instances (no torn state).
for (int i = 1; i < Concurrency; i++)
{
Assert.Same(resourcesResults[0], resourcesResults[i]);
Assert.Same(scriptsResults[0], scriptsResults[i]);
Assert.Same(contentResults[0], contentResults[i]);
}
}
[Fact]
public async Task CreateScriptAndResource_WithSerializerOptions_HandleCustomTypesAsync()
{
@@ -151,17 +260,14 @@ public sealed class AgentClassSkillTests
// Arrange
var skill = new AttributedScriptsSkill();
// Act
var scripts = skill.Scripts;
// Act & Assert — all scripts discovered with correct metadata
Assert.NotNull(skill.Scripts);
Assert.Equal(4, skill.Scripts!.Count);
Assert.Contains(skill.Scripts, s => s.Name == "do-work");
Assert.Contains(skill.Scripts, s => s.Name == "DefaultNamed");
Assert.Contains(skill.Scripts, s => s.Name == "append");
// Assert — all scripts discovered with correct metadata
Assert.NotNull(scripts);
Assert.Equal(4, scripts!.Count);
Assert.Contains(scripts, s => s.Name == "do-work");
Assert.Contains(scripts, s => s.Name == "DefaultNamed");
Assert.Contains(scripts, s => s.Name == "append");
var processScript = scripts.First(s => s.Name == "process");
var processScript = skill.Scripts.First(s => s.Name == "process");
Assert.Equal("Processes the input.", processScript.Description);
}
@@ -272,16 +378,16 @@ public sealed class AgentClassSkillTests
}
[Fact]
public void AttributedFullSkill_IncludesContentWithSchema_AndCachesMembers()
public async Task AttributedFullSkill_IncludesContentWithSchema_AndCachesMembersAsync()
{
// Arrange
var skill = new AttributedFullSkill();
// Act & Assert — Content includes reflected resources and scripts
Assert.Contains("<resources>", skill.Content);
Assert.Contains("conversion-table", skill.Content);
Assert.Contains("<scripts>", skill.Content);
Assert.Contains("convert", skill.Content);
Assert.Contains("<resources>", await skill.GetContentAsync());
Assert.Contains("conversion-table", await skill.GetContentAsync());
Assert.Contains("<scripts>", await skill.GetContentAsync());
Assert.Contains("convert", await skill.GetContentAsync());
// Act & Assert — discovered members are cached
Assert.Same(skill.Resources, skill.Resources);
@@ -299,33 +405,33 @@ public sealed class AgentClassSkillTests
// Arrange — skill with no attributes and no overrides; base discovery returns null (not empty list)
var skill = new NoAttributesNoOverridesSkill();
var baseType = typeof(AgentClassSkill<NoAttributesNoOverridesSkill>);
var resourcesDiscoveredField = baseType.GetField("_resourcesDiscovered", BindingFlags.Instance | BindingFlags.NonPublic);
var scriptsDiscoveredField = baseType.GetField("_scriptsDiscovered", BindingFlags.Instance | BindingFlags.NonPublic);
var reflectedResourcesField = baseType.GetField("_reflectedResources", BindingFlags.Instance | BindingFlags.NonPublic);
var reflectedScriptsField = baseType.GetField("_reflectedScripts", BindingFlags.Instance | BindingFlags.NonPublic);
var resourcesField = baseType.GetField("_resources", BindingFlags.Instance | BindingFlags.NonPublic);
var scriptsField = baseType.GetField("_scripts", BindingFlags.Instance | BindingFlags.NonPublic);
Assert.NotNull(resourcesDiscoveredField);
Assert.NotNull(scriptsDiscoveredField);
Assert.NotNull(reflectedResourcesField);
Assert.NotNull(reflectedScriptsField);
Assert.False((bool)resourcesDiscoveredField!.GetValue(skill)!);
Assert.False((bool)scriptsDiscoveredField!.GetValue(skill)!);
Assert.NotNull(resourcesField);
Assert.NotNull(scriptsField);
var resourcesLazy = (Lazy<IReadOnlyList<AgentSkillResource>?>)resourcesField!.GetValue(skill)!;
var scriptsLazy = (Lazy<IReadOnlyList<AgentSkillScript>?>)scriptsField!.GetValue(skill)!;
Assert.False(resourcesLazy.IsValueCreated);
Assert.False(scriptsLazy.IsValueCreated);
// Act & Assert
Assert.Null(skill.Resources);
Assert.Null(skill.Scripts);
Assert.True((bool)resourcesDiscoveredField.GetValue(skill)!);
Assert.True((bool)scriptsDiscoveredField.GetValue(skill)!);
Assert.Null(reflectedResourcesField!.GetValue(skill));
Assert.Null(reflectedScriptsField!.GetValue(skill));
Assert.True(resourcesLazy.IsValueCreated);
Assert.True(scriptsLazy.IsValueCreated);
Assert.Null(resourcesLazy.Value);
Assert.Null(scriptsLazy.Value);
// Repeated access should not re-trigger discovery even when discovered value is null.
Assert.Null(skill.Resources);
Assert.Null(skill.Scripts);
Assert.True((bool)resourcesDiscoveredField.GetValue(skill)!);
Assert.True((bool)scriptsDiscoveredField.GetValue(skill)!);
Assert.Null(reflectedResourcesField.GetValue(skill));
Assert.Null(reflectedScriptsField.GetValue(skill));
Assert.True(resourcesLazy.IsValueCreated);
Assert.True(scriptsLazy.IsValueCreated);
Assert.Null(resourcesLazy.Value);
Assert.Null(scriptsLazy.Value);
}
[Fact]
@@ -382,7 +488,7 @@ public sealed class AgentClassSkillTests
var jso = SkillTestJsonContext.Default.Options;
// Act & Assert — script with custom JSO
var script = skill.Scripts![0];
var script = skill.Scripts!.First(s => s.Name == "lookup");
var inputJson = JsonSerializer.SerializeToElement(new LookupRequest { Query = "test", MaxResults = 3 }, jso);
using var argsDoc = JsonDocument.Parse($$"""{ "request": {{inputJson.GetRawText()}} }""");
var args = argsDoc.RootElement;
@@ -398,13 +504,13 @@ public sealed class AgentClassSkillTests
}
[Fact]
public void Content_IncludesDescription_ForReflectedResources()
public async Task Content_IncludesDescription_ForReflectedResourcesAsync()
{
// Arrange
var skill = new AttributedResourcePropertiesSkill();
// Act
var content = skill.Content;
var content = await skill.GetContentAsync();
// Assert — descriptions from [Description] attribute appear in synthesized content
Assert.Contains("Some important data.", content);
@@ -105,7 +105,7 @@ public sealed class AgentFileSkillScriptTests
}
[Fact]
public void Content_WithScripts_AppendsPerScriptEntries()
public async Task Content_WithScripts_AppendsPerScriptEntriesAsync()
{
// Arrange
static Task<object?> RunnerAsync(AgentFileSkill s, AgentFileSkillScript sc, JsonElement? a, IServiceProvider? sp, CancellationToken ct) => Task.FromResult<object?>(null);
@@ -118,7 +118,7 @@ public sealed class AgentFileSkillScriptTests
scripts: [script1, script2]);
// Act
var content = fileSkill.Content;
var content = await fileSkill.GetContentAsync();
// Assert — content starts with original and appends per-script entries
Assert.StartsWith("Original content", content);
@@ -130,7 +130,7 @@ public sealed class AgentFileSkillScriptTests
}
[Fact]
public void Content_WithoutScripts_ReturnsOriginalContent()
public async Task Content_WithoutScripts_ReturnsOriginalContentAsync()
{
// Arrange
var fileSkill = new AgentFileSkill(
@@ -139,14 +139,14 @@ public sealed class AgentFileSkillScriptTests
"/skills/my-skill");
// Act
var content = fileSkill.Content;
var content = await fileSkill.GetContentAsync();
// Assert
Assert.Equal("Original content only", content);
}
[Fact]
public void Content_WithScripts_IsCached()
public async Task Content_WithScripts_IsCachedAsync()
{
// Arrange
static Task<object?> RunnerAsync(AgentFileSkill s, AgentFileSkillScript sc, JsonElement? a, IServiceProvider? sp, CancellationToken ct) => Task.FromResult<object?>(null);
@@ -158,8 +158,8 @@ public sealed class AgentFileSkillScriptTests
scripts: [script]);
// Act
var content1 = fileSkill.Content;
var content2 = fileSkill.Content;
var content1 = await fileSkill.GetContentAsync();
var content2 = await fileSkill.GetContentAsync();
// Assert
Assert.Same(content1, content2);
@@ -232,7 +232,7 @@ public sealed class AgentFileSkillScriptTests
}
[Fact]
public void Content_WithScripts_ContainsDefaultParametersSchema()
public async Task Content_WithScripts_ContainsDefaultParametersSchemaAsync()
{
// Arrange
static Task<object?> RunnerAsync(AgentFileSkill s, AgentFileSkillScript sc, JsonElement? a, IServiceProvider? sp, CancellationToken ct) => Task.FromResult<object?>(null);
@@ -244,7 +244,7 @@ public sealed class AgentFileSkillScriptTests
scripts: [script]);
// Act
var content = fileSkill.Content;
var content = await fileSkill.GetContentAsync();
// Assert — the appended block contains the actual default schema from AgentFileSkillScript
Assert.Contains("""{"type":"array","items":{"type":"string"}}""", content);
@@ -2,7 +2,6 @@
using System;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
@@ -46,9 +45,9 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable
// Assert
Assert.Single(skills);
var skill = skills[0];
Assert.NotNull(skill.Scripts);
Assert.Single(skill.Scripts!);
Assert.Equal("scripts/convert.py", skill.Scripts![0].Name);
var script = await skill.GetScriptAsync("scripts/convert.py");
Assert.NotNull(script);
Assert.Equal("scripts/convert.py", script!.Name);
}
[Fact]
@@ -69,14 +68,13 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable
// Assert
Assert.Single(skills);
var scriptNames = skills[0].Scripts!.Select(s => s.Name).OrderBy(n => n, StringComparer.Ordinal).ToList();
Assert.Equal(6, scriptNames.Count);
Assert.Contains("scripts/run.cs", scriptNames);
Assert.Contains("scripts/run.csx", scriptNames);
Assert.Contains("scripts/run.js", scriptNames);
Assert.Contains("scripts/run.ps1", scriptNames);
Assert.Contains("scripts/run.py", scriptNames);
Assert.Contains("scripts/run.sh", scriptNames);
// Assert — verify all expected scripts are discoverable
foreach (var name in (string[])["scripts/run.cs", "scripts/run.csx", "scripts/run.js", "scripts/run.ps1", "scripts/run.py", "scripts/run.sh"])
{
var script = await skills[0].GetScriptAsync(name);
Assert.NotNull(script);
Assert.Equal(name, script!.Name);
}
}
[Fact]
@@ -94,7 +92,7 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable
// Assert
Assert.Single(skills);
Assert.Empty(skills[0].Scripts!);
Assert.Null(await skills[0].GetScriptAsync("scripts/data.txt"));
}
[Fact]
@@ -109,8 +107,7 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable
// Assert
Assert.Single(skills);
Assert.NotNull(skills[0].Scripts);
Assert.Empty(skills[0].Scripts!);
Assert.Null(await skills[0].GetScriptAsync("any-script"));
}
[Fact]
@@ -128,7 +125,7 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable
// Assert — neither file is in the default scripts/ directory, so no scripts are discovered
Assert.Single(skills);
Assert.Empty(skills[0].Scripts!);
Assert.Null(await skills[0].GetScriptAsync("convert.py"));
}
[Fact]
@@ -150,7 +147,7 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable
// Act
var skills = await source.GetSkillsAsync(CancellationToken.None);
var scriptResult = await skills[0].Scripts![0].RunAsync(skills[0], null, null, CancellationToken.None);
var scriptResult = await (await skills[0].GetScriptAsync("scripts/test.py"))!.RunAsync(skills[0], null, null, CancellationToken.None);
// Assert
Assert.True(executorCalled);
@@ -175,7 +172,7 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable
// Act — discovery succeeds even without a runner
var skills = await source.GetSkillsAsync(CancellationToken.None);
var script = skills[0].Scripts![0];
var script = (await skills[0].GetScriptAsync("scripts/run.sh"))!;
// Assert — running the script throws because no runner was provided
await Assert.ThrowsAsync<InvalidOperationException>(() => script.RunAsync(skills[0], null, null, CancellationToken.None));
@@ -195,8 +192,9 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable
// Assert
Assert.Single(skills);
Assert.Single(skills[0].Scripts!);
Assert.Equal("scripts/run.rb", skills[0].Scripts![0].Name);
var rbScript = await skills[0].GetScriptAsync("scripts/run.rb");
Assert.NotNull(rbScript);
Assert.Equal("scripts/run.rb", rbScript!.Name);
}
[Fact]
@@ -217,7 +215,7 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable
var skills = await source.GetSkillsAsync(CancellationToken.None);
using var argumentsDoc = JsonDocument.Parse("""{"value":26.2,"factor":1.60934}""");
var arguments = argumentsDoc.RootElement;
await skills[0].Scripts![0].RunAsync(skills[0], arguments, null, CancellationToken.None);
await (await skills[0].GetScriptAsync("scripts/test.py"))!.RunAsync(skills[0], arguments, null, CancellationToken.None);
// Assert
Assert.NotNull(capturedArgs);
@@ -240,8 +238,9 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable
// Assert — script file inside the deeply nested directory is discovered
Assert.Single(skills);
Assert.Single(skills[0].Scripts!);
Assert.Equal("f1/f2/f3/run.py", skills[0].Scripts![0].Name);
var nestedScript = await skills[0].GetScriptAsync("f1/f2/f3/run.py");
Assert.NotNull(nestedScript);
Assert.Equal("f1/f2/f3/run.py", nestedScript!.Name);
}
[Theory]
@@ -267,11 +266,12 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable
// Assert — scripts are discovered with names identical to using directories without "./"
Assert.Single(skills);
Assert.Equal(directories.Length, skills[0].Scripts!.Count);
foreach (string directory in directories)
{
string expectedName = $"{directory.Substring(2)}/run.py";
Assert.Contains(skills[0].Scripts!, s => s.Name == expectedName);
var script = await skills[0].GetScriptAsync(expectedName);
Assert.NotNull(script);
Assert.Equal(expectedName, script!.Name);
}
}
@@ -105,13 +105,13 @@ public sealed class AgentInlineSkillTests
}
[Fact]
public void Content_ContainsNameDescriptionAndInstructions()
public async Task Content_ContainsNameDescriptionAndInstructionsAsync()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Do the thing.");
// Act
var content = skill.Content;
var content = await skill.GetContentAsync();
// Assert
Assert.Contains("<name>my-skill</name>", content);
@@ -120,13 +120,13 @@ public sealed class AgentInlineSkillTests
}
[Fact]
public void Content_EscapesXmlCharacters()
public async Task Content_EscapesXmlCharactersAsync()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "x<y>z\"w & it's more", "1 & 2 < 3");
// Act
var content = skill.Content;
var content = await skill.GetContentAsync();
// Assert
Assert.Contains("<name>my-skill</name>", content);
@@ -135,28 +135,28 @@ public sealed class AgentInlineSkillTests
}
[Fact]
public void Content_IsCachedAcrossAccesses()
public async Task Content_IsCachedAcrossAccessesAsync()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
// Act
var first = skill.Content;
var second = skill.Content;
var first = await skill.GetContentAsync();
var second = await skill.GetContentAsync();
// Assert
Assert.Same(first, second);
}
[Fact]
public void Content_IncludesResourcesAddedBeforeFirstAccess()
public async Task Content_IncludesResourcesAddedBeforeFirstAccessAsync()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
skill.AddResource("config", "value1", "A config resource.");
// Act
var content = skill.Content;
var content = await skill.GetContentAsync();
// Assert
Assert.Contains("<resources>", content);
@@ -164,14 +164,14 @@ public sealed class AgentInlineSkillTests
}
[Fact]
public void Content_IncludesDelegateResourcesAddedBeforeFirstAccess()
public async Task Content_IncludesDelegateResourcesAddedBeforeFirstAccessAsync()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
skill.AddResource("dynamic", () => "hello");
// Act
var content = skill.Content;
var content = await skill.GetContentAsync();
// Assert
Assert.Contains("<resources>", content);
@@ -179,14 +179,14 @@ public sealed class AgentInlineSkillTests
}
[Fact]
public void Content_IncludesScriptsAddedBeforeFirstAccess()
public async Task Content_IncludesScriptsAddedBeforeFirstAccessAsync()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
skill.AddScript("run", () => "result", "Runs something.");
// Act
var content = skill.Content;
var content = await skill.GetContentAsync();
// Assert
Assert.Contains("<scripts>", content);
@@ -194,22 +194,22 @@ public sealed class AgentInlineSkillTests
}
[Fact]
public void Content_IsCachedAndNotRebuilt()
public async Task Content_IsCachedAndNotRebuiltAsync()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
skill.AddResource("r1", "v1");
// Act
var first = skill.Content;
var second = skill.Content;
var first = await skill.GetContentAsync();
var second = await skill.GetContentAsync();
// Assert
Assert.Same(first, second);
}
[Fact]
public void Content_IncludesResourcesAndScriptsAddedBeforeFirstAccess()
public async Task Content_IncludesResourcesAndScriptsAddedBeforeFirstAccessAsync()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
@@ -217,7 +217,7 @@ public sealed class AgentInlineSkillTests
skill.AddScript("s1", () => "ok");
// Act
var content = skill.Content;
var content = await skill.GetContentAsync();
// Assert
Assert.Contains("<resources>", content);
@@ -227,14 +227,14 @@ public sealed class AgentInlineSkillTests
}
[Fact]
public void Content_ParametersSchema_IsXmlEscaped()
public async Task Content_ParametersSchema_IsXmlEscapedAsync()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
skill.AddScript("search", (string query, int limit) => $"found {limit} results for {query}");
// Act
var content = skill.Content;
var content = await skill.GetContentAsync();
// Assert — JSON schema should be present and XML content chars escaped
Assert.Contains("parameters_schema", content);
@@ -280,17 +280,103 @@ public sealed class AgentInlineSkillTests
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
// Act & Assert
Assert.Null(skill.Resources);
Assert.Null(skill.GetTestResources());
}
[Fact]
public void Scripts_WhenNoneAdded_ReturnsNull()
public async Task Scripts_WhenNoneAdded_ReturnsNullAsync()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
// Act & Assert
Assert.Null(skill.Scripts);
Assert.Null(await skill.GetScriptAsync("nonexistent"));
}
[Fact]
public async Task GetResourceAsync_ExistingName_ReturnsResourceAsync()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
skill.AddResource("r1", "v1");
skill.AddResource("r2", "v2");
// Act
var resource = await skill.GetResourceAsync("r2");
// Assert
Assert.NotNull(resource);
Assert.Equal("r2", resource!.Name);
}
[Fact]
public async Task GetResourceAsync_NonExistingName_ReturnsNullAsync()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
skill.AddResource("r1", "v1");
// Act
var resource = await skill.GetResourceAsync("missing");
// Assert
Assert.Null(resource);
}
[Fact]
public async Task GetResourceAsync_NoResourcesAdded_ReturnsNullAsync()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
// Act
var resource = await skill.GetResourceAsync("missing");
// Assert
Assert.Null(resource);
}
[Fact]
public async Task GetScriptAsync_ExistingName_ReturnsScriptAsync()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
skill.AddScript("s1", () => "first");
skill.AddScript("s2", () => "second");
// Act
var script = await skill.GetScriptAsync("s2");
// Assert
Assert.NotNull(script);
Assert.Equal("s2", script!.Name);
}
[Fact]
public async Task GetScriptAsync_NonExistingName_ReturnsNullAsync()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
skill.AddScript("s1", () => "ok");
// Act
var script = await skill.GetScriptAsync("missing");
// Assert
Assert.Null(script);
}
[Fact]
public async Task GetScriptAsync_NoScriptsAdded_ReturnsNullAsync()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
// Act
var script = await skill.GetScriptAsync("missing");
// Assert
Assert.Null(script);
}
[Fact]
@@ -333,13 +419,13 @@ public sealed class AgentInlineSkillTests
}
[Fact]
public void Content_NoResourcesOrScripts_DoesNotContainResourcesOrScriptsTags()
public async Task Content_NoResourcesOrScripts_DoesNotContainResourcesOrScriptsTagsAsync()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
// Act
var content = skill.Content;
var content = await skill.GetContentAsync();
// Assert
Assert.DoesNotContain("<resources>", content);
@@ -347,58 +433,58 @@ public sealed class AgentInlineSkillTests
}
[Fact]
public void Content_ResourcesAddedAfterCaching_AreNotIncluded()
public async Task Content_ResourcesAddedAfterCaching_AreNotIncludedAsync()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
_ = skill.Content; // trigger caching
_ = await skill.GetContentAsync(); // trigger caching
skill.AddResource("late-resource", "late-value");
// Act
var content = skill.Content;
var content = await skill.GetContentAsync();
// Assert — the late resource should not appear because content was cached
Assert.DoesNotContain("late-resource", content);
}
[Fact]
public void Content_ScriptsAddedAfterCaching_AreNotIncluded()
public async Task Content_ScriptsAddedAfterCaching_AreNotIncludedAsync()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
_ = skill.Content; // trigger caching
_ = await skill.GetContentAsync(); // trigger caching
skill.AddScript("late-script", () => "late");
// Act
var content = skill.Content;
var content = await skill.GetContentAsync();
// Assert — the late script should not appear because content was cached
Assert.DoesNotContain("late-script", content);
}
[Fact]
public void Content_ScriptWithDescription_IncludesDescriptionAttribute()
public async Task Content_ScriptWithDescription_IncludesDescriptionAttributeAsync()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
skill.AddScript("my-script", () => "ok", "Runs something.");
// Act
var content = skill.Content;
var content = await skill.GetContentAsync();
// Assert
Assert.Contains("description=\"Runs something.\"", content);
}
[Fact]
public void Content_ScriptWithoutParametersOrDescription_UsesSelfClosingTag()
public async Task Content_ScriptWithoutParametersOrDescription_UsesSelfClosingTagAsync()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
skill.AddScript("simple", () => "ok");
// Act
var content = skill.Content;
var content = await skill.GetContentAsync();
// Assert — parameterless Action delegates still produce a schema, so this
// verifies the script is at least included in the output
@@ -406,7 +492,7 @@ public sealed class AgentInlineSkillTests
}
[Fact]
public void Content_ResourceWithDescription_IncludesDescriptionAttribute()
public async Task Content_ResourceWithDescription_IncludesDescriptionAttributeAsync()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
@@ -414,7 +500,7 @@ public sealed class AgentInlineSkillTests
skill.AddResource("no-desc", "value");
// Act
var content = skill.Content;
var content = await skill.GetContentAsync();
// Assert
Assert.Contains("description=\"A described resource.\"", content);
@@ -437,7 +523,7 @@ public sealed class AgentInlineSkillTests
var args = argsDoc.RootElement;
// Act
var result = await skill.Scripts![0].RunAsync(skill, args, null, CancellationToken.None);
var result = await (await skill.GetScriptAsync("lookup"))!.RunAsync(skill, args, null, CancellationToken.None);
// Assert — the custom input was deserialized via skill-level JSO and response was produced
Assert.NotNull(result);
@@ -461,7 +547,7 @@ public sealed class AgentInlineSkillTests
var args = argsDoc.RootElement;
// Act
var result = await skill.Scripts![0].RunAsync(skill, args, null, CancellationToken.None);
var result = await (await skill.GetScriptAsync("lookup"))!.RunAsync(skill, args, null, CancellationToken.None);
// Assert — per-script JSO takes effect and custom types are properly marshaled
Assert.NotNull(result);
@@ -477,7 +563,7 @@ public sealed class AgentInlineSkillTests
skill.AddResource("config", () => new SkillConfig { Theme = "dark", Verbose = true });
// Act
var result = await skill.Resources![0].ReadAsync();
var result = await skill.GetTestResources()![0].ReadAsync();
// Assert — the custom type was returned successfully via skill-level JSO
Assert.NotNull(result);
@@ -494,7 +580,7 @@ public sealed class AgentInlineSkillTests
skill.AddResource("config", () => new SkillConfig { Theme = "dark", Verbose = true }, serializerOptions: resourceJso);
// Act
var result = await skill.Resources![0].ReadAsync();
var result = await skill.GetTestResources()![0].ReadAsync();
// Assert — per-resource JSO takes effect and custom type is properly marshaled
Assert.NotNull(result);
@@ -0,0 +1,43 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
/// <summary>
/// Test-only helpers that peek at the underlying resource list of a skill via reflection.
/// </summary>
/// <remarks>
/// The public <see cref="AgentSkill"/> API exposes resources only through
/// <see cref="AgentSkill.GetResourceAsync"/>.
/// These helpers exist purely to allow unit tests for <see cref="AgentFileSkill"/> and
/// <see cref="AgentInlineSkill"/> to inspect the concrete enumerated list a skill carries.
/// </remarks>
internal static class AgentSkillTestExtensions
{
public static IReadOnlyList<AgentSkillResource>? GetTestResources(this AgentSkill skill)
{
// AgentFileSkill / AgentInlineSkill: private "_resources" field.
for (var type = skill.GetType(); type is not null; type = type.BaseType)
{
var field = type.GetField("_resources", BindingFlags.NonPublic | BindingFlags.Instance);
if (field is not null)
{
return UnwrapList(field.GetValue(skill));
}
}
return null;
}
private static IReadOnlyList<AgentSkillResource>? UnwrapList(object? value) =>
value switch
{
null => null,
IReadOnlyList<AgentSkillResource> list => list,
IEnumerable<AgentSkillResource> seq => seq.ToList(),
_ => null,
};
}
@@ -68,11 +68,12 @@ public sealed class AgentSkillsProviderTests : IDisposable
Assert.Contains("provider-skill", result.Instructions);
Assert.Contains("Provider skill test", result.Instructions);
// Should have load_skill tool (no resources, so no read_skill_resource)
// Should have load_skill, read_skill_resource, and run_skill_script tools
Assert.NotNull(result.Tools);
var toolNames = result.Tools!.Select(t => t.Name).ToList();
Assert.Contains("load_skill", toolNames);
Assert.DoesNotContain("read_skill_resource", toolNames);
Assert.Contains("read_skill_resource", toolNames);
Assert.Contains("run_skill_script", toolNames);
}
[Fact]
@@ -316,7 +317,7 @@ public sealed class AgentSkillsProviderTests : IDisposable
}
[Fact]
public async Task InvokingCoreAsync_WithoutScripts_NoRunSkillScriptToolAsync()
public async Task InvokingCoreAsync_WithoutScripts_StillIncludesAllToolsAsync()
{
// Arrange
this.CreateSkill("no-script-skill", "No scripts", "Body.");
@@ -328,10 +329,12 @@ public sealed class AgentSkillsProviderTests : IDisposable
// Act
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
// Assert
// Assert — all tools are always included regardless of skill content
Assert.NotNull(result.Tools);
var toolNames = result.Tools!.Select(t => t.Name).ToList();
Assert.DoesNotContain("run_skill_script", toolNames);
Assert.Contains("load_skill", toolNames);
Assert.Contains("read_skill_resource", toolNames);
Assert.Contains("run_skill_script", toolNames);
}
[Fact]
@@ -416,7 +419,7 @@ public sealed class AgentSkillsProviderTests : IDisposable
// Assert
Assert.Single(skills);
var fileSkill = Assert.IsType<AgentFileSkill>(skills[0]);
Assert.All(fileSkill.Resources, r => Assert.EndsWith(".json", r.Name));
Assert.All(fileSkill.GetTestResources()!, r => Assert.EndsWith(".json", r.Name));
}
private void CreateSkill(string name, string description, string body)
@@ -445,6 +448,279 @@ public sealed class AgentSkillsProviderTests : IDisposable
Assert.Contains("Skill body.", text);
}
[Fact]
public async Task LoadSkill_EmptySkillName_ReturnsErrorAsync()
{
// Arrange
this.CreateSkill("any-skill", "Test", "Body.");
var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor));
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
var loadSkillTool = result.Tools!.First(t => t.Name == "load_skill") as AIFunction;
// Act
var content = await loadSkillTool!.InvokeAsync(new AIFunctionArguments(new Dictionary<string, object?> { ["skillName"] = "" }));
// Assert
Assert.Equal("Error: Skill name cannot be empty.", content!.ToString());
}
[Fact]
public async Task LoadSkill_SkillNotFound_ReturnsErrorAsync()
{
// Arrange
this.CreateSkill("only-skill", "Test", "Body.");
var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor));
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
var loadSkillTool = result.Tools!.First(t => t.Name == "load_skill") as AIFunction;
// Act
var content = await loadSkillTool!.InvokeAsync(new AIFunctionArguments(new Dictionary<string, object?> { ["skillName"] = "non-existent" }));
// Assert
Assert.Equal("Error: Skill 'non-existent' not found.", content!.ToString());
}
[Fact]
public async Task InvokingCoreAsync_WithResources_IncludesReadSkillResourceToolAsync()
{
// Arrange — inline skill with a resource
var skill = new AgentInlineSkill("res-skill", "Has resources", "Body.");
skill.AddResource("config", "value1", "A config resource.");
var provider = new AgentSkillsProvider(skill);
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
// Act
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
// Assert
Assert.NotNull(result.Tools);
var toolNames = result.Tools!.Select(t => t.Name).ToList();
Assert.Contains("read_skill_resource", toolNames);
}
[Fact]
public async Task ReadSkillResource_ReturnsResourceContentAsync()
{
// Arrange — inline skill with a resource
var skill = new AgentInlineSkill("res-skill", "Has resources", "Body.");
skill.AddResource("config", "resource-value");
var provider = new AgentSkillsProvider(skill);
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
var readTool = result.Tools!.First(t => t.Name == "read_skill_resource") as AIFunction;
// Act
var content = await readTool!.InvokeAsync(new AIFunctionArguments(new Dictionary<string, object?>
{
["skillName"] = "res-skill",
["resourceName"] = "config",
})
{
Services = new TestServiceProvider(),
});
// Assert
Assert.Equal("resource-value", content!.ToString());
}
[Fact]
public async Task ReadSkillResource_EmptySkillName_ReturnsErrorAsync()
{
// Arrange
var skill = new AgentInlineSkill("res-skill", "Has resources", "Body.");
skill.AddResource("config", "v");
var provider = new AgentSkillsProvider(skill);
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
var readTool = result.Tools!.First(t => t.Name == "read_skill_resource") as AIFunction;
// Act
var content = await readTool!.InvokeAsync(new AIFunctionArguments(new Dictionary<string, object?>
{
["skillName"] = "",
["resourceName"] = "config",
})
{
Services = new TestServiceProvider(),
});
// Assert
Assert.Equal("Error: Skill name cannot be empty.", content!.ToString());
}
[Fact]
public async Task ReadSkillResource_EmptyResourceName_ReturnsErrorAsync()
{
// Arrange
var skill = new AgentInlineSkill("res-skill", "Has resources", "Body.");
skill.AddResource("config", "v");
var provider = new AgentSkillsProvider(skill);
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
var readTool = result.Tools!.First(t => t.Name == "read_skill_resource") as AIFunction;
// Act
var content = await readTool!.InvokeAsync(new AIFunctionArguments(new Dictionary<string, object?>
{
["skillName"] = "res-skill",
["resourceName"] = "",
})
{
Services = new TestServiceProvider(),
});
// Assert
Assert.Equal("Error: Resource name cannot be empty.", content!.ToString());
}
[Fact]
public async Task ReadSkillResource_SkillNotFound_ReturnsErrorAsync()
{
// Arrange
var skill = new AgentInlineSkill("res-skill", "Has resources", "Body.");
skill.AddResource("config", "v");
var provider = new AgentSkillsProvider(skill);
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
var readTool = result.Tools!.First(t => t.Name == "read_skill_resource") as AIFunction;
// Act
var content = await readTool!.InvokeAsync(new AIFunctionArguments(new Dictionary<string, object?>
{
["skillName"] = "non-existent",
["resourceName"] = "config",
})
{
Services = new TestServiceProvider(),
});
// Assert
Assert.Equal("Error: Skill 'non-existent' not found.", content!.ToString());
}
[Fact]
public async Task ReadSkillResource_ResourceNotFound_ReturnsErrorAsync()
{
// Arrange
var skill = new AgentInlineSkill("res-skill", "Has resources", "Body.");
skill.AddResource("config", "v");
var provider = new AgentSkillsProvider(skill);
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
var readTool = result.Tools!.First(t => t.Name == "read_skill_resource") as AIFunction;
// Act
var content = await readTool!.InvokeAsync(new AIFunctionArguments(new Dictionary<string, object?>
{
["skillName"] = "res-skill",
["resourceName"] = "missing",
})
{
Services = new TestServiceProvider(),
});
// Assert
Assert.Equal("Error: Resource 'missing' not found in skill 'res-skill'.", content!.ToString());
}
[Fact]
public async Task RunSkillScript_EmptySkillName_ReturnsErrorAsync()
{
// Arrange
string skillDir = Path.Combine(this._testRoot, "err-script-skill");
Directory.CreateDirectory(Path.Combine(skillDir, "scripts"));
File.WriteAllText(Path.Combine(skillDir, "SKILL.md"), "---\nname: err-script-skill\ndescription: Test\n---\nBody.");
File.WriteAllText(Path.Combine(skillDir, "scripts", "run.py"), "print('hi')");
var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor));
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
var runScriptTool = result.Tools!.First(t => t.Name == "run_skill_script") as AIFunction;
// Act
var content = await runScriptTool!.InvokeAsync(new AIFunctionArguments(new Dictionary<string, object?>
{
["skillName"] = "",
["scriptName"] = "scripts/run.py",
}));
// Assert
Assert.Equal("Error: Skill name cannot be empty.", content!.ToString());
}
[Fact]
public async Task RunSkillScript_EmptyScriptName_ReturnsErrorAsync()
{
// Arrange
string skillDir = Path.Combine(this._testRoot, "err-script2-skill");
Directory.CreateDirectory(Path.Combine(skillDir, "scripts"));
File.WriteAllText(Path.Combine(skillDir, "SKILL.md"), "---\nname: err-script2-skill\ndescription: Test\n---\nBody.");
File.WriteAllText(Path.Combine(skillDir, "scripts", "run.py"), "print('hi')");
var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor));
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
var runScriptTool = result.Tools!.First(t => t.Name == "run_skill_script") as AIFunction;
// Act
var content = await runScriptTool!.InvokeAsync(new AIFunctionArguments(new Dictionary<string, object?>
{
["skillName"] = "err-script2-skill",
["scriptName"] = "",
}));
// Assert
Assert.Equal("Error: Script name cannot be empty.", content!.ToString());
}
[Fact]
public async Task RunSkillScript_SkillNotFound_ReturnsErrorAsync()
{
// Arrange
string skillDir = Path.Combine(this._testRoot, "err-script3-skill");
Directory.CreateDirectory(Path.Combine(skillDir, "scripts"));
File.WriteAllText(Path.Combine(skillDir, "SKILL.md"), "---\nname: err-script3-skill\ndescription: Test\n---\nBody.");
File.WriteAllText(Path.Combine(skillDir, "scripts", "run.py"), "print('hi')");
var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor));
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
var runScriptTool = result.Tools!.First(t => t.Name == "run_skill_script") as AIFunction;
// Act
var content = await runScriptTool!.InvokeAsync(new AIFunctionArguments(new Dictionary<string, object?>
{
["skillName"] = "non-existent",
["scriptName"] = "scripts/run.py",
}));
// Assert
Assert.Equal("Error: Skill 'non-existent' not found.", content!.ToString());
}
[Fact]
public async Task RunSkillScript_ScriptNotFound_ReturnsErrorAsync()
{
// Arrange
string skillDir = Path.Combine(this._testRoot, "err-script4-skill");
Directory.CreateDirectory(Path.Combine(skillDir, "scripts"));
File.WriteAllText(Path.Combine(skillDir, "SKILL.md"), "---\nname: err-script4-skill\ndescription: Test\n---\nBody.");
File.WriteAllText(Path.Combine(skillDir, "scripts", "run.py"), "print('hi')");
var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor));
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
var runScriptTool = result.Tools!.First(t => t.Name == "run_skill_script") as AIFunction;
// Act
var content = await runScriptTool!.InvokeAsync(new AIFunctionArguments(new Dictionary<string, object?>
{
["skillName"] = "err-script4-skill",
["scriptName"] = "scripts/missing.py",
}));
// Assert
Assert.Equal("Error: Script 'scripts/missing.py' not found in skill 'err-script4-skill'.", content!.ToString());
}
[Fact]
public async Task Builder_UseFileScriptRunnerAfterUseFileSkills_RunnerIsUsedAsync()
{
@@ -998,9 +1274,5 @@ public sealed class AgentSkillsProviderTests : IDisposable
public override AgentSkillFrontmatter Frontmatter { get; }
protected override string Instructions => this._instructions;
public override IReadOnlyList<AgentSkillResource>? Resources => null;
public override IReadOnlyList<AgentSkillScript>? Scripts => null;
}
}
@@ -281,9 +281,9 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
// Assert
Assert.Single(skills);
var skill = skills[0];
Assert.Equal(2, skill.Resources!.Count);
Assert.Contains(skill.Resources!, r => r.Name.Equals("references/FAQ.md", StringComparison.OrdinalIgnoreCase));
Assert.Contains(skill.Resources!, r => r.Name.Equals("assets/data.json", StringComparison.OrdinalIgnoreCase));
Assert.Equal(2, skill.GetTestResources()!.Count);
Assert.Contains(skill.GetTestResources()!, r => r.Name.Equals("references/FAQ.md", StringComparison.OrdinalIgnoreCase));
Assert.Contains(skill.GetTestResources()!, r => r.Name.Equals("assets/data.json", StringComparison.OrdinalIgnoreCase));
}
[Fact]
@@ -306,8 +306,8 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
// Assert
Assert.Single(skills);
var skill = skills[0];
Assert.Single(skill.Resources!);
Assert.Equal("references/data.json", skill.Resources![0].Name);
Assert.Single(skill.GetTestResources()!);
Assert.Equal("references/data.json", skill.GetTestResources()![0].Name);
}
[Fact]
@@ -329,8 +329,8 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
// Assert
Assert.Single(skills);
var skill = skills[0];
Assert.Single(skill.Resources!);
Assert.Equal("references/notes.md", skill.Resources![0].Name);
Assert.Single(skill.GetTestResources()!);
Assert.Equal("references/notes.md", skill.GetTestResources()![0].Name);
}
[Fact]
@@ -355,9 +355,9 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
// Assert — only the file directly in references/ is discovered; the nested file is not
Assert.Single(skills);
var skill = skills[0];
Assert.Single(skill.Resources!);
Assert.Contains(skill.Resources!, r => r.Name.Equals("references/top.md", StringComparison.OrdinalIgnoreCase));
Assert.DoesNotContain(skill.Resources!, r => r.Name.Contains("deep.md", StringComparison.OrdinalIgnoreCase));
Assert.Single(skill.GetTestResources()!);
Assert.Contains(skill.GetTestResources()!, r => r.Name.Equals("references/top.md", StringComparison.OrdinalIgnoreCase));
Assert.DoesNotContain(skill.GetTestResources()!, r => r.Name.Contains("deep.md", StringComparison.OrdinalIgnoreCase));
}
[Fact]
@@ -380,8 +380,8 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
// Assert — only .custom files should be discovered, not .json
Assert.Single(skills);
var skill = skills[0];
Assert.Single(skill.Resources!);
Assert.Equal("references/data.custom", skill.Resources![0].Name);
Assert.Single(skill.GetTestResources()!);
Assert.Equal("references/data.custom", skill.GetTestResources()![0].Name);
}
[Theory]
@@ -406,7 +406,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
// Assert — default extensions include .md
var skills = await source.GetSkillsAsync();
Assert.Single(skills[0].Resources!);
Assert.Single(skills[0].GetTestResources()!);
}
[Fact]
@@ -442,7 +442,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
// Assert — root-level files are NOT discovered unless "." is in ResourceDirectories
Assert.Single(skills);
Assert.Empty(skills[0].Resources!);
Assert.Empty(skills[0].GetTestResources()!);
}
[Fact]
@@ -465,9 +465,9 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
// Assert — both root-level resource files (and SKILL.md excluded) should be discovered
Assert.Single(skills);
var skill = skills[0];
Assert.Equal(2, skill.Resources!.Count);
Assert.Contains(skill.Resources!, r => r.Name.Equals("guide.md", StringComparison.OrdinalIgnoreCase));
Assert.Contains(skill.Resources!, r => r.Name.Equals("config.json", StringComparison.OrdinalIgnoreCase));
Assert.Equal(2, skill.GetTestResources()!.Count);
Assert.Contains(skill.GetTestResources()!, r => r.Name.Equals("guide.md", StringComparison.OrdinalIgnoreCase));
Assert.Contains(skill.GetTestResources()!, r => r.Name.Equals("config.json", StringComparison.OrdinalIgnoreCase));
}
[Fact]
@@ -488,7 +488,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
// Assert — non-spec directories are not scanned by default
Assert.Single(skills);
Assert.Empty(skills[0].Resources!);
Assert.Empty(skills[0].GetTestResources()!);
}
[Fact]
@@ -514,8 +514,8 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
// Assert — only docs/ is scanned; references/ is NOT scanned
Assert.Single(skills);
var skill = skills[0];
Assert.Single(skill.Resources!);
Assert.Equal("docs/readme.md", skill.Resources![0].Name);
Assert.Single(skill.GetTestResources()!);
Assert.Equal("docs/readme.md", skill.GetTestResources()![0].Name);
}
[Fact]
@@ -530,7 +530,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
// Assert
Assert.Single(skills);
Assert.Empty(skills[0].Resources!);
Assert.Empty(skills[0].GetTestResources()!);
}
[Fact]
@@ -588,7 +588,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
File.WriteAllText(Path.Combine(refsDir, "doc.md"), "Document content here.");
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
var skills = await source.GetSkillsAsync();
var resource = skills[0].Resources!.First(r => r.Name == "references/doc.md");
var resource = skills[0].GetTestResources()!.First(r => r.Name == "references/doc.md");
// Act
var content = await resource.ReadAsync();
@@ -672,8 +672,8 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
// Assert — skill should still load, the symlinked references/ is skipped, assets/legit.md is found
var skill = skills.FirstOrDefault(s => s.Frontmatter.Name == "symlink-escape-skill");
Assert.NotNull(skill);
Assert.Single(skill.Resources!);
Assert.Equal("assets/legit.md", skill.Resources![0].Name);
Assert.Single(skill.GetTestResources()!);
Assert.Equal("assets/legit.md", skill.GetTestResources()![0].Name);
}
[Fact]
@@ -714,8 +714,8 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
// Assert — only assets/legit.md is found; the symlinked references/ directory is skipped entirely
var skill = skills.FirstOrDefault(s => s.Frontmatter.Name == "symlink-directory-skip");
Assert.NotNull(skill);
Assert.Single(skill.Resources!);
Assert.Equal("assets/legit.md", skill.Resources![0].Name);
Assert.Single(skill.GetTestResources()!);
Assert.Equal("assets/legit.md", skill.GetTestResources()![0].Name);
}
[Fact]
@@ -751,7 +751,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
// Assert — skill loads but scripts from the symlinked directory are not discovered
var skill = skills.FirstOrDefault(s => s.Frontmatter.Name == "symlink-script-skip");
Assert.NotNull(skill);
Assert.Empty(skill.Scripts!);
Assert.Null(await skill.GetScriptAsync("any-script"));
}
[Fact]
@@ -791,7 +791,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
// Assert — the symlinked intermediate segment causes the directory to be skipped
var skill = skills.FirstOrDefault(s => s.Frontmatter.Name == "symlink-intermediate");
Assert.NotNull(skill);
Assert.Empty(skill.Resources!);
Assert.Empty(skill.GetTestResources()!);
}
#endif
@@ -1020,8 +1020,8 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
// Assert — only one copy of the resource despite two equivalent directory entries
Assert.Single(skills);
Assert.Single(skills[0].Resources!);
Assert.Equal("references/FAQ.md", skills[0].Resources![0].Name);
Assert.Single(skills[0].GetTestResources()!);
Assert.Equal("references/FAQ.md", skills[0].GetTestResources()![0].Name);
}
[Fact]
@@ -1043,8 +1043,8 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
// Assert — trailing slash variant deduplicated
Assert.Single(skills);
Assert.Single(skills[0].Resources!);
Assert.Equal("references/data.json", skills[0].Resources![0].Name);
Assert.Single(skills[0].GetTestResources()!);
Assert.Equal("references/data.json", skills[0].GetTestResources()![0].Name);
}
[Fact]
@@ -1066,8 +1066,9 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
// Assert — backslash variant deduplicated
Assert.Single(skills);
Assert.Single(skills[0].Scripts!);
Assert.Equal("scripts/run.py", skills[0].Scripts![0].Name);
var script = await skills[0].GetScriptAsync("scripts/run.py");
Assert.NotNull(script);
Assert.Equal("scripts/run.py", script!.Name);
}
[Theory]
@@ -1093,8 +1094,8 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
// Assert — the resource is discovered with a name identical to using the directory without "./"
Assert.Single(skills);
Assert.Single(skills[0].Resources!);
Assert.Equal($"{directoryWithoutDotSlash}/data.json", skills[0].Resources![0].Name);
Assert.Single(skills[0].GetTestResources()!);
Assert.Equal($"{directoryWithoutDotSlash}/data.json", skills[0].GetTestResources()![0].Name);
}
[Fact]
@@ -1117,8 +1118,8 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
// Assert — resource file inside the deeply nested directory is discovered
Assert.Single(skills);
var skill = skills[0];
Assert.Single(skill.Resources!);
Assert.Equal("f1/f2/f3/data.json", skill.Resources![0].Name);
Assert.Single(skill.GetTestResources()!);
Assert.Equal("f1/f2/f3/data.json", skill.GetTestResources()![0].Name);
}
private string CreateSkillDirectory(string name, string description, string body)
@@ -1188,8 +1189,9 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
// Assert — script at the skill root should be discovered
var skill = skills.FirstOrDefault(s => s.Frontmatter.Name == "root-script-skill");
Assert.NotNull(skill);
Assert.Single(skill.Scripts!);
Assert.Equal("run.py", skill.Scripts![0].Name);
var script = await skill.GetScriptAsync("run.py");
Assert.NotNull(script);
Assert.Equal("run.py", script!.Name);
}
#if NET
@@ -1229,8 +1231,8 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
// Assert — only legit.md should be discovered; the symlinked leak.md is skipped
var skill = skills.FirstOrDefault(s => s.Frontmatter.Name == "symlink-file-skill");
Assert.NotNull(skill);
Assert.Single(skill.Resources!);
Assert.Equal("references/legit.md", skill.Resources![0].Name);
Assert.Single(skill.GetTestResources()!);
Assert.Equal("references/legit.md", skill.GetTestResources()![0].Name);
}
#endif
}
@@ -155,9 +155,9 @@ public sealed class HostedAgentSkillsPatternTests : IDisposable
Assert.NotNull(result.Tools);
var toolNames = result.Tools!.Select(t => t.Name).ToList();
Assert.Contains("load_skill", toolNames);
// No scripts or resources => no read_skill_resource or run_skill_script
Assert.DoesNotContain("read_skill_resource", toolNames);
Assert.DoesNotContain("run_skill_script", toolNames);
// All tools are always included regardless of whether skills have resources or scripts
Assert.Contains("read_skill_resource", toolNames);
Assert.Contains("run_skill_script", toolNames);
}
[Fact]
@@ -31,13 +31,7 @@ internal sealed class TestAgentSkill : AgentSkill
public override AgentSkillFrontmatter Frontmatter => this._frontmatter;
/// <inheritdoc/>
public override string Content => this._content;
/// <inheritdoc/>
public override IReadOnlyList<AgentSkillResource>? Resources => null;
/// <inheritdoc/>
public override IReadOnlyList<AgentSkillScript>? Scripts => null;
public override ValueTask<string> GetContentAsync(CancellationToken cancellationToken = default) => new(this._content);
}
/// <summary>