.NET: Add inline skills API (#4951)

* add inline skills

* Fix IDE1006 and IDE0004 formatting errors in test files

- Add 'Async' suffix to async test methods in FilteringAgentSkillsSourceTests,
  DeduplicatingAgentSkillsSourceTests, and AgentInMemorySkillsSourceTests
- Use pragma to suppress false-positive IDE0004 on casts needed for overload
  disambiguation in AgentInlineSkillTests and AgentInlineSkillResourceTests

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* address issues

* address comments

* make inline skills script and resource model classes internal

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
SergeyMenshykh
2026-03-30 17:23:04 +01:00
committed by GitHub
Unverified
parent 4527dee64b
commit ade295b122
19 changed files with 1541 additions and 53 deletions
+1
View File
@@ -105,6 +105,7 @@
<Folder Name="/Samples/02-agents/AgentSkills/">
<File Path="samples/02-agents/AgentSkills/README.md" />
<Project Path="samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/Agent_Step01_FileBasedSkills.csproj" />
<Project Path="samples/02-agents/AgentSkills/Agent_Step02_CodeDefinedSkills/Agent_Step02_CodeDefinedSkills.csproj" />
</Folder>
<Folder Name="/Samples/02-agents/AGUI/Step05_StateManagement/">
<Project Path="samples/02-agents/AGUI/Step05_StateManagement/Client/Client.csproj" />
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<NoWarn>$(NoWarn);MAAI001</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.OpenAI" />
<PackageReference Include="Azure.Identity" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,90 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates how to define Agent Skills entirely in code using AgentInlineSkill.
// No SKILL.md files are needed — skills, resources, and scripts are all defined programmatically.
//
// Three approaches are shown using a unit-converter skill:
// 1. Static resources — inline content provided via AddResource
// 2. Dynamic resources — computed at runtime via a factory delegate
// 3. Code scripts — executable delegates the agent can invoke directly
using System.Text.Json;
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using OpenAI.Responses;
// --- Configuration ---
string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
// --- Build the code-defined skill ---
var unitConverterSkill = new AgentInlineSkill(
name: "unit-converter",
description: "Convert between common units using a multiplication factor. Use when asked to convert miles, kilometers, pounds, or kilograms.",
instructions: """
Use this skill when the user asks to convert between units.
1. Review the conversion-table resource to find the factor for the requested conversion.
2. Check the conversion-policy resource for rounding and formatting rules.
3. Use the convert script, passing the value and factor from the table.
""")
// 1. Static Resource: conversion tables
.AddResource(
"conversion-table",
"""
# Conversion Tables
Formula: **result = value × factor**
| From | To | Factor |
|-------------|-------------|----------|
| miles | kilometers | 1.60934 |
| kilometers | miles | 0.621371 |
| pounds | kilograms | 0.453592 |
| kilograms | pounds | 2.20462 |
""")
// 2. Dynamic Resource: conversion policy (computed at runtime)
.AddResource("conversion-policy", () =>
{
const int Precision = 4;
return $"""
# Conversion Policy
**Decimal places:** {Precision}
**Format:** Always show both the original and converted values with units
**Generated at:** {DateTime.UtcNow:O}
""";
})
// 3. Code Script: convert
.AddScript("convert", (double value, double factor) =>
{
double result = Math.Round(value * factor, 4);
return JsonSerializer.Serialize(new { value, factor, result });
});
// --- Skills Provider ---
var skillsProvider = new AgentSkillsProvider(unitConverterSkill);
// --- Agent Setup ---
AIAgent agent = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential())
.GetResponsesClient()
.AsAIAgent(new ChatClientAgentOptions
{
Name = "UnitConverterAgent",
ChatOptions = new()
{
Instructions = "You are a helpful assistant that can convert units.",
},
AIContextProviders = [skillsProvider],
},
model: deploymentName);
// --- Example: Unit conversion ---
Console.WriteLine("Converting units with code-defined skills");
Console.WriteLine(new string('-', 60));
AgentResponse response = await agent.RunAsync(
"How many kilometers is a marathon (26.2 miles)? And how many pounds is 75 kilograms?");
Console.WriteLine($"Agent: {response.Text}");
@@ -0,0 +1,52 @@
# Code-Defined Agent Skills Sample
This sample demonstrates how to define **Agent Skills entirely in code** using `AgentInlineSkill`.
## What it demonstrates
- Creating skills programmatically with `AgentInlineSkill` — no SKILL.md files needed
- **Static resources** via `AddResource` with inline content
- **Dynamic resources** via `AddResource` with a factory delegate (computed at runtime)
- **Code scripts** via `AddScript` with a delegate handler
- Using the `AgentSkillsProvider` constructor with inline skills
## Skills Included
### unit-converter (code-defined)
Converts between common units using multiplication factors. Defined entirely in C# code:
- `conversion-table` — Static resource with factor table
- `conversion-policy` — Dynamic resource with formatting rules (generated at runtime)
- `convert` — Script that performs `value × factor` conversion
## Running the Sample
### Prerequisites
- .NET 10.0 SDK
- Azure OpenAI endpoint with a deployed model
### Setup
```bash
export AZURE_OPENAI_ENDPOINT="https://your-endpoint.openai.azure.com/"
export AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini"
```
### Run
```bash
dotnet run
```
### Expected Output
```
Converting units with code-defined skills
------------------------------------------------------------
Agent: Here are your conversions:
1. **26.2 miles → 42.16 km** (a marathon distance)
2. **75 kg → 165.35 lbs**
```
+18 -1
View File
@@ -1,7 +1,24 @@
# AgentSkills Samples
Samples demonstrating Agent Skills capabilities.
Samples demonstrating Agent Skills capabilities. Each sample shows a different way to define and use skills.
| Sample | Description |
|--------|-------------|
| [Agent_Step01_FileBasedSkills](Agent_Step01_FileBasedSkills/) | Define skills as `SKILL.md` files on disk with reference documents. Uses a unit-converter skill. |
| [Agent_Step02_CodeDefinedSkills](Agent_Step02_CodeDefinedSkills/) | Define skills entirely in C# code using `AgentInlineSkill`, with static/dynamic resources and scripts. |
## Key Concepts
### File-Based vs Code-Defined Skills
| Aspect | File-Based | Code-Defined |
|--------|-----------|--------------|
| Definition | `SKILL.md` files on disk | `AgentInlineSkill` instances in C# |
| Resources | All files in skill directory (filtered by extension) | `AddResource` (static value or delegate-backed) |
| Scripts | Supported via script executor delegate | `AddScript` delegates |
| Discovery | Automatic from directory path | Explicit via constructor |
| Dynamic content | No (static files only) | Yes (factory delegates) |
| Reusability | Copy skill directory | Inline or shared instances |
For single-source scenarios, use the `AgentSkillsProvider` constructors directly. To combine multiple skill types, use the `AgentSkillsProviderBuilder`.
@@ -0,0 +1,35 @@
// Copyright (c) Microsoft. All rights reserved.
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;
namespace Microsoft.Agents.AI;
/// <summary>
/// A skill source that holds <see cref="AgentSkill"/> instances in memory.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
internal sealed class AgentInMemorySkillsSource : AgentSkillsSource
{
private readonly List<AgentSkill> _skills;
/// <summary>
/// Initializes a new instance of the <see cref="AgentInMemorySkillsSource"/> class.
/// </summary>
/// <param name="skills">The skills to include in this source.</param>
public AgentInMemorySkillsSource(IEnumerable<AgentSkill> skills)
{
this._skills = Throw.IfNull(skills).ToList();
}
/// <inheritdoc/>
public override Task<IList<AgentSkill>> GetSkillsAsync(CancellationToken cancellationToken = default)
{
return Task.FromResult<IList<AgentSkill>>(this._skills);
}
}
@@ -12,7 +12,8 @@ namespace Microsoft.Agents.AI;
/// <remarks>
/// <para>
/// A skill represents a domain-specific capability with instructions, resources, and scripts.
/// Concrete implementations include <see cref="AgentFileSkill"/> (filesystem-backed).
/// Concrete implementations include <see cref="AgentFileSkill"/> (filesystem-backed)
/// and <see cref="AgentInlineSkill"/> (code-defined).
/// </para>
/// <para>
/// Skill metadata follows the <see href="https://agentskills.io/specification">Agent Skills specification</see>.
@@ -35,6 +36,8 @@ public abstract class AgentSkill
/// </summary>
/// <remarks>
/// For file-based skills this is the raw SKILL.md file content.
/// For code-defined skills this is a synthesized XML document
/// containing name, description, and body (instructions, resources, scripts).
/// </remarks>
public abstract string Content { get; }
@@ -116,6 +116,38 @@ public sealed partial class AgentSkillsProvider : AIContextProvider
{
}
/// <summary>
/// Initializes a new instance of the <see cref="AgentSkillsProvider"/> class
/// with one or more inline (code-defined) skills.
/// Duplicate skill names are automatically deduplicated (first occurrence wins).
/// </summary>
/// <param name="skills">The inline skills to include.</param>
public AgentSkillsProvider(params AgentInlineSkill[] skills)
: this(skills as IEnumerable<AgentInlineSkill>)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="AgentSkillsProvider"/> class
/// with inline (code-defined) skills.
/// Duplicate skill names are automatically deduplicated (first occurrence wins).
/// </summary>
/// <param name="skills">The inline skills to include.</param>
/// <param name="options">Optional provider configuration.</param>
/// <param name="loggerFactory">Optional logger factory.</param>
public AgentSkillsProvider(
IEnumerable<AgentInlineSkill> 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
@@ -13,9 +13,13 @@ namespace Microsoft.Agents.AI;
/// Fluent builder for constructing an <see cref="AgentSkillsProvider"/> backed by a composite source.
/// </summary>
/// <remarks>
/// <para>
/// Use this builder to combine multiple skill sources into a single provider:
/// </para>
/// <code>
/// var provider = new AgentSkillsProviderBuilder()
/// .UseFileSkills("/path/to/skills")
/// .UseSkills(myInlineSkill1, myInlineSkill2)
/// .Build();
/// </code>
/// </remarks>
@@ -65,6 +69,40 @@ public sealed class AgentSkillsProviderBuilder
return this;
}
/// <summary>
/// Adds a single skill.
/// </summary>
/// <param name="skill">The skill to add.</param>
/// <returns>This builder instance for chaining.</returns>
public AgentSkillsProviderBuilder UseSkill(AgentSkill skill)
{
return this.UseSkills(skill);
}
/// <summary>
/// Adds one or more skills.
/// </summary>
/// <param name="skills">The skills to add.</param>
/// <returns>This builder instance for chaining.</returns>
public AgentSkillsProviderBuilder UseSkills(params AgentSkill[] skills)
{
var source = new AgentInMemorySkillsSource(skills);
this._sourceFactories.Add((_, _) => source);
return this;
}
/// <summary>
/// Adds skills from the specified collection.
/// </summary>
/// <param name="skills">The skills to add.</param>
/// <returns>This builder instance for chaining.</returns>
public AgentSkillsProviderBuilder UseSkills(IEnumerable<AgentSkill> skills)
{
var source = new AgentInMemorySkillsSource(skills);
this._sourceFactories.Add((_, _) => source);
return this;
}
/// <summary>
/// Adds a custom skill source.
/// </summary>
@@ -0,0 +1,215 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// A skill defined entirely in code with resources (static values or delegates) and scripts (delegates).
/// </summary>
/// <remarks>
/// All calls to <see cref="AddResource(string, object, string?)"/>,
/// <see cref="AddResource(string, Delegate, string?)"/>, and <see cref="AddScript"/>
/// must be made before the skill's <see cref="Content"/> is first accessed.
/// Calls made after that point will not be reflected in the generated
/// <see cref="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>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class AgentInlineSkill : AgentSkill
{
private readonly string _instructions;
private List<AgentSkillResource>? _resources;
private List<AgentSkillScript>? _scripts;
private string? _cachedContent;
/// <summary>
/// Initializes a new instance of the <see cref="AgentInlineSkill"/> class
/// with a pre-built <see cref="AgentSkillFrontmatter"/>.
/// </summary>
/// <param name="frontmatter">The skill frontmatter containing name, description, and other metadata.</param>
/// <param name="instructions">Skill instructions text.</param>
public AgentInlineSkill(AgentSkillFrontmatter frontmatter, string instructions)
{
this.Frontmatter = Throw.IfNull(frontmatter);
this._instructions = Throw.IfNullOrWhitespace(instructions);
}
/// <summary>
/// Initializes a new instance of the <see cref="AgentInlineSkill"/> class
/// with all frontmatter properties specified individually.
/// </summary>
/// <param name="name">Skill name in kebab-case.</param>
/// <param name="description">Skill description for discovery.</param>
/// <param name="instructions">Skill instructions text.</param>
/// <param name="license">Optional license name or reference.</param>
/// <param name="compatibility">Optional compatibility information (max 500 chars).</param>
/// <param name="allowedTools">Optional space-delimited list of pre-approved tools.</param>
/// <param name="metadata">Optional arbitrary key-value metadata.</param>
public AgentInlineSkill(
string name,
string description,
string instructions,
string? license = null,
string? compatibility = null,
string? allowedTools = null,
AdditionalPropertiesDictionary? metadata = null)
: this(
new AgentSkillFrontmatter(name, description, compatibility)
{
License = license,
AllowedTools = allowedTools,
Metadata = metadata,
},
instructions)
{
}
/// <inheritdoc/>
public override AgentSkillFrontmatter Frontmatter { get; }
/// <inheritdoc/>
public override string Content => this._cachedContent ??= this.BuildContent();
/// <inheritdoc/>
public override IReadOnlyList<AgentSkillResource>? Resources => this._resources;
/// <inheritdoc/>
public override IReadOnlyList<AgentSkillScript>? Scripts => this._scripts;
/// <summary>
/// Registers a static resource with this skill.
/// </summary>
/// <param name="name">The resource name.</param>
/// <param name="value">The static resource value.</param>
/// <param name="description">An optional description of the resource.</param>
/// <returns>This instance, for chaining.</returns>
public AgentInlineSkill AddResource(string name, object value, string? description = null)
{
(this._resources ??= []).Add(new AgentInlineSkillResource(name, value, description));
return this;
}
/// <summary>
/// Registers a dynamic resource with this skill, backed by a C# delegate.
/// The delegate's parameters and return type are automatically marshaled via <c>AIFunctionFactory</c>.
/// </summary>
/// <param name="name">The resource name.</param>
/// <param name="method">A method that produces the resource value when requested.</param>
/// <param name="description">An optional description of the resource.</param>
/// <returns>This instance, for chaining.</returns>
public AgentInlineSkill AddResource(string name, Delegate method, string? description = null)
{
(this._resources ??= []).Add(new AgentInlineSkillResource(name, method, description));
return this;
}
/// <summary>
/// Registers a script with this skill, backed by a C# delegate.
/// The delegate's parameters and return type are automatically marshaled via <c>AIFunctionFactory</c>.
/// </summary>
/// <param name="name">The script name.</param>
/// <param name="method">A method to execute when the script is invoked.</param>
/// <param name="description">An optional description of the script.</param>
/// <returns>This instance, for chaining.</returns>
public AgentInlineSkill AddScript(string name, Delegate method, string? description = null)
{
(this._scripts ??= []).Add(new AgentInlineSkillScript(name, method, description));
return this;
}
private string BuildContent()
{
var sb = new StringBuilder();
sb.Append($"<name>{EscapeXmlString(this.Frontmatter.Name)}</name>\n")
.Append($"<description>{EscapeXmlString(this.Frontmatter.Description)}</description>\n\n")
.Append("<instructions>\n")
.Append(EscapeXmlString(this._instructions))
.Append("\n</instructions>");
if (this.Resources is { Count: > 0 })
{
sb.Append("\n\n<resources>\n");
foreach (var resource in this.Resources)
{
if (resource.Description is not null)
{
sb.Append($" <resource name=\"{EscapeXmlString(resource.Name)}\" description=\"{EscapeXmlString(resource.Description)}\"/>\n");
}
else
{
sb.Append($" <resource name=\"{EscapeXmlString(resource.Name)}\"/>\n");
}
}
sb.Append("</resources>");
}
if (this.Scripts is { Count: > 0 })
{
sb.Append("\n\n<scripts>\n");
foreach (var script in this.Scripts)
{
JsonElement? parametersSchema = ((AgentInlineSkillScript)script).ParametersSchema;
if (script.Description is null && parametersSchema is null)
{
sb.Append($" <script name=\"{EscapeXmlString(script.Name)}\"/>\n");
}
else
{
sb.Append(script.Description is not null
? $" <script name=\"{EscapeXmlString(script.Name)}\" description=\"{EscapeXmlString(script.Description)}\">\n"
: $" <script name=\"{EscapeXmlString(script.Name)}\">\n");
if (parametersSchema is not null)
{
sb.Append($" <parameters_schema>{EscapeXmlString(parametersSchema.Value.GetRawText(), preserveQuotes: true)}</parameters_schema>\n");
}
sb.Append(" </script>\n");
}
}
sb.Append("</scripts>");
}
return sb.ToString();
}
/// <summary>
/// Escapes XML special characters: always escapes <c>&amp;</c>, <c>&lt;</c>, <c>&gt;</c>,
/// <c>&quot;</c>, and <c>&apos;</c>. When <paramref name="preserveQuotes"/> is <see langword="true"/>,
/// quotes are left unescaped to preserve readability of embedded content such as JSON.
/// </summary>
/// <param name="value">The string to escape.</param>
/// <param name="preserveQuotes">
/// When <see langword="true"/>, leaves <c>"</c> and <c>'</c> unescaped for use in XML element content (e.g., JSON).
/// When <see langword="false"/> (default), escapes all XML special characters including quotes.
/// </param>
private static string EscapeXmlString(string value, bool preserveQuotes = false)
{
var result = value
.Replace("&", "&amp;")
.Replace("<", "&lt;")
.Replace(">", "&gt;");
if (!preserveQuotes)
{
result = result
.Replace("\"", "&quot;")
.Replace("'", "&apos;");
}
return result;
}
}
@@ -0,0 +1,60 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// A skill resource defined in code, backed by either a static value or a delegate.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
internal sealed class AgentInlineSkillResource : AgentSkillResource
{
private readonly object? _value;
private readonly AIFunction? _function;
/// <summary>
/// Initializes a new instance of the <see cref="AgentInlineSkillResource"/> class with a static value.
/// The value is returned as-is when <see cref="ReadAsync"/> is called.
/// </summary>
/// <param name="name">The resource name.</param>
/// <param name="value">The static resource value.</param>
/// <param name="description">An optional description of the resource.</param>
public AgentInlineSkillResource(string name, object value, string? description = null)
: base(name, description)
{
this._value = Throw.IfNull(value);
}
/// <summary>
/// Initializes a new instance of the <see cref="AgentInlineSkillResource"/> class with a delegate.
/// The delegate is invoked via an <see cref="AIFunction"/> each time <see cref="ReadAsync"/> is called,
/// producing a dynamic (computed) value.
/// </summary>
/// <param name="name">The resource name.</param>
/// <param name="method">A method that produces the resource value when requested.</param>
/// <param name="description">An optional description of the resource.</param>
public AgentInlineSkillResource(string name, Delegate method, string? description = null)
: base(name, description)
{
Throw.IfNull(method);
this._function = AIFunctionFactory.Create(method, name: this.Name);
}
/// <inheritdoc/>
public override async Task<object?> ReadAsync(IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default)
{
if (this._function is not null)
{
return await this._function.InvokeAsync(new AIFunctionArguments() { Services = serviceProvider }, cancellationToken).ConfigureAwait(false);
}
return this._value;
}
}
@@ -0,0 +1,46 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// A skill script backed by a delegate.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
internal sealed class AgentInlineSkillScript : AgentSkillScript
{
private readonly AIFunction _function;
/// <summary>
/// Initializes a new instance of the <see cref="AgentInlineSkillScript"/> class from a delegate.
/// The delegate's parameters and return type are automatically marshaled via <see cref="AIFunctionFactory"/>.
/// </summary>
/// <param name="name">The script name.</param>
/// <param name="method">A method to execute when the script is invoked. Parameters are automatically deserialized from JSON.</param>
/// <param name="description">An optional description of the script.</param>
public AgentInlineSkillScript(string name, Delegate method, string? description = null)
: base(Throw.IfNullOrWhitespace(name), description)
{
Throw.IfNull(method);
this._function = AIFunctionFactory.Create(method, name: this.Name);
}
/// <summary>
/// Gets the JSON schema describing the parameters accepted by this script, or <see langword="null"/> if not available.
/// </summary>
public JsonElement? ParametersSchema => this._function.JsonSchema;
/// <inheritdoc/>
public override async Task<object?> RunAsync(AgentSkill skill, AIFunctionArguments arguments, CancellationToken cancellationToken = default)
{
return await this._function.InvokeAsync(arguments, cancellationToken).ConfigureAwait(false);
}
}
@@ -0,0 +1,51 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
/// <summary>
/// Unit tests for <see cref="AgentInMemorySkillsSource"/>.
/// </summary>
public sealed class AgentInMemorySkillsSourceTests
{
[Fact]
public async Task GetSkillsAsync_ValidSkills_ReturnsAllAsync()
{
// Arrange
var skills = new AgentSkill[]
{
new AgentInlineSkill("my-skill", "A valid skill.", "Instructions."),
new AgentInlineSkill("another", "Another valid skill.", "More instructions."),
};
var source = new AgentInMemorySkillsSource(skills);
// Act
var result = await source.GetSkillsAsync(CancellationToken.None);
// Assert
Assert.Equal(2, result.Count);
Assert.Equal("my-skill", result[0].Frontmatter.Name);
Assert.Equal("another", result[1].Frontmatter.Name);
}
[Theory]
[InlineData("INVALID-NAME")]
[InlineData("-leading")]
[InlineData("trailing-")]
public void Constructor_InvalidFrontmatter_ThrowsArgumentException(string invalidName)
{
// Act & Assert
Assert.Throws<ArgumentException>(() =>
new AgentInlineSkill(invalidName, "A skill.", "Instructions."));
}
[Fact]
public void Constructor_NullSkills_Throws()
{
// Act & Assert
Assert.Throws<ArgumentNullException>(() => new AgentInMemorySkillsSource(null!));
}
}
@@ -0,0 +1,155 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
/// <summary>
/// Unit tests for <see cref="AgentInlineSkillResource"/>.
/// </summary>
public sealed class AgentInlineSkillResourceTests
{
[Fact]
public async Task ReadAsync_StaticValue_ReturnsValueAsync()
{
// Arrange
var resource = new AgentInlineSkillResource("config", "my-value");
// Act
var result = await resource.ReadAsync();
// Assert
Assert.Equal("my-value", result);
}
[Fact]
public async Task ReadAsync_StaticObjectValue_ReturnsSameInstanceAsync()
{
// Arrange
var obj = new object();
var resource = new AgentInlineSkillResource("ref", obj);
// Act
var result = await resource.ReadAsync();
// Assert
Assert.Same(obj, result);
}
[Fact]
public async Task ReadAsync_Delegate_InvokesFunctionAsync()
{
// Arrange
int callCount = 0;
var resource = new AgentInlineSkillResource("dynamic", () =>
{
callCount++;
return "computed";
});
// Act
var result = await resource.ReadAsync();
// Assert
Assert.Equal("computed", result?.ToString());
Assert.Equal(1, callCount);
}
[Fact]
public async Task ReadAsync_Delegate_InvokesEachTimeAsync()
{
// Arrange
int callCount = 0;
var resource = new AgentInlineSkillResource("counter", () => ++callCount);
// Act
await resource.ReadAsync();
await resource.ReadAsync();
var result = await resource.ReadAsync();
// Assert
Assert.Equal(3, callCount);
}
[Fact]
public void Constructor_StaticValue_SetsNameAndDescription()
{
// Arrange & Act
var resource = new AgentInlineSkillResource("my-res", "val", "A description.");
// Assert
Assert.Equal("my-res", resource.Name);
Assert.Equal("A description.", resource.Description);
}
[Fact]
public void Constructor_StaticValue_NullDescription_DescriptionIsNull()
{
// Arrange & Act
var resource = new AgentInlineSkillResource("my-res", "val");
// Assert
Assert.Null(resource.Description);
}
[Fact]
public void Constructor_StaticValue_NullValue_Throws()
{
// Act & Assert — cast needed to target the object overload
#pragma warning disable IDE0004
Assert.Throws<ArgumentNullException>(() =>
new AgentInlineSkillResource("my-res", (object)null!));
#pragma warning restore IDE0004
}
[Fact]
public void Constructor_Delegate_NullMethod_Throws()
{
// Act & Assert
Assert.Throws<ArgumentNullException>(() =>
new AgentInlineSkillResource("my-res", null!));
}
[Fact]
public void Constructor_NullName_Throws()
{
// Act & Assert
Assert.Throws<ArgumentNullException>(() =>
new AgentInlineSkillResource(null!, "val"));
}
[Fact]
public void Constructor_WhitespaceName_Throws()
{
// Act & Assert
Assert.Throws<ArgumentException>(() =>
new AgentInlineSkillResource(" ", "val"));
}
[Fact]
public void Constructor_Delegate_SetsNameAndDescription()
{
// Arrange & Act
var resource = new AgentInlineSkillResource("dyn-res", () => "hello", "Dynamic resource.");
// Assert
Assert.Equal("dyn-res", resource.Name);
Assert.Equal("Dynamic resource.", resource.Description);
}
[Fact]
public async Task ReadAsync_SupportsCancellationTokenAsync()
{
// Arrange
using var cts = new CancellationTokenSource();
var resource = new AgentInlineSkillResource("cancellable", "value");
// Act — should not throw with a non-cancelled token
var result = await resource.ReadAsync(cancellationToken: cts.Token);
// Assert
Assert.Equal("value", result);
}
}
@@ -0,0 +1,132 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
/// <summary>
/// Unit tests for <see cref="AgentInlineSkillScript"/>.
/// </summary>
public sealed class AgentInlineSkillScriptTests
{
[Fact]
public async Task RunAsync_InvokesDelegate_ReturnsResultAsync()
{
// Arrange
var script = new AgentInlineSkillScript("greet", () => "hello");
var skill = new AgentInlineSkill("test-skill", "Test.", "Instructions.");
// Act
var result = await script.RunAsync(skill, new AIFunctionArguments(), CancellationToken.None);
// Assert
Assert.Equal("hello", result?.ToString());
}
[Fact]
public async Task RunAsync_WithParameters_PassesArgumentsAsync()
{
// Arrange
var script = new AgentInlineSkillScript("add", (int a, int b) => a + b);
var skill = new AgentInlineSkill("calc-skill", "Calc.", "Instructions.");
var args = new AIFunctionArguments { ["a"] = 3, ["b"] = 7 };
// Act
var result = await script.RunAsync(skill, args, CancellationToken.None);
// Assert
Assert.Equal(10, int.Parse(result?.ToString()!));
}
[Fact]
public void ParametersSchema_NoParameters_ReturnsSchema()
{
// Arrange
var script = new AgentInlineSkillScript("noop", () => "ok");
// Act
var schema = script.ParametersSchema;
// Assert — parameterless delegates still produce a schema
Assert.NotNull(schema);
}
[Fact]
public void ParametersSchema_WithParameters_ContainsPropertyNames()
{
// Arrange
var script = new AgentInlineSkillScript("search", (string query, int limit) => $"{query}:{limit}");
// Act
var schema = script.ParametersSchema;
// Assert
Assert.NotNull(schema);
var schemaText = schema!.Value.GetRawText();
Assert.Contains("query", schemaText);
Assert.Contains("limit", schemaText);
}
[Fact]
public void Constructor_SetsNameAndDescription()
{
// Arrange & Act
var script = new AgentInlineSkillScript("my-script", () => "ok", "Does something.");
// Assert
Assert.Equal("my-script", script.Name);
Assert.Equal("Does something.", script.Description);
}
[Fact]
public void Constructor_NullDescription_DescriptionIsNull()
{
// Arrange & Act
var script = new AgentInlineSkillScript("my-script", () => "ok");
// Assert
Assert.Null(script.Description);
}
[Fact]
public void Constructor_NullName_Throws()
{
// Act & Assert
Assert.Throws<ArgumentNullException>(() =>
new AgentInlineSkillScript(null!, () => "ok"));
}
[Fact]
public void Constructor_WhitespaceName_Throws()
{
// Act & Assert
Assert.Throws<ArgumentException>(() =>
new AgentInlineSkillScript(" ", () => "ok"));
}
[Fact]
public void Constructor_NullMethod_Throws()
{
// Act & Assert
Assert.Throws<ArgumentNullException>(() =>
new AgentInlineSkillScript("my-script", null!));
}
[Fact]
public async Task RunAsync_StringParameter_WorksAsync()
{
// Arrange
var script = new AgentInlineSkillScript("echo", (string message) => message);
var skill = new AgentInlineSkill("test-skill", "Test.", "Instructions.");
var args = new AIFunctionArguments { ["message"] = "hello world" };
// Act
var result = await script.RunAsync(skill, args, CancellationToken.None);
// Assert
Assert.Equal("hello world", result?.ToString());
}
}
@@ -0,0 +1,420 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
/// <summary>
/// Unit tests for <see cref="AgentInlineSkill"/>.
/// </summary>
public sealed class AgentInlineSkillTests
{
[Fact]
public void Constructor_WithNameAndDescription_SetsFrontmatter()
{
// Arrange & Act
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
// Assert
Assert.Equal("my-skill", skill.Frontmatter.Name);
Assert.Equal("A valid skill.", skill.Frontmatter.Description);
Assert.Null(skill.Frontmatter.License);
Assert.Null(skill.Frontmatter.Compatibility);
Assert.Null(skill.Frontmatter.AllowedTools);
Assert.Null(skill.Frontmatter.Metadata);
}
[Fact]
public void Constructor_WithAllProps_SetsFrontmatter()
{
// Arrange
var metadata = new AdditionalPropertiesDictionary { ["key"] = "value" };
// Act
var skill = new AgentInlineSkill(
"my-skill",
"A valid skill.",
"Instructions.",
license: "MIT",
compatibility: "gpt-4",
allowedTools: "tool-a tool-b",
metadata: metadata);
// Assert
Assert.Equal("my-skill", skill.Frontmatter.Name);
Assert.Equal("A valid skill.", skill.Frontmatter.Description);
Assert.Equal("MIT", skill.Frontmatter.License);
Assert.Equal("gpt-4", skill.Frontmatter.Compatibility);
Assert.Equal("tool-a tool-b", skill.Frontmatter.AllowedTools);
Assert.NotNull(skill.Frontmatter.Metadata);
Assert.Equal("value", skill.Frontmatter.Metadata["key"]);
}
[Fact]
public void Constructor_WithFrontmatter_UsesFrontmatterDirectly()
{
// Arrange
var frontmatter = new AgentSkillFrontmatter("my-skill", "A valid skill.")
{
License = "Apache-2.0",
Compatibility = "gpt-4",
AllowedTools = "tool-a",
Metadata = new AdditionalPropertiesDictionary { ["env"] = "prod" },
};
// Act
var skill = new AgentInlineSkill(frontmatter, "Instructions.");
// Assert
Assert.Same(frontmatter, skill.Frontmatter);
Assert.Equal("Apache-2.0", skill.Frontmatter.License);
Assert.Equal("gpt-4", skill.Frontmatter.Compatibility);
Assert.Equal("tool-a", skill.Frontmatter.AllowedTools);
Assert.Equal("prod", skill.Frontmatter.Metadata!["env"]);
}
[Fact]
public void Constructor_WithFrontmatter_NullFrontmatter_Throws()
{
// Act & Assert
Assert.Throws<ArgumentNullException>(() =>
new AgentInlineSkill(null!, "Instructions."));
}
[Fact]
public void Constructor_WithFrontmatter_NullInstructions_Throws()
{
// Arrange
var frontmatter = new AgentSkillFrontmatter("my-skill", "A valid skill.");
// Act & Assert
Assert.Throws<ArgumentNullException>(() =>
new AgentInlineSkill(frontmatter, null!));
}
[Fact]
public void Constructor_WithAllProps_NullInstructions_Throws()
{
// Act & Assert
Assert.Throws<ArgumentNullException>(() =>
new AgentInlineSkill("my-skill", "A valid skill.", null!));
}
[Fact]
public void Content_ContainsNameDescriptionAndInstructions()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Do the thing.");
// Act
var content = skill.Content;
// Assert
Assert.Contains("<name>my-skill</name>", content);
Assert.Contains("<description>A valid skill.</description>", content);
Assert.Contains("<instructions>\nDo the thing.\n</instructions>", content);
}
[Fact]
public void Content_EscapesXmlCharacters()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "x<y>z\"w & it's more", "1 & 2 < 3");
// Act
var content = skill.Content;
// Assert
Assert.Contains("<name>my-skill</name>", content);
Assert.Contains("<description>x&lt;y&gt;z&quot;w &amp; it&apos;s more</description>", content);
Assert.Contains("1 &amp; 2 &lt; 3", content); // instructions are escaped
}
[Fact]
public void Content_IsCachedAcrossAccesses()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
// Act
var first = skill.Content;
var second = skill.Content;
// Assert
Assert.Same(first, second);
}
[Fact]
public void Content_IncludesResourcesAddedBeforeFirstAccess()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
skill.AddResource("config", "value1", "A config resource.");
// Act
var content = skill.Content;
// Assert
Assert.Contains("<resources>", content);
Assert.Contains("config", content);
}
[Fact]
public void Content_IncludesDelegateResourcesAddedBeforeFirstAccess()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
skill.AddResource("dynamic", () => "hello");
// Act
var content = skill.Content;
// Assert
Assert.Contains("<resources>", content);
Assert.Contains("dynamic", content);
}
[Fact]
public void Content_IncludesScriptsAddedBeforeFirstAccess()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
skill.AddScript("run", () => "result", "Runs something.");
// Act
var content = skill.Content;
// Assert
Assert.Contains("<scripts>", content);
Assert.Contains("run", content);
}
[Fact]
public void Content_IsCachedAndNotRebuilt()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
skill.AddResource("r1", "v1");
// Act
var first = skill.Content;
var second = skill.Content;
// Assert
Assert.Same(first, second);
}
[Fact]
public void Content_IncludesResourcesAndScriptsAddedBeforeFirstAccess()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
skill.AddResource("r1", "v1");
skill.AddScript("s1", () => "ok");
// Act
var content = skill.Content;
// Assert
Assert.Contains("<resources>", content);
Assert.Contains("r1", content);
Assert.Contains("<scripts>", content);
Assert.Contains("s1", content);
}
[Fact]
public void Content_ParametersSchema_IsXmlEscaped()
{
// 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;
// Assert — JSON schema should be present and XML content chars escaped
Assert.Contains("parameters_schema", content);
Assert.DoesNotContain("<![CDATA[", content);
}
[Fact]
public void AddResource_NullValue_Throws()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
// Act & Assert — cast needed to target the object overload
#pragma warning disable IDE0004
Assert.Throws<ArgumentNullException>(() => skill.AddResource("config", (object)null!));
#pragma warning restore IDE0004
}
[Fact]
public void AddResource_NullDelegate_Throws()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
// Act & Assert
Assert.Throws<ArgumentNullException>(() => skill.AddResource("config", null!));
}
[Fact]
public void AddScript_NullDelegate_Throws()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
// Act & Assert
Assert.Throws<ArgumentNullException>(() => skill.AddScript("run", null!));
}
[Fact]
public void Resources_WhenNoneAdded_ReturnsNull()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
// Act & Assert
Assert.Null(skill.Resources);
}
[Fact]
public void Scripts_WhenNoneAdded_ReturnsNull()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
// Act & Assert
Assert.Null(skill.Scripts);
}
[Fact]
public void AddResource_ReturnsSameInstance_ForChaining()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
// Act
var returned = skill.AddResource("r1", "v1");
// Assert
Assert.Same(skill, returned);
}
[Fact]
public void AddResource_Delegate_ReturnsSameInstance_ForChaining()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
// Act
var returned = skill.AddResource("r1", () => "v1");
// Assert
Assert.Same(skill, returned);
}
[Fact]
public void AddScript_ReturnsSameInstance_ForChaining()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
// Act
var returned = skill.AddScript("s1", () => "ok");
// Assert
Assert.Same(skill, returned);
}
[Fact]
public void Content_NoResourcesOrScripts_DoesNotContainResourcesOrScriptsTags()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
// Act
var content = skill.Content;
// Assert
Assert.DoesNotContain("<resources>", content);
Assert.DoesNotContain("<scripts>", content);
}
[Fact]
public void Content_ResourcesAddedAfterCaching_AreNotIncluded()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
_ = skill.Content; // trigger caching
skill.AddResource("late-resource", "late-value");
// Act
var content = skill.Content;
// Assert — the late resource should not appear because content was cached
Assert.DoesNotContain("late-resource", content);
}
[Fact]
public void Content_ScriptsAddedAfterCaching_AreNotIncluded()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
_ = skill.Content; // trigger caching
skill.AddScript("late-script", () => "late");
// Act
var content = skill.Content;
// Assert — the late script should not appear because content was cached
Assert.DoesNotContain("late-script", content);
}
[Fact]
public void Content_ScriptWithDescription_IncludesDescriptionAttribute()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
skill.AddScript("my-script", () => "ok", "Runs something.");
// Act
var content = skill.Content;
// Assert
Assert.Contains("description=\"Runs something.\"", content);
}
[Fact]
public void Content_ScriptWithoutParametersOrDescription_UsesSelfClosingTag()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
skill.AddScript("simple", () => "ok");
// Act
var content = skill.Content;
// Assert — parameterless Action delegates still produce a schema, so this
// verifies the script is at least included in the output
Assert.Contains("simple", content);
}
[Fact]
public void Content_ResourceWithDescription_IncludesDescriptionAttribute()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
skill.AddResource("with-desc", "value", "A described resource.");
skill.AddResource("no-desc", "value");
// Act
var content = skill.Content;
// Assert
Assert.Contains("description=\"A described resource.\"", content);
Assert.DoesNotContain("no-desc\" description", content);
}
}
@@ -270,7 +270,7 @@ public sealed class AgentSkillsProviderTests : IDisposable
// Arrange
var source = new CountingAgentSkillsSource(
[
new TestAgentSkill("concurrent-skill", "Concurrent test", "Body.")
new AgentInlineSkill("concurrent-skill", "Concurrent test", "Body.")
]);
var provider = new AgentSkillsProvider(source);
@@ -502,7 +502,7 @@ public sealed class AgentSkillsProviderTests : IDisposable
// Arrange
var source = new CountingAgentSkillsSource(
[
new TestAgentSkill("no-cache-skill", "No cache test", "Body.")
new AgentInlineSkill("no-cache-skill", "No cache test", "Body.")
]);
var provider = new AgentSkillsProviderBuilder()
.UseSource(source)
@@ -525,7 +525,7 @@ public sealed class AgentSkillsProviderTests : IDisposable
// Arrange
var source = new CountingAgentSkillsSource(
[
new TestAgentSkill("cached-skill", "Cached test", "Body.")
new AgentInlineSkill("cached-skill", "Cached test", "Body.")
]);
var provider = new AgentSkillsProviderBuilder()
.UseSource(source)
@@ -547,7 +547,7 @@ public sealed class AgentSkillsProviderTests : IDisposable
// Arrange
var source = new CountingAgentSkillsSource(
[
new TestAgentSkill("default-skill", "Default test", "Body.")
new AgentInlineSkill("default-skill", "Default test", "Body.")
]);
var provider = new AgentSkillsProviderBuilder()
.UseSource(source)
@@ -563,6 +563,78 @@ public sealed class AgentSkillsProviderTests : IDisposable
Assert.Equal(1, source.GetSkillsCallCount);
}
[Fact]
public async Task Build_PreservesSourceRegistrationOrderAsync()
{
// Arrange — register file, inline, file in that order
string dir1 = Path.Combine(this._testRoot, "dir1");
string dir2 = Path.Combine(this._testRoot, "dir2");
CreateSkillIn(dir1, "file-skill-1", "First file skill", "Body 1.");
CreateSkillIn(dir2, "file-skill-2", "Second file skill", "Body 2.");
var inlineSkill = new AgentInlineSkill("inline-skill", "Inline skill", "Body inline.");
var provider = new AgentSkillsProviderBuilder()
.UseFileSkill(dir1)
.UseSkills(inlineSkill)
.UseFileSkill(dir2)
.UseFileScriptRunner(s_noOpExecutor)
.UseOptions(o => o.DisableCaching = true)
.Build();
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
// Act
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
// Assert — all three skills should be present in alphabetical order in the prompt
Assert.NotNull(result.Instructions);
var instructions = result.Instructions!;
var indexFileSkill1 = instructions.IndexOf("file-skill-1", StringComparison.Ordinal);
var indexFileSkill2 = instructions.IndexOf("file-skill-2", StringComparison.Ordinal);
var indexInlineSkill = instructions.IndexOf("inline-skill", StringComparison.Ordinal);
Assert.True(indexFileSkill1 >= 0, "file-skill-1 should be present in the instructions.");
Assert.True(indexFileSkill2 >= 0, "file-skill-2 should be present in the instructions.");
Assert.True(indexInlineSkill >= 0, "inline-skill should be present in the instructions.");
Assert.True(indexFileSkill1 < indexFileSkill2, "file-skill-1 should appear before file-skill-2.");
Assert.True(indexFileSkill2 < indexInlineSkill, "file-skill-2 should appear before inline-skill.");
}
[Fact]
public async Task Build_MixedSources_AllSkillsDiscoveredAsync()
{
// Arrange — use UseSource, UseSkill, and UseFileSkill in mixed order
string dir = Path.Combine(this._testRoot, "mixed-dir");
CreateSkillIn(dir, "file-skill", "File skill", "Body file.");
var inlineSkill = new AgentInlineSkill("inline-skill", "Inline skill", "Body inline.");
var customSource = new CountingAgentSkillsSource(
[
new AgentInlineSkill("custom-skill", "Custom source skill", "Body custom.")
]);
var provider = new AgentSkillsProviderBuilder()
.UseSource(customSource)
.UseSkills(inlineSkill)
.UseFileSkill(dir)
.UseFileScriptRunner(s_noOpExecutor)
.UseOptions(o => o.DisableCaching = true)
.Build();
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
// Act
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
// Assert — all skills from all sources are present
Assert.NotNull(result.Instructions);
Assert.Contains("custom-skill", result.Instructions);
Assert.Contains("inline-skill", result.Instructions);
Assert.Contains("file-skill", result.Instructions);
}
[Fact]
public async Task InvokingCoreAsync_WithScriptsAndScriptApproval_WrapsRunScriptToolAsync()
{
@@ -722,6 +794,63 @@ public sealed class AgentSkillsProviderTests : IDisposable
Assert.Contains("Body 1.", content!.ToString()!);
}
[Fact]
public async Task Constructor_InlineSkillsParams_ProvidesSkillsAsync()
{
// Arrange
var skill1 = new AgentInlineSkill("inline-a", "Inline A", "Instructions A.");
var skill2 = new AgentInlineSkill("inline-b", "Inline B", "Instructions B.");
var provider = new AgentSkillsProvider(skill1, skill2);
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
// Act
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
// Assert
Assert.NotNull(result.Instructions);
Assert.Contains("inline-a", result.Instructions);
Assert.Contains("inline-b", result.Instructions);
}
[Fact]
public async Task Constructor_InlineSkillsEnumerable_ProvidesSkillsAsync()
{
// Arrange
var skills = new List<AgentInlineSkill>
{
new("enum-inline-a", "Inline A", "Instructions A."),
new("enum-inline-b", "Inline B", "Instructions B."),
};
var provider = new AgentSkillsProvider(skills);
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
// Act
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
// Assert
Assert.NotNull(result.Instructions);
Assert.Contains("enum-inline-a", result.Instructions);
Assert.Contains("enum-inline-b", result.Instructions);
}
[Fact]
public async Task Constructor_InlineSkills_DeduplicatesAsync()
{
// Arrange — two inline skills with the same name
var skill1 = new AgentInlineSkill("dup-inline", "First", "First instructions.");
var skill2 = new AgentInlineSkill("dup-inline", "Second", "Second instructions.");
var provider = new AgentSkillsProvider(skill1, skill2);
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
// Act
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
var loadSkillTool = result.Tools!.First(t => t.Name == "load_skill") as AIFunction;
var content = await loadSkillTool!.InvokeAsync(new AIFunctionArguments(new Dictionary<string, object?> { ["skillName"] = "dup-inline" }));
// Assert — only one occurrence (first)
Assert.Contains("First instructions.", content!.ToString()!);
}
/// <summary>
/// A test skill source that counts how many times <see cref="GetSkillsAsync"/> is called.
/// </summary>
@@ -743,23 +872,4 @@ public sealed class AgentSkillsProviderTests : IDisposable
return Task.FromResult(this._skills);
}
}
private sealed class TestAgentSkill : AgentSkill
{
private readonly string _content;
public TestAgentSkill(string name, string description, string content)
{
this.Frontmatter = new AgentSkillFrontmatter(name, description);
this._content = content;
}
public override AgentSkillFrontmatter Frontmatter { get; }
public override string Content => this._content;
public override IReadOnlyList<AgentSkillResource>? Resources => null;
public override IReadOnlyList<AgentSkillScript>? Scripts => null;
}
}
@@ -16,9 +16,11 @@ public sealed class DeduplicatingAgentSkillsSourceTests
public async Task GetSkillsAsync_NoDuplicates_ReturnsAllSkillsAsync()
{
// Arrange
var inner = new TestAgentSkillsSource(
new TestAgentSkill("skill-a", "A", "Instructions A."),
new TestAgentSkill("skill-b", "B", "Instructions B."));
var inner = new AgentInMemorySkillsSource(new AgentSkill[]
{
new AgentInlineSkill("skill-a", "A", "Instructions A."),
new AgentInlineSkill("skill-b", "B", "Instructions B."),
});
var source = new DeduplicatingAgentSkillsSource(inner);
// Act
@@ -34,11 +36,11 @@ public sealed class DeduplicatingAgentSkillsSourceTests
// Arrange
var skills = new AgentSkill[]
{
new TestAgentSkill("dupe", "First", "Instructions 1."),
new TestAgentSkill("dupe", "Second", "Instructions 2."),
new TestAgentSkill("unique", "Unique", "Instructions 3."),
new AgentInlineSkill("dupe", "First", "Instructions 1."),
new AgentInlineSkill("dupe", "Second", "Instructions 2."),
new AgentInlineSkill("unique", "Unique", "Instructions 3."),
};
var inner = new TestAgentSkillsSource(skills);
var inner = new AgentInMemorySkillsSource(skills);
var source = new DeduplicatingAgentSkillsSource(inner);
// Act
@@ -53,7 +55,7 @@ public sealed class DeduplicatingAgentSkillsSourceTests
[Fact]
public async Task GetSkillsAsync_CaseInsensitiveDuplication_KeepsFirstAsync()
{
// Arrange — use a custom source that returns skills with same name but different casing
// Arrange - Use a custom source that returns skills with same name but different casing
var inner = new FakeDuplicateCaseSource();
var source = new DeduplicatingAgentSkillsSource(inner);
@@ -69,7 +71,7 @@ public sealed class DeduplicatingAgentSkillsSourceTests
public async Task GetSkillsAsync_EmptySource_ReturnsEmptyAsync()
{
// Arrange
var inner = new TestAgentSkillsSource(System.Array.Empty<AgentSkill>());
var inner = new AgentInMemorySkillsSource(System.Array.Empty<AgentSkill>());
var source = new DeduplicatingAgentSkillsSource(inner);
// Act
@@ -90,8 +92,8 @@ public sealed class DeduplicatingAgentSkillsSourceTests
// two skills with the same lowercase name to test case-insensitive dedup.
var skills = new List<AgentSkill>
{
new TestAgentSkill("my-skill", "First", "Instructions 1."),
new TestAgentSkill("my-skill", "Second", "Instructions 2."),
new AgentInlineSkill("my-skill", "First", "Instructions 1."),
new AgentInlineSkill("my-skill", "Second", "Instructions 2."),
};
return Task.FromResult<IList<AgentSkill>>(skills);
}
@@ -15,9 +15,11 @@ public sealed class FilteringAgentSkillsSourceTests
public async Task GetSkillsAsync_PredicateIncludesAll_ReturnsAllSkillsAsync()
{
// Arrange
var inner = new TestAgentSkillsSource(
new TestAgentSkill("skill-a", "A", "Instructions A."),
new TestAgentSkill("skill-b", "B", "Instructions B."));
var inner = new AgentInMemorySkillsSource(new AgentSkill[]
{
new AgentInlineSkill("skill-a", "A", "Instructions A."),
new AgentInlineSkill("skill-b", "B", "Instructions B."),
});
var source = new FilteringAgentSkillsSource(inner, _ => true);
// Act
@@ -31,9 +33,11 @@ public sealed class FilteringAgentSkillsSourceTests
public async Task GetSkillsAsync_PredicateExcludesAll_ReturnsEmptyAsync()
{
// Arrange
var inner = new TestAgentSkillsSource(
new TestAgentSkill("skill-a", "A", "Instructions A."),
new TestAgentSkill("skill-b", "B", "Instructions B."));
var inner = new AgentInMemorySkillsSource(new AgentSkill[]
{
new AgentInlineSkill("skill-a", "A", "Instructions A."),
new AgentInlineSkill("skill-b", "B", "Instructions B."),
});
var source = new FilteringAgentSkillsSource(inner, _ => false);
// Act
@@ -47,10 +51,12 @@ public sealed class FilteringAgentSkillsSourceTests
public async Task GetSkillsAsync_PartialFilter_ReturnsMatchingSkillsOnlyAsync()
{
// Arrange
var inner = new TestAgentSkillsSource(
new TestAgentSkill("keep-me", "Keep", "Instructions."),
new TestAgentSkill("drop-me", "Drop", "Instructions."),
new TestAgentSkill("keep-also", "KeepAlso", "Instructions."));
var inner = new AgentInMemorySkillsSource(new AgentSkill[]
{
new AgentInlineSkill("keep-me", "Keep", "Instructions."),
new AgentInlineSkill("drop-me", "Drop", "Instructions."),
new AgentInlineSkill("keep-also", "KeepAlso", "Instructions."),
});
var source = new FilteringAgentSkillsSource(
inner,
skill => skill.Frontmatter.Name.StartsWith("keep", StringComparison.OrdinalIgnoreCase));
@@ -67,7 +73,7 @@ public sealed class FilteringAgentSkillsSourceTests
public async Task GetSkillsAsync_EmptySource_ReturnsEmptyAsync()
{
// Arrange
var inner = new TestAgentSkillsSource(Array.Empty<AgentSkill>());
var inner = new AgentInMemorySkillsSource(Array.Empty<AgentSkill>());
var source = new FilteringAgentSkillsSource(inner, _ => true);
// Act
@@ -81,7 +87,7 @@ public sealed class FilteringAgentSkillsSourceTests
public void Constructor_NullPredicate_Throws()
{
// Arrange
var inner = new TestAgentSkillsSource(Array.Empty<AgentSkill>());
var inner = new AgentInMemorySkillsSource(Array.Empty<AgentSkill>());
// Act & Assert
Assert.Throws<ArgumentNullException>(() => new FilteringAgentSkillsSource(inner, null!));
@@ -98,11 +104,13 @@ public sealed class FilteringAgentSkillsSourceTests
public async Task GetSkillsAsync_PreservesOrderAsync()
{
// Arrange
var inner = new TestAgentSkillsSource(
new TestAgentSkill("alpha", "Alpha", "Instructions."),
new TestAgentSkill("beta", "Beta", "Instructions."),
new TestAgentSkill("gamma", "Gamma", "Instructions."),
new TestAgentSkill("delta", "Delta", "Instructions."));
var inner = new AgentInMemorySkillsSource(new AgentSkill[]
{
new AgentInlineSkill("alpha", "Alpha", "Instructions."),
new AgentInlineSkill("beta", "Beta", "Instructions."),
new AgentInlineSkill("gamma", "Gamma", "Instructions."),
new AgentInlineSkill("delta", "Delta", "Instructions."),
});
// Keep only alpha and gamma
var source = new FilteringAgentSkillsSource(