From 3c1288bf9202a04f5a93591c203dd6b72795b627 Mon Sep 17 00:00:00 2001
From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com>
Date: Wed, 1 Apr 2026 11:51:52 +0000
Subject: [PATCH 01/10] add class-based skills
---
dotnet/agent-framework-dotnet.slnx | 4 +
dotnet/filtered-unit.slnx | 342 ++++++++++++++++
.../Agent_Step01_FileBasedSkills/README.md | 2 +-
.../Agent_Step03_ClassBasedSkills.csproj | 21 +
.../Agent_Step03_ClassBasedSkills/Program.cs | 102 +++++
.../Agent_Step03_ClassBasedSkills/README.md | 49 +++
.../Agent_Step04_MixedSkills.csproj | 32 ++
.../Agent_Step04_MixedSkills/Program.cs | 149 +++++++
.../Agent_Step04_MixedSkills/README.md | 67 +++
.../skills/unit-converter/SKILL.md | 11 +
.../references/unit-conversion-table.md | 10 +
.../unit-converter/scripts/convert-units.py | 29 ++
...gent_Step05_CodeDefinedSkillsWithDI.csproj | 22 +
.../Program.cs | 116 ++++++
.../README.md | 38 ++
...Agent_Step06_ClassBasedSkillsWithDI.csproj | 22 +
.../Program.cs | 140 +++++++
.../README.md | 58 +++
.../samples/02-agents/AgentSkills/README.md | 34 +-
.../Skills/AgentSkillScript.cs | 6 +
.../Skills/AgentSkillsProvider.cs | 16 +-
.../Skills/AgentSkillsProviderBuilder.cs | 18 +-
.../Skills/Programmatic/AgentClassSkill.cs | 95 +++++
.../Skills/Programmatic/AgentInlineSkill.cs | 95 +----
.../AgentInlineSkillContentBuilder.cs | 118 ++++++
.../Programmatic/AgentInlineSkillScript.cs | 2 +-
.../AgentSkills/AgentClassSkillTests.cs | 382 ++++++++++++++++++
.../AgentSkills/AgentSkillsProviderTests.cs | 74 ++++
.../DeduplicatingAgentSkillsSourceTests.cs | 8 +-
.../FilteringAgentSkillsSourceTests.cs | 10 +-
30 files changed, 1949 insertions(+), 123 deletions(-)
create mode 100644 dotnet/filtered-unit.slnx
create mode 100644 dotnet/samples/02-agents/AgentSkills/Agent_Step03_ClassBasedSkills/Agent_Step03_ClassBasedSkills.csproj
create mode 100644 dotnet/samples/02-agents/AgentSkills/Agent_Step03_ClassBasedSkills/Program.cs
create mode 100644 dotnet/samples/02-agents/AgentSkills/Agent_Step03_ClassBasedSkills/README.md
create mode 100644 dotnet/samples/02-agents/AgentSkills/Agent_Step04_MixedSkills/Agent_Step04_MixedSkills.csproj
create mode 100644 dotnet/samples/02-agents/AgentSkills/Agent_Step04_MixedSkills/Program.cs
create mode 100644 dotnet/samples/02-agents/AgentSkills/Agent_Step04_MixedSkills/README.md
create mode 100644 dotnet/samples/02-agents/AgentSkills/Agent_Step04_MixedSkills/skills/unit-converter/SKILL.md
create mode 100644 dotnet/samples/02-agents/AgentSkills/Agent_Step04_MixedSkills/skills/unit-converter/references/unit-conversion-table.md
create mode 100644 dotnet/samples/02-agents/AgentSkills/Agent_Step04_MixedSkills/skills/unit-converter/scripts/convert-units.py
create mode 100644 dotnet/samples/02-agents/AgentSkills/Agent_Step05_CodeDefinedSkillsWithDI/Agent_Step05_CodeDefinedSkillsWithDI.csproj
create mode 100644 dotnet/samples/02-agents/AgentSkills/Agent_Step05_CodeDefinedSkillsWithDI/Program.cs
create mode 100644 dotnet/samples/02-agents/AgentSkills/Agent_Step05_CodeDefinedSkillsWithDI/README.md
create mode 100644 dotnet/samples/02-agents/AgentSkills/Agent_Step06_ClassBasedSkillsWithDI/Agent_Step06_ClassBasedSkillsWithDI.csproj
create mode 100644 dotnet/samples/02-agents/AgentSkills/Agent_Step06_ClassBasedSkillsWithDI/Program.cs
create mode 100644 dotnet/samples/02-agents/AgentSkills/Agent_Step06_ClassBasedSkillsWithDI/README.md
create mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentClassSkill.cs
create mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillContentBuilder.cs
create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentClassSkillTests.cs
diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx
index b9755dac837..f81fd55d582 100644
--- a/dotnet/agent-framework-dotnet.slnx
+++ b/dotnet/agent-framework-dotnet.slnx
@@ -106,6 +106,10 @@
+
+
+
+
diff --git a/dotnet/filtered-unit.slnx b/dotnet/filtered-unit.slnx
new file mode 100644
index 00000000000..1a3ad7e6b20
--- /dev/null
+++ b/dotnet/filtered-unit.slnx
@@ -0,0 +1,342 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/README.md b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/README.md
index 41b813b98ff..592aca4a279 100644
--- a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/README.md
+++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/README.md
@@ -6,7 +6,7 @@ This sample demonstrates how to use **file-based Agent Skills** with a `ChatClie
- Discovering skills from `SKILL.md` files on disk via `AgentFileSkillsSource`
- The progressive disclosure pattern: advertise → load → read resources → run scripts
-- Using the `AgentSkillsProvider` constructor with a skill directory path and script executor
+- Using the `AgentSkillsProvider` constructor with a skill directory path and script runner
- Running file-based scripts (Python) via a subprocess-based executor
## Skills Included
diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step03_ClassBasedSkills/Agent_Step03_ClassBasedSkills.csproj b/dotnet/samples/02-agents/AgentSkills/Agent_Step03_ClassBasedSkills/Agent_Step03_ClassBasedSkills.csproj
new file mode 100644
index 00000000000..fd3d71fe7e9
--- /dev/null
+++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step03_ClassBasedSkills/Agent_Step03_ClassBasedSkills.csproj
@@ -0,0 +1,21 @@
+
+
+
+ Exe
+ net10.0
+
+ enable
+ enable
+ $(NoWarn);MAAI001
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step03_ClassBasedSkills/Program.cs b/dotnet/samples/02-agents/AgentSkills/Agent_Step03_ClassBasedSkills/Program.cs
new file mode 100644
index 00000000000..fb0f202230c
--- /dev/null
+++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step03_ClassBasedSkills/Program.cs
@@ -0,0 +1,102 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+// This sample demonstrates how to define Agent Skills as C# classes using AgentClassSkill.
+// Class-based skills bundle all components into a single class implementation.
+
+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";
+
+// --- Class-Based Skill ---
+// Instantiate the skill class.
+var unitConverter = new UnitConverterSkill();
+
+// --- Skills Provider ---
+var skillsProvider = new AgentSkillsProvider(unitConverter);
+
+// --- 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 class-based 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}");
+
+///
+/// A unit-converter skill defined as a C# class.
+///
+///
+/// Class-based skills bundle all components (name, description, body, resources, scripts)
+/// into a single class.
+///
+internal sealed class UnitConverterSkill : AgentClassSkill
+{
+ private IReadOnlyList? _resources;
+ private IReadOnlyList? _scripts;
+
+ ///
+ public override AgentSkillFrontmatter Frontmatter { get; } = new(
+ "unit-converter",
+ "Convert between common units using a multiplication factor. Use when asked to convert miles, kilometers, pounds, or kilograms.");
+
+ ///
+ protected override string 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. Use the convert script, passing the value and factor from the table.
+ 3. Present the result clearly with both units.
+ """;
+
+ ///
+ public override IReadOnlyList? Resources => this._resources ??=
+ [
+ CreateResource(
+ "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 |
+ """),
+ ];
+
+ ///
+ public override IReadOnlyList? Scripts => this._scripts ??=
+ [
+ CreateScript("convert", ConvertUnits),
+ ];
+
+ private static string ConvertUnits(double value, double factor)
+ {
+ double result = Math.Round(value * factor, 4);
+ return JsonSerializer.Serialize(new { value, factor, result });
+ }
+}
diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step03_ClassBasedSkills/README.md b/dotnet/samples/02-agents/AgentSkills/Agent_Step03_ClassBasedSkills/README.md
new file mode 100644
index 00000000000..506784256ac
--- /dev/null
+++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step03_ClassBasedSkills/README.md
@@ -0,0 +1,49 @@
+# Class-Based Agent Skills Sample
+
+This sample demonstrates how to define **Agent Skills as C# classes** using `AgentClassSkill`.
+
+## What it demonstrates
+
+- Creating skills as classes that extend `AgentClassSkill`
+- Bundling name, description, body, resources, and scripts into a single class
+- Using the `AgentSkillsProvider` constructor with class-based skills
+
+## Skills Included
+
+### unit-converter (class-based)
+
+A `UnitConverterSkill` class that converts between common units. Defined in `Program.cs`:
+
+- `conversion-table` — Static resource with factor table
+- `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 class-based skills
+------------------------------------------------------------
+Agent: Here are your conversions:
+
+1. **26.2 miles → 42.16 km** (a marathon distance)
+2. **75 kg → 165.35 lbs**
+```
diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step04_MixedSkills/Agent_Step04_MixedSkills.csproj b/dotnet/samples/02-agents/AgentSkills/Agent_Step04_MixedSkills/Agent_Step04_MixedSkills.csproj
new file mode 100644
index 00000000000..7e7e9ef0fab
--- /dev/null
+++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step04_MixedSkills/Agent_Step04_MixedSkills.csproj
@@ -0,0 +1,32 @@
+
+
+
+ Exe
+ net10.0
+
+ enable
+ enable
+ $(NoWarn);MAAI001
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ PreserveNewest
+
+
+
+
diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step04_MixedSkills/Program.cs b/dotnet/samples/02-agents/AgentSkills/Agent_Step04_MixedSkills/Program.cs
new file mode 100644
index 00000000000..b8a9e8fbb1c
--- /dev/null
+++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step04_MixedSkills/Program.cs
@@ -0,0 +1,149 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+// This sample demonstrates an advanced scenario: combining multiple skill types in a single agent
+// using AgentSkillsProviderBuilder. The builder is designed for cases where the simple
+// AgentSkillsProvider constructors are insufficient — for example, when you need to mix skill
+// sources, apply filtering, or configure cross-cutting options in one place.
+//
+// Three different skill sources are registered here:
+// 1. File-based: unit-converter (miles↔km, pounds↔kg) from SKILL.md on disk
+// 2. Code-defined: volume-converter (gallons↔liters) using AgentInlineSkill
+// 3. Class-based: temperature-converter (°F↔°C↔K) using AgentClassSkill
+//
+// For simpler, single-source scenarios, see the earlier steps in this sample series
+// (e.g., Step01 for file-based, Step02 for code-defined, Step03 for class-based).
+
+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";
+
+// --- 1. Code-Defined Skill: volume-converter ---
+var volumeConverterSkill = new AgentInlineSkill(
+ name: "volume-converter",
+ description: "Convert between gallons and liters using a multiplication factor.",
+ instructions: """
+ Use this skill when the user asks to convert between gallons and liters.
+
+ 1. Review the volume-conversion-table resource to find the correct factor.
+ 2. Use the convert-volume script, passing the value and factor.
+ """)
+ .AddResource("volume-conversion-table",
+ """
+ # Volume Conversion Table
+
+ Formula: **result = value × factor**
+
+ | From | To | Factor |
+ |---------|---------|---------|
+ | gallons | liters | 3.78541 |
+ | liters | gallons | 0.264172|
+ """)
+ .AddScript("convert-volume", (double value, double factor) =>
+ {
+ double result = Math.Round(value * factor, 4);
+ return JsonSerializer.Serialize(new { value, factor, result });
+ });
+
+// --- 2. Class-Based Skill: temperature-converter ---
+var temperatureConverter = new TemperatureConverterSkill();
+
+// --- 3. Build provider combining all three source types ---
+var skillsProvider = new AgentSkillsProviderBuilder()
+ .UseFileSkill(Path.Combine(AppContext.BaseDirectory, "skills")) // File-based: unit-converter
+ .UseSkill(volumeConverterSkill) // Code-defined: volume-converter
+ .UseSkill(temperatureConverter) // Class-based: temperature-converter
+ .UseFileScriptRunner(SubprocessScriptRunner.RunAsync)
+ .Build();
+
+// --- Agent Setup ---
+AIAgent agent = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential())
+ .GetResponsesClient()
+ .AsAIAgent(new ChatClientAgentOptions
+ {
+ Name = "MultiConverterAgent",
+ ChatOptions = new()
+ {
+ Instructions = "You are a helpful assistant that can convert units, volumes, and temperatures.",
+ },
+ AIContextProviders = [skillsProvider],
+ },
+ model: deploymentName);
+
+// --- Example: Use all three skills ---
+Console.WriteLine("Converting with mixed skills (file + code + class)");
+Console.WriteLine(new string('-', 60));
+
+AgentResponse response = await agent.RunAsync(
+ "I need three conversions: " +
+ "1) How many kilometers is a marathon (26.2 miles)? " +
+ "2) How many liters is a 5-gallon bucket? " +
+ "3) What is 98.6°F in Celsius?");
+
+Console.WriteLine($"Agent: {response.Text}");
+
+///
+/// A temperature-converter skill defined as a C# class.
+///
+internal sealed class TemperatureConverterSkill : AgentClassSkill
+{
+ private IReadOnlyList? _resources;
+ private IReadOnlyList? _scripts;
+
+ ///
+ public override AgentSkillFrontmatter Frontmatter { get; } = new(
+ "temperature-converter",
+ "Convert between temperature scales (Fahrenheit, Celsius, Kelvin).");
+
+ ///
+ protected override string Instructions => """
+ Use this skill when the user asks to convert temperatures.
+
+ 1. Review the temperature-conversion-formulas resource for the correct formula.
+ 2. Use the convert-temperature script, passing the value, source scale, and target scale.
+ 3. Present the result clearly with both temperature scales.
+ """;
+
+ ///
+ public override IReadOnlyList? Resources => this._resources ??=
+ [
+ CreateResource(
+ "temperature-conversion-formulas",
+ """
+ # Temperature Conversion Formulas
+
+ | From | To | Formula |
+ |-------------|-------------|---------------------------|
+ | Fahrenheit | Celsius | °C = (°F − 32) × 5/9 |
+ | Celsius | Fahrenheit | °F = (°C × 9/5) + 32 |
+ | Celsius | Kelvin | K = °C + 273.15 |
+ | Kelvin | Celsius | °C = K − 273.15 |
+ """),
+ ];
+
+ ///
+ public override IReadOnlyList? Scripts => this._scripts ??=
+ [
+ CreateScript("convert-temperature", ConvertTemperature),
+ ];
+
+ private static string ConvertTemperature(double value, string from, string to)
+ {
+ double result = (from.ToUpperInvariant(), to.ToUpperInvariant()) switch
+ {
+ ("FAHRENHEIT", "CELSIUS") => Math.Round((value - 32) * 5.0 / 9.0, 2),
+ ("CELSIUS", "FAHRENHEIT") => Math.Round(value * 9.0 / 5.0 + 32, 2),
+ ("CELSIUS", "KELVIN") => Math.Round(value + 273.15, 2),
+ ("KELVIN", "CELSIUS") => Math.Round(value - 273.15, 2),
+ _ => throw new ArgumentException($"Unsupported conversion: {from} → {to}")
+ };
+
+ return JsonSerializer.Serialize(new { value, from, to, result });
+ }
+}
diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step04_MixedSkills/README.md b/dotnet/samples/02-agents/AgentSkills/Agent_Step04_MixedSkills/README.md
new file mode 100644
index 00000000000..14a0d089b9d
--- /dev/null
+++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step04_MixedSkills/README.md
@@ -0,0 +1,67 @@
+# Mixed Agent Skills Sample (Advanced)
+
+This sample demonstrates an **advanced scenario**: combining multiple skill types in a single agent using `AgentSkillsProviderBuilder`.
+
+> **Tip:** For simpler, single-source scenarios, use the `AgentSkillsProvider` constructors directly — see [Step01](../Agent_Step01_FileBasedSkills/) (file-based), [Step02](../Agent_Step02_CodeDefinedSkills/) (code-defined), or [Step03](../Agent_Step03_ClassBasedSkills/) (class-based).
+
+## What it demonstrates
+
+- Combining file-based, code-defined, and class-based skills in one provider
+- Using `UseFileSkill` and `UseSkill` on the builder to register different skill types
+- Aggregating skills from all sources into a single provider with automatic deduplication
+
+## When to use `AgentSkillsProviderBuilder`
+
+The builder is intended for advanced scenarios where the simple `AgentSkillsProvider` constructors are insufficient:
+
+| Scenario | Builder method |
+|----------|---------------|
+| **Mixed skill types** — combine file-based, code-defined, and class-based skills | `UseFileSkill` + `UseSkill` / `UseSkills` |
+| **Multiple file script runners** — use different script runners for different file skill directories | `UseFileSkill` / `UseFileSkills` with per-source `scriptRunner` |
+| **Skill filtering** — include/exclude skills using a predicate | `UseFilter(predicate)` |
+
+## Skills Included
+
+### unit-converter (file-based)
+
+Discovered from `skills/unit-converter/SKILL.md` on disk. Converts miles↔km, pounds↔kg.
+
+### volume-converter (code-defined)
+
+Defined as `AgentInlineSkill` in `Program.cs`. Converts gallons↔liters.
+
+### temperature-converter (class-based)
+
+Defined as `TemperatureConverterSkill` class in `Program.cs`. Converts °F↔°C↔K.
+
+## 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 with mixed skills (file + code + class)
+------------------------------------------------------------
+Agent: Here are your conversions:
+
+1. **26.2 miles → 42.16 km** (a marathon distance)
+2. **5 gallons → 18.93 liters**
+3. **98.6°F → 37.0°C**
+```
diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step04_MixedSkills/skills/unit-converter/SKILL.md b/dotnet/samples/02-agents/AgentSkills/Agent_Step04_MixedSkills/skills/unit-converter/SKILL.md
new file mode 100644
index 00000000000..246a3392f73
--- /dev/null
+++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step04_MixedSkills/skills/unit-converter/SKILL.md
@@ -0,0 +1,11 @@
+---
+name: unit-converter
+description: Convert between common units using a multiplication factor. Use when asked to convert miles, kilometers, pounds, or kilograms.
+---
+
+## Usage
+
+When the user requests a unit conversion:
+1. First, review `references/unit-conversion-table.md` to find the correct factor
+2. Run the `scripts/convert-units.py` script with `--value --factor ` (e.g. `--value 26.2 --factor 1.60934`)
+3. Present the converted value clearly with both units
diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step04_MixedSkills/skills/unit-converter/references/unit-conversion-table.md b/dotnet/samples/02-agents/AgentSkills/Agent_Step04_MixedSkills/skills/unit-converter/references/unit-conversion-table.md
new file mode 100644
index 00000000000..7a0160b8546
--- /dev/null
+++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step04_MixedSkills/skills/unit-converter/references/unit-conversion-table.md
@@ -0,0 +1,10 @@
+# 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 |
diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step04_MixedSkills/skills/unit-converter/scripts/convert-units.py b/dotnet/samples/02-agents/AgentSkills/Agent_Step04_MixedSkills/skills/unit-converter/scripts/convert-units.py
new file mode 100644
index 00000000000..ac271dd5941
--- /dev/null
+++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step04_MixedSkills/skills/unit-converter/scripts/convert-units.py
@@ -0,0 +1,29 @@
+# Unit conversion script
+# Converts a value using a multiplication factor: result = value × factor
+#
+# Usage:
+# python scripts/convert-units.py --value 26.2 --factor 1.60934
+# python scripts/convert-units.py --value 75 --factor 2.20462
+
+import argparse
+import json
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(
+ description="Convert a value using a multiplication factor.",
+ epilog="Examples:\n"
+ " python scripts/convert-units.py --value 26.2 --factor 1.60934\n"
+ " python scripts/convert-units.py --value 75 --factor 2.20462",
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ )
+ parser.add_argument("--value", type=float, required=True, help="The numeric value to convert.")
+ parser.add_argument("--factor", type=float, required=True, help="The conversion factor from the table.")
+ args = parser.parse_args()
+
+ result = round(args.value * args.factor, 4)
+ print(json.dumps({"value": args.value, "factor": args.factor, "result": result}))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step05_CodeDefinedSkillsWithDI/Agent_Step05_CodeDefinedSkillsWithDI.csproj b/dotnet/samples/02-agents/AgentSkills/Agent_Step05_CodeDefinedSkillsWithDI/Agent_Step05_CodeDefinedSkillsWithDI.csproj
new file mode 100644
index 00000000000..959fa29167a
--- /dev/null
+++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step05_CodeDefinedSkillsWithDI/Agent_Step05_CodeDefinedSkillsWithDI.csproj
@@ -0,0 +1,22 @@
+
+
+
+ Exe
+ net10.0
+
+ enable
+ enable
+ $(NoWarn);MAAI001;CA1812
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step05_CodeDefinedSkillsWithDI/Program.cs b/dotnet/samples/02-agents/AgentSkills/Agent_Step05_CodeDefinedSkillsWithDI/Program.cs
new file mode 100644
index 00000000000..9424b1acaae
--- /dev/null
+++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step05_CodeDefinedSkillsWithDI/Program.cs
@@ -0,0 +1,116 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+// This sample demonstrates how to use Dependency Injection (DI) with Agent Skills.
+// Skill script and resource functions can resolve services from the DI container via
+// IServiceProvider, enabling clean separation of concerns and testability.
+//
+// The sample registers a ConversionRateService in the DI container. A code-defined skill
+// resource resolves this service to list supported conversions dynamically, and a skill
+// script resolves it to look up live conversion rates at execution time.
+
+using System.Text.Json;
+using Azure.AI.OpenAI;
+using Azure.Identity;
+using Microsoft.Agents.AI;
+using Microsoft.Extensions.DependencyInjection;
+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 ---
+// The skill uses DI to resolve ConversionRateService in both its resource and script functions.
+var unitConverterSkill = new AgentInlineSkill(
+ name: "unit-converter",
+ description: "Convert between common units. 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.
+ """)
+ // Dynamic resource with DI: resolves ConversionRateService to build conversion table
+ .AddResource("conversion-table", (IServiceProvider serviceProvider) =>
+ {
+ var rateService = serviceProvider.GetRequiredService();
+ return rateService.GetConversionTable();
+ })
+ // Script with DI: resolves ConversionRateService to perform the conversion
+ .AddScript("convert", (double value, double factor, IServiceProvider serviceProvider) =>
+ {
+ var rateService = serviceProvider.GetRequiredService();
+ return rateService.Convert(value, factor);
+ });
+
+// --- Skills Provider ---
+var skillsProvider = new AgentSkillsProvider(unitConverterSkill);
+
+// --- DI Container ---
+// Register application services that skill scripts can resolve at execution time.
+ServiceCollection services = new();
+services.AddSingleton();
+
+// --- Agent Setup ---
+AIAgent agent = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential())
+ .GetResponsesClient()
+ .AsAIAgent(
+ options: new ChatClientAgentOptions
+ {
+ Name = "UnitConverterAgent",
+ ChatOptions = new()
+ {
+ Instructions = "You are a helpful assistant that can convert units.",
+ },
+ AIContextProviders = [skillsProvider],
+ },
+ model: deploymentName,
+ services: services.BuildServiceProvider());
+
+// --- Example: Unit conversion ---
+Console.WriteLine("Converting units with DI-powered 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}");
+
+// ---------------------------------------------------------------------------
+// Services
+// ---------------------------------------------------------------------------
+
+///
+/// Provides conversion rates between units.
+/// In a real application this could call an external API, read from a database,
+/// or apply time-varying exchange rates.
+///
+internal sealed class ConversionRateService
+{
+ ///
+ /// Returns a static markdown table of all supported conversions with factors.
+ ///
+ public string GetConversionTable() =>
+ """
+ # 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 |
+ """;
+
+ ///
+ /// Converts a value by the given factor and returns a JSON result.
+ ///
+ public string Convert(double value, double factor)
+ {
+ double result = Math.Round(value * factor, 4);
+ return JsonSerializer.Serialize(new { value, factor, result });
+ }
+}
diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step05_CodeDefinedSkillsWithDI/README.md b/dotnet/samples/02-agents/AgentSkills/Agent_Step05_CodeDefinedSkillsWithDI/README.md
new file mode 100644
index 00000000000..aa4281ca000
--- /dev/null
+++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step05_CodeDefinedSkillsWithDI/README.md
@@ -0,0 +1,38 @@
+# Agent Skills with Dependency Injection
+
+This sample demonstrates how to use **Dependency Injection (DI)** with Agent Skills resources and script functions.
+
+## What It Shows
+
+- Registering application services in a `ServiceCollection`
+- Defining a code-defined skill resource that resolves services from `IServiceProvider`
+- Defining a code-defined skill script that resolves services from `IServiceProvider`
+- Passing the built `IServiceProvider` to the agent so skills can access DI services at execution time
+
+## How It Works
+
+1. A `ConversionRateService` is registered as a singleton in the DI container
+2. A code-defined skill resource declares `IServiceProvider` as a parameter — the framework injects it automatically
+3. The resource resolves `ConversionRateService` from the provider to build a supported-conversions table dynamically
+4. A code-defined skill script also declares `IServiceProvider` as a parameter to look up conversion factors at runtime
+5. The agent is created with the service provider, which flows through to skill resource and script execution
+
+## Prerequisites
+
+- .NET 10
+- An Azure OpenAI deployment
+
+## Configuration
+
+Set the following environment variables:
+
+| Variable | Description |
+|---|---|
+| `AZURE_OPENAI_ENDPOINT` | Your Azure OpenAI endpoint URL |
+| `AZURE_OPENAI_DEPLOYMENT_NAME` | Model deployment name (defaults to `gpt-4o-mini`) |
+
+## Running the Sample
+
+```bash
+dotnet run
+```
diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step06_ClassBasedSkillsWithDI/Agent_Step06_ClassBasedSkillsWithDI.csproj b/dotnet/samples/02-agents/AgentSkills/Agent_Step06_ClassBasedSkillsWithDI/Agent_Step06_ClassBasedSkillsWithDI.csproj
new file mode 100644
index 00000000000..959fa29167a
--- /dev/null
+++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step06_ClassBasedSkillsWithDI/Agent_Step06_ClassBasedSkillsWithDI.csproj
@@ -0,0 +1,22 @@
+
+
+
+ Exe
+ net10.0
+
+ enable
+ enable
+ $(NoWarn);MAAI001;CA1812
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step06_ClassBasedSkillsWithDI/Program.cs b/dotnet/samples/02-agents/AgentSkills/Agent_Step06_ClassBasedSkillsWithDI/Program.cs
new file mode 100644
index 00000000000..08044ddfc7a
--- /dev/null
+++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step06_ClassBasedSkillsWithDI/Program.cs
@@ -0,0 +1,140 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+// This sample demonstrates how to use Dependency Injection (DI) with class-based Agent Skills.
+// Unlike code-defined skills (Step05), class-based skills bundle all components into a single
+// class extending AgentClassSkill. Skill script and resource functions can still resolve
+// services from the DI container via IServiceProvider, combining class-based organization
+// with the flexibility of DI.
+
+using System.Text.Json;
+using Azure.AI.OpenAI;
+using Azure.Identity;
+using Microsoft.Agents.AI;
+using Microsoft.Extensions.DependencyInjection;
+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";
+
+// --- Class-Based Skill with DI ---
+// Instantiate the skill class. Its resources and scripts will resolve services from
+// the DI container at execution time.
+var unitConverter = new UnitConverterSkill();
+
+// --- Skills Provider ---
+var skillsProvider = new AgentSkillsProvider(unitConverter);
+
+// --- DI Container ---
+// Register application services that skill scripts can resolve at execution time.
+ServiceCollection services = new();
+services.AddSingleton();
+
+// --- Agent Setup ---
+AIAgent agent = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential())
+ .GetResponsesClient()
+ .AsAIAgent(
+ options: new ChatClientAgentOptions
+ {
+ Name = "UnitConverterAgent",
+ ChatOptions = new()
+ {
+ Instructions = "You are a helpful assistant that can convert units.",
+ },
+ AIContextProviders = [skillsProvider],
+ },
+ model: deploymentName,
+ services: services.BuildServiceProvider());
+
+// --- Example: Unit conversion ---
+Console.WriteLine("Converting units with DI-powered class-based 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}");
+
+///
+/// A unit-converter skill defined as a C# class that uses Dependency Injection.
+///
+///
+/// This skill resolves from the DI container
+/// in both its resource and script functions. This enables clean separation of
+/// concerns and testability while retaining the class-based skill pattern.
+///
+internal sealed class UnitConverterSkill : AgentClassSkill
+{
+ private IReadOnlyList? _resources;
+ private IReadOnlyList? _scripts;
+
+ ///
+ public override AgentSkillFrontmatter Frontmatter { get; } = new(
+ "unit-converter",
+ "Convert between common units using a multiplication factor. Use when asked to convert miles, kilometers, pounds, or kilograms.");
+
+ ///
+ protected override string 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. Use the convert script, passing the value and factor from the table.
+ 3. Present the result clearly with both units.
+ """;
+
+ ///
+ public override IReadOnlyList? Resources => this._resources ??=
+ [
+ // Dynamic resource with DI: resolves ConversionRateService to build conversion table
+ CreateResource("conversion-table", (IServiceProvider serviceProvider) =>
+ {
+ var rateService = serviceProvider.GetRequiredService();
+ return rateService.GetConversionTable();
+ }),
+ ];
+
+ ///
+ public override IReadOnlyList? Scripts => this._scripts ??=
+ [
+ // Script with DI: resolves ConversionRateService to perform the conversion
+ CreateScript("convert", (double value, double factor, IServiceProvider serviceProvider) =>
+ {
+ var rateService = serviceProvider.GetRequiredService();
+ return rateService.Convert(value, factor);
+ }),
+ ];
+}
+
+///
+/// Provides conversion rates between units.
+/// In a real application this could call an external API, read from a database,
+/// or apply time-varying exchange rates.
+///
+internal sealed class ConversionRateService
+{
+ ///
+ /// Returns a static markdown table of all supported conversions with factors.
+ ///
+ public string GetConversionTable() =>
+ """
+ # 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 |
+ """;
+
+ ///
+ /// Converts a value by the given factor and returns a JSON result.
+ ///
+ public string Convert(double value, double factor)
+ {
+ double result = Math.Round(value * factor, 4);
+ return JsonSerializer.Serialize(new { value, factor, result });
+ }
+}
diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step06_ClassBasedSkillsWithDI/README.md b/dotnet/samples/02-agents/AgentSkills/Agent_Step06_ClassBasedSkillsWithDI/README.md
new file mode 100644
index 00000000000..1406dbb3093
--- /dev/null
+++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step06_ClassBasedSkillsWithDI/README.md
@@ -0,0 +1,58 @@
+# Class-Based Agent Skills with Dependency Injection
+
+This sample demonstrates how to use **Dependency Injection (DI)** with **class-based Agent Skills** (`AgentClassSkill`).
+
+## What It Shows
+
+- Defining a skill as a class that extends `AgentClassSkill`
+- Using `IServiceProvider` in skill resource delegates to resolve services from the DI container
+- Using `IServiceProvider` in skill script delegates to resolve services from the DI container
+- Registering application services in a `ServiceCollection` and passing the built provider to the agent
+
+## How It Works
+
+1. A `ConversionRateService` is registered as a singleton in the DI container
+2. `UnitConverterSkill` extends `AgentClassSkill` and declares its resources and scripts using `CreateResource` and `CreateScript` factory methods
+3. The resource delegate declares `IServiceProvider` as a parameter — the framework injects it automatically
+4. The resource resolves `ConversionRateService` from the provider to build a supported-conversions table dynamically
+5. The script delegate also declares `IServiceProvider` as a parameter to look up conversion factors at runtime
+6. The agent is created with the service provider, which flows through to skill resource and script execution
+
+## How It Differs from Other Samples
+
+| Sample | Skill Type | DI Support |
+|--------|-----------|------------|
+| [Step03](../Agent_Step03_ClassBasedSkills/) | Class-based (`AgentClassSkill`) | No — static resources |
+| [Step05](../Agent_Step05_CodeDefinedSkillsWithDI/) | Code-defined (`AgentInlineSkill`) | Yes — inline delegates |
+| **Step06 (this)** | **Class-based (`AgentClassSkill`)** | **Yes — class delegates** |
+
+## Prerequisites
+
+- .NET 10
+- An Azure OpenAI deployment
+
+## Configuration
+
+Set the following environment variables:
+
+| Variable | Description |
+|---|---|
+| `AZURE_OPENAI_ENDPOINT` | Your Azure OpenAI endpoint URL |
+| `AZURE_OPENAI_DEPLOYMENT_NAME` | Model deployment name (defaults to `gpt-4o-mini`) |
+
+## Running the Sample
+
+```bash
+dotnet run
+```
+
+### Expected Output
+
+```
+Converting units with DI-powered class-based skills
+------------------------------------------------------------
+Agent: Here are your conversions:
+
+1. **26.2 miles → 42.16 km** (a marathon distance)
+2. **75 kg → 165.35 lbs**
+```
diff --git a/dotnet/samples/02-agents/AgentSkills/README.md b/dotnet/samples/02-agents/AgentSkills/README.md
index 60113849976..bcdcf6be819 100644
--- a/dotnet/samples/02-agents/AgentSkills/README.md
+++ b/dotnet/samples/02-agents/AgentSkills/README.md
@@ -6,19 +6,33 @@ Samples demonstrating Agent Skills capabilities. Each sample shows a different w
|--------|-------------|
| [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. |
+| [Agent_Step03_ClassBasedSkills](Agent_Step03_ClassBasedSkills/) | Define skills as C# classes using `AgentClassSkill`. |
+| [Agent_Step04_MixedSkills](Agent_Step04_MixedSkills/) | **(Advanced)** Combine file-based, code-defined, and class-based skills using `AgentSkillsProviderBuilder`. |
+| [Agent_Step05_CodeDefinedSkillsWithDI](Agent_Step05_CodeDefinedSkillsWithDI/) | Use Dependency Injection with code-defined skills (`AgentInlineSkill`). |
+| [Agent_Step06_ClassBasedSkillsWithDI](Agent_Step06_ClassBasedSkillsWithDI/) | Use Dependency Injection with class-based skills (`AgentClassSkill`). |
## Key Concepts
-### File-Based vs Code-Defined Skills
+### Skill Types
-| 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 |
+| Aspect | File-Based | Code-Defined | Class-Based |
+|--------|-----------|--------------|-------------|
+| Definition | `SKILL.md` files on disk | `AgentInlineSkill` instances in C# | Classes extending `AgentClassSkill` |
+| Resources | All files in skill directory (filtered by extension) | `AddResource` (static value or delegate-backed) | `CreateResource` factory methods |
+| Scripts | Supported via script runner delegate | `AddScript` delegates | `CreateScript` factory methods |
+| Discovery | Automatic from directory path | Explicit via constructor | Explicit via constructor |
+| Dynamic content | No (static files only) | Yes (factory delegates) | Yes (factory delegates) |
+| Sharing pattern | Copy skill directory | Inline or shared instances | Package in shared assemblies/NuGet |
+| DI support | No | Yes (via `IServiceProvider` parameter) | Yes (via `IServiceProvider` parameter) |
-For single-source scenarios, use the `AgentSkillsProvider` constructors directly. To combine multiple skill types, use the `AgentSkillsProviderBuilder`.
+### `AgentSkillsProvider` vs `AgentSkillsProviderBuilder`
+For single-source scenarios, use the `AgentSkillsProvider` constructors directly — they accept a skill directory path, a set of skills, or a custom source.
+
+Use `AgentSkillsProviderBuilder` for advanced scenarios where simple constructors are insufficient:
+
+- **Mixed skill types** — combine file-based, code-defined, and class-based skills in one provider
+- **Multiple file script runners** — use different script runners for different file skill directories
+- **Skill filtering** — include or exclude skills using a predicate
+
+See [Agent_Step04_MixedSkills](Agent_Step04_MixedSkills/) for a working example.
diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillScript.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillScript.cs
index ad647d2eb09..1ac44bfac88 100644
--- a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillScript.cs
+++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillScript.cs
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
+using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
@@ -36,6 +37,11 @@ protected AgentSkillScript(string name, string? description = null)
///
public string? Description { get; }
+ ///
+ /// Gets the JSON schema describing the parameters accepted by this script, or if not available.
+ ///
+ public virtual JsonElement? ParametersSchema => null;
+
///
/// Runs the script with the given arguments.
///
diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProvider.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProvider.cs
index 70d79392279..b5598e19d37 100644
--- a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProvider.cs
+++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProvider.cs
@@ -117,26 +117,24 @@ public AgentSkillsProvider(
}
///
- /// Initializes a new instance of the class
- /// with one or more inline (code-defined) skills.
+ /// Initializes a new instance of the class.
/// Duplicate skill names are automatically deduplicated (first occurrence wins).
///
- /// The inline skills to include.
- public AgentSkillsProvider(params AgentInlineSkill[] skills)
- : this(skills as IEnumerable)
+ /// The skills to include.
+ public AgentSkillsProvider(params AgentSkill[] skills)
+ : this(skills as IEnumerable)
{
}
///
- /// Initializes a new instance of the class
- /// with inline (code-defined) skills.
+ /// Initializes a new instance of the class.
/// Duplicate skill names are automatically deduplicated (first occurrence wins).
///
- /// The inline skills to include.
+ /// The skills to include.
/// Optional provider configuration.
/// Optional logger factory.
public AgentSkillsProvider(
- IEnumerable skills,
+ IEnumerable skills,
AgentSkillsProviderOptions? options = null,
ILoggerFactory? loggerFactory = null)
: this(
diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProviderBuilder.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProviderBuilder.cs
index 8e52cc522e0..0da54d04267 100644
--- a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProviderBuilder.cs
+++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProviderBuilder.cs
@@ -11,15 +11,31 @@ namespace Microsoft.Agents.AI;
///
/// Fluent builder for constructing an backed by a composite source.
+/// Intended for advanced scenarios where the simple constructors are insufficient.
///
///
///
-/// Use this builder to combine multiple skill sources into a single provider:
+/// For simple, single-source scenarios, prefer the constructors directly
+/// (e.g., passing a skill directory path or a set of skills). Use this builder when you need one or more
+/// of the following advanced capabilities:
+///
+///
+/// - Mixed skill types — combine file-based, code-defined (),
+/// and class-based () skills in a single provider.
+/// - Multiple file script runners — use different script runners for different
+/// file skill directories via per-source scriptRunner parameters on
+/// / .
+/// - Skill filtering — include or exclude skills using a predicate
+/// via .
+///
+///
+/// Example — combining file-based and code-defined skills:
///
///
/// var provider = new AgentSkillsProviderBuilder()
/// .UseFileSkills("/path/to/skills")
/// .UseSkills(myInlineSkill1, myInlineSkill2)
+/// .UseFileScriptRunner(SubprocessScriptRunner.RunAsync)
/// .Build();
///
///
diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentClassSkill.cs b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentClassSkill.cs
new file mode 100644
index 00000000000..47f12d5ea5f
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentClassSkill.cs
@@ -0,0 +1,95 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.Shared.DiagnosticIds;
+
+namespace Microsoft.Agents.AI;
+
+///
+/// Abstract base class for defining skills as C# classes that bundle all components together.
+///
+///
+///
+/// Inherit from this class to create a self-contained skill definition. Override the abstract
+/// properties to provide name, description, and instructions. Use ,
+/// , and to define
+/// inline resources and scripts.
+///
+///
+///
+///
+/// public class PdfFormatterSkill : AgentClassSkill
+/// {
+/// private IReadOnlyList<AgentSkillResource>? _resources;
+/// private IReadOnlyList<AgentSkillScript>? _scripts;
+///
+/// public override AgentSkillFrontmatter Frontmatter { get; } = new("pdf-formatter", "Format documents as PDF.");
+/// protected override string Instructions => "Use this skill to format documents...";
+///
+/// public override IReadOnlyList<AgentSkillResource>? Resources => this._resources ??=
+/// [
+/// CreateResource("template", "Use this template..."),
+/// ];
+///
+/// public override IReadOnlyList<AgentSkillScript>? Scripts => this._scripts ??=
+/// [
+/// CreateScript("format-pdf", FormatPdf),
+/// ];
+///
+/// private static string FormatPdf(string content) => content;
+/// }
+///
+///
+[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
+public abstract class AgentClassSkill : AgentSkill
+{
+ private string? _content;
+
+ ///
+ /// Gets the raw instructions text for this skill.
+ ///
+ protected abstract string Instructions { get; }
+
+ ///
+ ///
+ /// Returns a synthesized XML document containing name, description, instructions, resources, and scripts.
+ /// The result is cached after the first access. Override to provide custom content.
+ ///
+ public override string Content => this._content ??= AgentInlineSkillContentBuilder.Build(
+ this.Frontmatter.Name,
+ this.Frontmatter.Description,
+ this.Instructions,
+ this.Resources,
+ this.Scripts);
+
+ ///
+ /// Creates a skill resource backed by a static value.
+ ///
+ /// The resource name.
+ /// The static resource value.
+ /// An optional description of the resource.
+ /// A new instance.
+ protected static AgentSkillResource CreateResource(string name, object value, string? description = null)
+ => new AgentInlineSkillResource(name, value, description);
+
+ ///
+ /// Creates a skill resource backed by a delegate that produces a dynamic value.
+ ///
+ /// The resource name.
+ /// A method that produces the resource value when requested.
+ /// An optional description of the resource.
+ /// A new instance.
+ protected static AgentSkillResource CreateResource(string name, Delegate method, string? description = null)
+ => new AgentInlineSkillResource(name, method, description);
+
+ ///
+ /// Creates a skill script backed by a delegate.
+ ///
+ /// The script name.
+ /// A method to execute when the script is invoked.
+ /// An optional description of the script.
+ /// A new instance.
+ protected static AgentSkillScript CreateScript(string name, Delegate method, string? description = null)
+ => new AgentInlineSkillScript(name, method, description);
+}
diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkill.cs b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkill.cs
index d326a47a594..f24d41c3620 100644
--- a/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkill.cs
+++ b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkill.cs
@@ -3,8 +3,6 @@
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;
@@ -27,8 +25,8 @@ namespace Microsoft.Agents.AI;
public sealed class AgentInlineSkill : AgentSkill
{
private readonly string _instructions;
- private List? _resources;
- private List? _scripts;
+ private List? _resources;
+ private List? _scripts;
private string? _cachedContent;
///
@@ -77,7 +75,7 @@ public AgentInlineSkill(
public override AgentSkillFrontmatter Frontmatter { get; }
///
- public override string Content => this._cachedContent ??= this.BuildContent();
+ public override string Content => this._cachedContent ??= AgentInlineSkillContentBuilder.Build(this.Frontmatter.Name, this.Frontmatter.Description, this._instructions, this._resources, this._scripts);
///
public override IReadOnlyList? Resources => this._resources;
@@ -125,91 +123,4 @@ public AgentInlineSkill AddScript(string name, Delegate method, string? descript
(this._scripts ??= []).Add(new AgentInlineSkillScript(name, method, description));
return this;
}
-
- private string BuildContent()
- {
- var sb = new StringBuilder();
-
- sb.Append($"{EscapeXmlString(this.Frontmatter.Name)}\n")
- .Append($"{EscapeXmlString(this.Frontmatter.Description)}\n\n")
- .Append("\n")
- .Append(EscapeXmlString(this._instructions))
- .Append("\n");
-
- if (this.Resources is { Count: > 0 })
- {
- sb.Append("\n\n\n");
- foreach (var resource in this.Resources)
- {
- if (resource.Description is not null)
- {
- sb.Append($" \n");
- }
- else
- {
- sb.Append($" \n");
- }
- }
-
- sb.Append("");
- }
-
- if (this.Scripts is { Count: > 0 })
- {
- sb.Append("\n\n\n");
- foreach (var script in this.Scripts)
- {
- JsonElement? parametersSchema = ((AgentInlineSkillScript)script).ParametersSchema;
-
- if (script.Description is null && parametersSchema is null)
- {
- sb.Append($" \n");
- }
- }
-
- sb.Append("");
- }
-
- return sb.ToString();
- }
-
- ///
- /// Escapes XML special characters: always escapes &, <, >,
- /// ", and '. When is ,
- /// quotes are left unescaped to preserve readability of embedded content such as JSON.
- ///
- /// The string to escape.
- ///
- /// When , leaves " and ' unescaped for use in XML element content (e.g., JSON).
- /// When (default), escapes all XML special characters including quotes.
- ///
- private static string EscapeXmlString(string value, bool preserveQuotes = false)
- {
- var result = value
- .Replace("&", "&")
- .Replace("<", "<")
- .Replace(">", ">");
-
- if (!preserveQuotes)
- {
- result = result
- .Replace("\"", """)
- .Replace("'", "'");
- }
-
- return result;
- }
}
diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillContentBuilder.cs b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillContentBuilder.cs
new file mode 100644
index 00000000000..f90d67dd1d8
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillContentBuilder.cs
@@ -0,0 +1,118 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using System.Text;
+using Microsoft.Shared.Diagnostics;
+
+namespace Microsoft.Agents.AI;
+
+///
+/// Internal helper that builds XML-structured content strings for code-defined and class-based skills.
+///
+internal static class AgentInlineSkillContentBuilder
+{
+ ///
+ /// Builds the complete skill content containing name, description, instructions, resources, and scripts.
+ ///
+ /// The skill name.
+ /// The skill description.
+ /// The raw instructions text.
+ /// Optional resources associated with the skill.
+ /// Optional scripts associated with the skill.
+ /// An XML-structured content string.
+ public static string Build(
+ string name,
+ string description,
+ string instructions,
+ IReadOnlyList? resources,
+ IReadOnlyList? scripts)
+ {
+ _ = Throw.IfNullOrWhitespace(name);
+ _ = Throw.IfNullOrWhitespace(description);
+ _ = Throw.IfNullOrWhitespace(instructions);
+
+ var sb = new StringBuilder();
+
+ sb.Append($"{EscapeXmlString(name)}\n")
+ .Append($"{EscapeXmlString(description)}\n\n")
+ .Append("\n")
+ .Append(EscapeXmlString(instructions))
+ .Append("\n");
+
+ if (resources is { Count: > 0 })
+ {
+ sb.Append("\n\n\n");
+ foreach (var resource in resources)
+ {
+ if (resource.Description is not null)
+ {
+ sb.Append($" \n");
+ }
+ else
+ {
+ sb.Append($" \n");
+ }
+ }
+
+ sb.Append("");
+ }
+
+ if (scripts is { Count: > 0 })
+ {
+ sb.Append("\n\n\n");
+ foreach (var script in scripts)
+ {
+ var parametersSchema = script.ParametersSchema;
+
+ if (script.Description is null && parametersSchema is null)
+ {
+ sb.Append($" \n");
+ }
+ }
+
+ sb.Append("");
+ }
+
+ return sb.ToString();
+ }
+
+ ///
+ /// Escapes XML special characters: always escapes &, <, >,
+ /// ", and '. When is ,
+ /// quotes are left unescaped to preserve readability of embedded content such as JSON.
+ ///
+ /// The string to escape.
+ ///
+ /// When , leaves " and ' unescaped for use in XML element content (e.g., JSON).
+ /// When (default), escapes all XML special characters including quotes.
+ ///
+ private static string EscapeXmlString(string value, bool preserveQuotes = false)
+ {
+ var result = value
+ .Replace("&", "&")
+ .Replace("<", "<")
+ .Replace(">", ">");
+
+ if (!preserveQuotes)
+ {
+ result = result
+ .Replace("\"", """)
+ .Replace("'", "'");
+ }
+
+ return result;
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillScript.cs b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillScript.cs
index acb4f4780bb..e851c6f9fcb 100644
--- a/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillScript.cs
+++ b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillScript.cs
@@ -36,7 +36,7 @@ public AgentInlineSkillScript(string name, Delegate method, string? description
///
/// Gets the JSON schema describing the parameters accepted by this script, or if not available.
///
- public JsonElement? ParametersSchema => this._function.JsonSchema;
+ public override JsonElement? ParametersSchema => this._function.JsonSchema;
///
public override async Task
public IEnumerable? AllowedScriptExtensions { get; set; }
+
+ ///
+ /// Gets or sets the sub-folder names to scan for script files, relative to the skill directory.
+ /// Use "." to include files directly at the skill root.
+ /// When , defaults to scripts (per the
+ /// Agent Skills specification).
+ /// When set, replaces the defaults entirely.
+ ///
+ public IEnumerable? ScriptFolders { get; set; }
+
+ ///
+ /// Gets or sets the sub-folder names to scan for resource files, relative to the skill directory.
+ /// Use "." to include files directly at the skill root.
+ /// When , defaults to references and assets (per the
+ /// Agent Skills specification).
+ /// When set, replaces the defaults entirely.
+ ///
+ public IEnumerable? ResourceFolders { get; set; }
}
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillsSourceScriptTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillsSourceScriptTests.cs
index ef8f7780a6b..22dc5d40e6c 100644
--- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillsSourceScriptTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillsSourceScriptTests.cs
@@ -114,9 +114,10 @@ public async Task GetSkillsAsync_NoScriptFiles_ReturnsEmptyScriptsAsync()
}
[Fact]
- public async Task GetSkillsAsync_ScriptsOutsideScriptsDir_AreAlsoDiscoveredAsync()
+ public async Task GetSkillsAsync_ScriptsOutsideScriptsDir_AreNotDiscoveredAsync()
{
- // Arrange — scripts at any depth in the skill directory are discovered
+ // Arrange — scripts outside configured folders are not discovered; only files directly
+ // inside the configured folder are picked up (no subdirectory recursion)
string skillDir = CreateSkillDir(this._testRoot, "root-scripts", "Root scripts skill", "Body.");
CreateFile(skillDir, "convert.py", "print('root')");
CreateFile(skillDir, "tools/helper.sh", "echo 'helper'");
@@ -125,12 +126,9 @@ public async Task GetSkillsAsync_ScriptsOutsideScriptsDir_AreAlsoDiscoveredAsync
// Act
var skills = await source.GetSkillsAsync(CancellationToken.None);
- // Assert
+ // Assert — neither file is in the default scripts/ folder, so no scripts are discovered
Assert.Single(skills);
- var scriptNames = skills[0].Scripts!.Select(s => s.Name).OrderBy(n => n, StringComparer.Ordinal).ToList();
- Assert.Equal(2, scriptNames.Count);
- Assert.Contains("convert.py", scriptNames);
- Assert.Contains("tools/helper.sh", scriptNames);
+ Assert.Empty(skills[0].Scripts!);
}
[Fact]
@@ -230,6 +228,46 @@ public async Task GetSkillsAsync_ExecutorReceivesArgumentsAsync()
Assert.Equal(1.60934, capturedArgs["factor"]);
}
+ [Fact]
+ public async Task GetSkillsAsync_ScriptFoldersWithNestedPath_DiscoversScriptsAsync()
+ {
+ // Arrange — ScriptFolders configured with a multi-segment relative path (f1/f2/f3)
+ string skillDir = CreateSkillDir(this._testRoot, "nested-script-skill", "Nested script folder", "Body.");
+ CreateFile(skillDir, "f1/f2/f3/run.py", "print('nested')");
+ var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
+ new AgentFileSkillsSourceOptions { ScriptFolders = ["f1/f2/f3"] });
+
+ // Act
+ var skills = await source.GetSkillsAsync(CancellationToken.None);
+
+ // Assert — script file inside the deeply nested folder is discovered
+ Assert.Single(skills);
+ Assert.Single(skills[0].Scripts!);
+ Assert.Equal("f1/f2/f3/run.py", skills[0].Scripts![0].Name);
+ }
+
+ [Theory]
+ [InlineData("./scripts")]
+ [InlineData("./scripts/f1")]
+ public async Task GetSkillsAsync_ScriptFolderWithDotSlashPrefix_DiscoversScriptsAsync(string folder)
+ {
+ // Arrange — "./scripts" and "./scripts/f1" are equivalent to "scripts" and "scripts/f1";
+ // the leading "./" is transparently normalized by Path.GetFullPath during file enumeration.
+ string folderWithoutDotSlash = folder.Substring(2); // strip "./"
+ string skillDir = CreateSkillDir(this._testRoot, "dotslash-script-skill", "Dot-slash prefix", "Body.");
+ CreateFile(skillDir, $"{folderWithoutDotSlash}/run.py", "print('dotslash')");
+ var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
+ new AgentFileSkillsSourceOptions { ScriptFolders = [folder] });
+
+ // Act
+ var skills = await source.GetSkillsAsync(CancellationToken.None);
+
+ // Assert — script is discovered with a name identical to using the folder without "./"
+ Assert.Single(skills);
+ Assert.Single(skills[0].Scripts!);
+ Assert.Equal($"{folderWithoutDotSlash}/run.py", skills[0].Scripts![0].Name);
+ }
+
private static string CreateSkillDir(string root, string name, string description, string body)
{
string skillDir = Path.Combine(root, name);
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs
index e9dc2e0358c..0f926450f1b 100644
--- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs
@@ -199,12 +199,14 @@ public async Task GetSkillsAsync_NameMismatchesDirectory_ExcludesSkillAsync()
[Fact]
public async Task GetSkillsAsync_FilesWithMatchingExtensions_DiscoveredAsResourcesAsync()
{
- // Arrange — create resource files in the skill directory
+ // Arrange — create resource files in spec-defined sub-folders
string skillDir = Path.Combine(this._testRoot, "resource-skill");
- string refsDir = Path.Combine(skillDir, "refs");
+ string refsDir = Path.Combine(skillDir, "references");
+ string assetsDir = Path.Combine(skillDir, "assets");
Directory.CreateDirectory(refsDir);
+ Directory.CreateDirectory(assetsDir);
File.WriteAllText(Path.Combine(refsDir, "FAQ.md"), "FAQ content");
- File.WriteAllText(Path.Combine(refsDir, "data.json"), "{}");
+ File.WriteAllText(Path.Combine(assetsDir, "data.json"), "{}");
File.WriteAllText(
Path.Combine(skillDir, "SKILL.md"),
"---\nname: resource-skill\ndescription: Has resources\n---\nSee docs for details.");
@@ -217,18 +219,19 @@ public async Task GetSkillsAsync_FilesWithMatchingExtensions_DiscoveredAsResourc
Assert.Single(skills);
var skill = skills[0];
Assert.Equal(2, skill.Resources!.Count);
- Assert.Contains(skill.Resources!, r => r.Name.Equals("refs/FAQ.md", StringComparison.OrdinalIgnoreCase));
- Assert.Contains(skill.Resources!, r => r.Name.Equals("refs/data.json", StringComparison.OrdinalIgnoreCase));
+ 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));
}
[Fact]
public async Task GetSkillsAsync_FilesWithNonMatchingExtensions_NotDiscoveredAsync()
{
- // Arrange — create a file with an extension not in the default list
+ // Arrange — create a file with an extension not in the default list inside a spec folder
string skillDir = Path.Combine(this._testRoot, "ext-skill");
- Directory.CreateDirectory(skillDir);
- File.WriteAllText(Path.Combine(skillDir, "image.png"), "fake image");
- File.WriteAllText(Path.Combine(skillDir, "data.json"), "{}");
+ string refsDir = Path.Combine(skillDir, "references");
+ Directory.CreateDirectory(refsDir);
+ File.WriteAllText(Path.Combine(refsDir, "image.png"), "fake image");
+ File.WriteAllText(Path.Combine(refsDir, "data.json"), "{}");
File.WriteAllText(
Path.Combine(skillDir, "SKILL.md"),
"---\nname: ext-skill\ndescription: Extension test\n---\nBody.");
@@ -241,7 +244,7 @@ public async Task GetSkillsAsync_FilesWithNonMatchingExtensions_NotDiscoveredAsy
Assert.Single(skills);
var skill = skills[0];
Assert.Single(skill.Resources!);
- Assert.Equal("data.json", skill.Resources![0].Name);
+ Assert.Equal("references/data.json", skill.Resources![0].Name);
}
[Fact]
@@ -249,8 +252,9 @@ public async Task GetSkillsAsync_SkillMdFile_NotIncludedAsResourceAsync()
{
// Arrange — the SKILL.md file itself should not be in the resource list
string skillDir = Path.Combine(this._testRoot, "selfref-skill");
- Directory.CreateDirectory(skillDir);
- File.WriteAllText(Path.Combine(skillDir, "notes.md"), "notes");
+ string refsDir = Path.Combine(skillDir, "references");
+ Directory.CreateDirectory(refsDir);
+ File.WriteAllText(Path.Combine(refsDir, "notes.md"), "notes");
File.WriteAllText(
Path.Combine(skillDir, "SKILL.md"),
"---\nname: selfref-skill\ndescription: Self ref test\n---\nBody.");
@@ -263,15 +267,18 @@ public async Task GetSkillsAsync_SkillMdFile_NotIncludedAsResourceAsync()
Assert.Single(skills);
var skill = skills[0];
Assert.Single(skill.Resources!);
- Assert.Equal("notes.md", skill.Resources![0].Name);
+ Assert.Equal("references/notes.md", skill.Resources![0].Name);
}
[Fact]
public async Task GetSkillsAsync_NestedResourceFiles_DiscoveredAsync()
{
- // Arrange — resource files in nested subdirectories
+ // Arrange — resource files directly in references/ are discovered; subdirectories are not scanned
string skillDir = Path.Combine(this._testRoot, "nested-res-skill");
- string deepDir = Path.Combine(skillDir, "level1", "level2");
+ string refsDir = Path.Combine(skillDir, "references");
+ Directory.CreateDirectory(refsDir);
+ File.WriteAllText(Path.Combine(refsDir, "top.md"), "top content");
+ string deepDir = Path.Combine(refsDir, "level1", "level2");
Directory.CreateDirectory(deepDir);
File.WriteAllText(Path.Combine(deepDir, "deep.md"), "deep content");
File.WriteAllText(
@@ -282,21 +289,23 @@ public async Task GetSkillsAsync_NestedResourceFiles_DiscoveredAsync()
// Act
var skills = await source.GetSkillsAsync();
- // Assert
+ // 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("level1/level2/deep.md", StringComparison.OrdinalIgnoreCase));
+ 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));
}
[Fact]
public async Task GetSkillsAsync_CustomResourceExtensions_UsedForDiscoveryAsync()
{
- // Arrange — use a source with custom extensions
+ // Arrange — use a source with custom extensions; files placed in spec folder
string skillDir = Path.Combine(this._testRoot, "custom-ext-skill");
- Directory.CreateDirectory(skillDir);
- File.WriteAllText(Path.Combine(skillDir, "data.custom"), "custom data");
- File.WriteAllText(Path.Combine(skillDir, "data.json"), "{}");
+ string refsDir = Path.Combine(skillDir, "references");
+ Directory.CreateDirectory(refsDir);
+ File.WriteAllText(Path.Combine(refsDir, "data.custom"), "custom data");
+ File.WriteAllText(Path.Combine(refsDir, "data.json"), "{}");
File.WriteAllText(
Path.Combine(skillDir, "SKILL.md"),
"---\nname: custom-ext-skill\ndescription: Custom extensions\n---\nBody.");
@@ -309,7 +318,7 @@ public async Task GetSkillsAsync_CustomResourceExtensions_UsedForDiscoveryAsync(
Assert.Single(skills);
var skill = skills[0];
Assert.Single(skill.Resources!);
- Assert.Equal("data.custom", skill.Resources![0].Name);
+ Assert.Equal("references/data.custom", skill.Resources![0].Name);
}
[Theory]
@@ -327,7 +336,9 @@ public async Task Constructor_NullExtensions_UsesDefaultsAsync()
{
// Arrange & Act
string skillDir = this.CreateSkillDirectory("null-ext", "A skill", "Body.");
- File.WriteAllText(Path.Combine(skillDir, "notes.md"), "notes");
+ string refsDir = Path.Combine(skillDir, "references");
+ Directory.CreateDirectory(refsDir);
+ File.WriteAllText(Path.Combine(refsDir, "notes.md"), "notes");
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
// Assert — default extensions include .md
@@ -351,9 +362,9 @@ public void Constructor_MixOfValidAndInvalidExtensions_ThrowsArgumentException()
}
[Fact]
- public async Task GetSkillsAsync_ResourceInSkillRoot_DiscoveredAsync()
+ public async Task GetSkillsAsync_ResourceInSkillRoot_NotDiscoveredByDefaultAsync()
{
- // Arrange — resource file directly in the skill directory (not in a subdirectory)
+ // Arrange — resource files directly in the skill directory (not in a spec sub-folder)
string skillDir = Path.Combine(this._testRoot, "root-resource-skill");
Directory.CreateDirectory(skillDir);
File.WriteAllText(Path.Combine(skillDir, "guide.md"), "guide content");
@@ -366,7 +377,29 @@ public async Task GetSkillsAsync_ResourceInSkillRoot_DiscoveredAsync()
// Act
var skills = await source.GetSkillsAsync();
- // Assert — both root-level resource files should be discovered
+ // Assert — root-level files are NOT discovered unless "." is in ResourceFolders
+ Assert.Single(skills);
+ Assert.Empty(skills[0].Resources!);
+ }
+
+ [Fact]
+ public async Task GetSkillsAsync_ResourceInSkillRoot_DiscoveredWhenRootFolderConfiguredAsync()
+ {
+ // Arrange — "." in ResourceFolders opts into root-level resource discovery
+ string skillDir = Path.Combine(this._testRoot, "root-opt-in-skill");
+ Directory.CreateDirectory(skillDir);
+ File.WriteAllText(Path.Combine(skillDir, "guide.md"), "guide content");
+ File.WriteAllText(Path.Combine(skillDir, "config.json"), "{}");
+ File.WriteAllText(
+ Path.Combine(skillDir, "SKILL.md"),
+ "---\nname: root-opt-in-skill\ndescription: Root opt-in\n---\nBody.");
+ var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
+ new AgentFileSkillsSourceOptions { ResourceFolders = ["references", "assets", "."] });
+
+ // Act
+ var skills = await source.GetSkillsAsync();
+
+ // 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);
@@ -374,6 +407,54 @@ public async Task GetSkillsAsync_ResourceInSkillRoot_DiscoveredAsync()
Assert.Contains(skill.Resources!, r => r.Name.Equals("config.json", StringComparison.OrdinalIgnoreCase));
}
+ [Fact]
+ public async Task GetSkillsAsync_ResourceInNonSpecFolder_NotDiscoveredByDefaultAsync()
+ {
+ // Arrange — resource in a non-spec folder (neither references/ nor assets/)
+ string skillDir = Path.Combine(this._testRoot, "non-spec-skill");
+ string customDir = Path.Combine(skillDir, "docs");
+ Directory.CreateDirectory(customDir);
+ File.WriteAllText(Path.Combine(customDir, "readme.md"), "docs content");
+ File.WriteAllText(
+ Path.Combine(skillDir, "SKILL.md"),
+ "---\nname: non-spec-skill\ndescription: Non-spec folder\n---\nBody.");
+ var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
+
+ // Act
+ var skills = await source.GetSkillsAsync();
+
+ // Assert — non-spec folders are not scanned by default
+ Assert.Single(skills);
+ Assert.Empty(skills[0].Resources!);
+ }
+
+ [Fact]
+ public async Task GetSkillsAsync_CustomResourceFolders_ReplacesDefaultsAsync()
+ {
+ // Arrange — custom ResourceFolders replaces the spec defaults
+ string skillDir = Path.Combine(this._testRoot, "custom-folder-skill");
+ string customDir = Path.Combine(skillDir, "docs");
+ string refsDir = Path.Combine(skillDir, "references");
+ Directory.CreateDirectory(customDir);
+ Directory.CreateDirectory(refsDir);
+ File.WriteAllText(Path.Combine(customDir, "readme.md"), "docs content");
+ File.WriteAllText(Path.Combine(refsDir, "ref.md"), "ref content");
+ File.WriteAllText(
+ Path.Combine(skillDir, "SKILL.md"),
+ "---\nname: custom-folder-skill\ndescription: Custom folder\n---\nBody.");
+ var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
+ new AgentFileSkillsSourceOptions { ResourceFolders = ["docs"] });
+
+ // Act
+ var skills = await source.GetSkillsAsync();
+
+ // 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);
+ }
+
[Fact]
public async Task GetSkillsAsync_NoResourceFiles_ReturnsEmptyResourcesAsync()
{
@@ -437,14 +518,14 @@ public async Task GetSkillsAsync_NestedSkillDirectory_DiscoveredWithinDepthLimit
[Fact]
public async Task ReadSkillResourceAsync_ValidResource_ReturnsContentAsync()
{
- // Arrange — create a skill with a resource file discovered from the directory
+ // Arrange — create a skill with a resource file discovered from the references folder
string skillDir = this.CreateSkillDirectory("read-skill", "A skill", "See docs for details.");
- string refsDir = Path.Combine(skillDir, "refs");
+ string refsDir = Path.Combine(skillDir, "references");
Directory.CreateDirectory(refsDir);
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 == "refs/doc.md");
+ var resource = skills[0].Resources!.First(r => r.Name == "references/doc.md");
// Act
var content = await resource.ReadAsync();
@@ -495,16 +576,18 @@ public async Task GetSkillsAsync_DescriptionExceedsMaxLength_ExcludesSkillAsync(
[Fact]
public async Task GetSkillsAsync_SymlinkInPath_SkipsSymlinkedResourcesAsync()
{
- // Arrange — a "refs" subdirectory is a symlink pointing outside the skill directory
+ // Arrange — references/ is a symlink pointing outside the skill directory;
+ // a legitimate file lives in assets/ and should still be discovered.
string skillDir = Path.Combine(this._testRoot, "symlink-escape-skill");
- Directory.CreateDirectory(skillDir);
- File.WriteAllText(Path.Combine(skillDir, "legit.md"), "legit content");
+ string assetsDir = Path.Combine(skillDir, "assets");
+ Directory.CreateDirectory(assetsDir);
+ File.WriteAllText(Path.Combine(assetsDir, "legit.md"), "legit content");
string outsideDir = Path.Combine(this._testRoot, "outside");
Directory.CreateDirectory(outsideDir);
File.WriteAllText(Path.Combine(outsideDir, "secret.md"), "secret content");
- string refsLink = Path.Combine(skillDir, "refs");
+ string refsLink = Path.Combine(skillDir, "references");
try
{
Directory.CreateSymbolicLink(refsLink, outsideDir);
@@ -523,11 +606,11 @@ public async Task GetSkillsAsync_SymlinkInPath_SkipsSymlinkedResourcesAsync()
// Act
var skills = await source.GetSkillsAsync();
- // Assert — skill should still load, but symlinked resources should be excluded
+ // 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("legit.md", skill.Resources![0].Name);
+ Assert.Equal("assets/legit.md", skill.Resources![0].Name);
}
#endif
@@ -693,6 +776,99 @@ public async Task GetSkillsAsync_NoOptionalFields_DefaultsToNullAsync()
Assert.Null(fm.Metadata);
}
+ [Theory]
+ [InlineData("..")]
+ [InlineData("../escape")]
+ [InlineData("sub/../escape")]
+ [InlineData("/absolute")]
+ [InlineData("\\absolute")]
+ public void Constructor_InvalidFolderName_SkipsInvalidFolders(string badFolder)
+ {
+ // Arrange & Act — invalid folders are skipped with a warning rather than throwing
+ var source1 = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ScriptFolders = [badFolder] });
+ var source2 = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ResourceFolders = [badFolder] });
+
+ // Assert
+ Assert.NotNull(source1);
+ Assert.NotNull(source2);
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ public void Constructor_NullOrWhitespaceFolderName_ThrowsArgumentException(string? badFolder)
+ {
+ // Arrange & Act & Assert — null/whitespace is a contract violation, not a config error
+ Assert.Throws(() => new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ScriptFolders = [badFolder!] }));
+ Assert.Throws(() => new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ResourceFolders = [badFolder!] }));
+ }
+
+ [Theory]
+ [InlineData("scripts")]
+ [InlineData("my-scripts")]
+ [InlineData("sub/folder")]
+ [InlineData(".")]
+ [InlineData("./scripts")]
+ [InlineData("./scripts/f1")]
+ public void Constructor_ValidFolderName_DoesNotThrow(string validFolder)
+ {
+ // Arrange & Act & Assert
+ var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ScriptFolders = [validFolder] });
+ Assert.NotNull(source);
+ }
+
+ [Theory]
+ [InlineData("./references")]
+ [InlineData("./assets/docs")]
+ public async Task GetSkillsAsync_ResourceFolderWithDotSlashPrefix_DiscoversResourcesAsync(string folder)
+ {
+ // Arrange — "./references" and "./assets/docs" are equivalent to "references" and "assets/docs";
+ // the leading "./" is transparently normalized by Path.GetFullPath during file enumeration.
+ string folderWithoutDotSlash = folder.Substring(2); // strip "./"
+ string skillDir = Path.Combine(this._testRoot, "dotslash-res-skill");
+ string targetDir = Path.Combine(skillDir, folderWithoutDotSlash.Replace('/', Path.DirectorySeparatorChar));
+ Directory.CreateDirectory(targetDir);
+ File.WriteAllText(Path.Combine(targetDir, "data.json"), "{}");
+ File.WriteAllText(
+ Path.Combine(skillDir, "SKILL.md"),
+ "---\nname: dotslash-res-skill\ndescription: Dot-slash prefix\n---\nBody.");
+ var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
+ new AgentFileSkillsSourceOptions { ResourceFolders = [folder] });
+
+ // Act
+ var skills = await source.GetSkillsAsync();
+
+ // Assert — the resource is discovered with a name identical to using the folder without "./"
+ Assert.Single(skills);
+ Assert.Single(skills[0].Resources!);
+ Assert.Equal($"{folderWithoutDotSlash}/data.json", skills[0].Resources![0].Name);
+ }
+
+ [Fact]
+ public async Task GetSkillsAsync_ResourceFoldersWithNestedPath_DiscoversResourcesAsync()
+ {
+ // Arrange — ResourceFolders configured with a multi-segment relative path (f1/f2/f3)
+ string skillDir = Path.Combine(this._testRoot, "nested-folder-skill");
+ string nestedDir = Path.Combine(skillDir, "f1", "f2", "f3");
+ Directory.CreateDirectory(nestedDir);
+ File.WriteAllText(Path.Combine(nestedDir, "data.json"), "{}");
+ File.WriteAllText(
+ Path.Combine(skillDir, "SKILL.md"),
+ "---\nname: nested-folder-skill\ndescription: Nested folder\n---\nBody.");
+ var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
+ new AgentFileSkillsSourceOptions { ResourceFolders = ["f1/f2/f3"] });
+
+ // Act
+ var skills = await source.GetSkillsAsync();
+
+ // Assert — resource file inside the deeply nested folder is discovered
+ Assert.Single(skills);
+ var skill = skills[0];
+ Assert.Single(skill.Resources!);
+ Assert.Equal("f1/f2/f3/data.json", skill.Resources![0].Name);
+ }
+
private string CreateSkillDirectory(string name, string description, string body)
{
string skillDir = Path.Combine(this._testRoot, name);
From caceaffe2a0d821255fdb34c88f6252e8afb4245 Mon Sep 17 00:00:00 2001
From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com>
Date: Fri, 3 Apr 2026 12:35:21 +0000
Subject: [PATCH 06/10] Remove Step05 and Step06 DI skill samples
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
...gent_Step05_CodeDefinedSkillsWithDI.csproj | 22 ---
.../Program.cs | 116 ---------------
.../README.md | 38 -----
...Agent_Step06_ClassBasedSkillsWithDI.csproj | 22 ---
.../Program.cs | 140 ------------------
.../README.md | 58 --------
6 files changed, 396 deletions(-)
delete mode 100644 dotnet/samples/02-agents/AgentSkills/Agent_Step05_CodeDefinedSkillsWithDI/Agent_Step05_CodeDefinedSkillsWithDI.csproj
delete mode 100644 dotnet/samples/02-agents/AgentSkills/Agent_Step05_CodeDefinedSkillsWithDI/Program.cs
delete mode 100644 dotnet/samples/02-agents/AgentSkills/Agent_Step05_CodeDefinedSkillsWithDI/README.md
delete mode 100644 dotnet/samples/02-agents/AgentSkills/Agent_Step06_ClassBasedSkillsWithDI/Agent_Step06_ClassBasedSkillsWithDI.csproj
delete mode 100644 dotnet/samples/02-agents/AgentSkills/Agent_Step06_ClassBasedSkillsWithDI/Program.cs
delete mode 100644 dotnet/samples/02-agents/AgentSkills/Agent_Step06_ClassBasedSkillsWithDI/README.md
diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step05_CodeDefinedSkillsWithDI/Agent_Step05_CodeDefinedSkillsWithDI.csproj b/dotnet/samples/02-agents/AgentSkills/Agent_Step05_CodeDefinedSkillsWithDI/Agent_Step05_CodeDefinedSkillsWithDI.csproj
deleted file mode 100644
index 959fa29167a..00000000000
--- a/dotnet/samples/02-agents/AgentSkills/Agent_Step05_CodeDefinedSkillsWithDI/Agent_Step05_CodeDefinedSkillsWithDI.csproj
+++ /dev/null
@@ -1,22 +0,0 @@
-
-
-
- Exe
- net10.0
-
- enable
- enable
- $(NoWarn);MAAI001;CA1812
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step05_CodeDefinedSkillsWithDI/Program.cs b/dotnet/samples/02-agents/AgentSkills/Agent_Step05_CodeDefinedSkillsWithDI/Program.cs
deleted file mode 100644
index 9424b1acaae..00000000000
--- a/dotnet/samples/02-agents/AgentSkills/Agent_Step05_CodeDefinedSkillsWithDI/Program.cs
+++ /dev/null
@@ -1,116 +0,0 @@
-// Copyright (c) Microsoft. All rights reserved.
-
-// This sample demonstrates how to use Dependency Injection (DI) with Agent Skills.
-// Skill script and resource functions can resolve services from the DI container via
-// IServiceProvider, enabling clean separation of concerns and testability.
-//
-// The sample registers a ConversionRateService in the DI container. A code-defined skill
-// resource resolves this service to list supported conversions dynamically, and a skill
-// script resolves it to look up live conversion rates at execution time.
-
-using System.Text.Json;
-using Azure.AI.OpenAI;
-using Azure.Identity;
-using Microsoft.Agents.AI;
-using Microsoft.Extensions.DependencyInjection;
-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 ---
-// The skill uses DI to resolve ConversionRateService in both its resource and script functions.
-var unitConverterSkill = new AgentInlineSkill(
- name: "unit-converter",
- description: "Convert between common units. 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.
- """)
- // Dynamic resource with DI: resolves ConversionRateService to build conversion table
- .AddResource("conversion-table", (IServiceProvider serviceProvider) =>
- {
- var rateService = serviceProvider.GetRequiredService();
- return rateService.GetConversionTable();
- })
- // Script with DI: resolves ConversionRateService to perform the conversion
- .AddScript("convert", (double value, double factor, IServiceProvider serviceProvider) =>
- {
- var rateService = serviceProvider.GetRequiredService();
- return rateService.Convert(value, factor);
- });
-
-// --- Skills Provider ---
-var skillsProvider = new AgentSkillsProvider(unitConverterSkill);
-
-// --- DI Container ---
-// Register application services that skill scripts can resolve at execution time.
-ServiceCollection services = new();
-services.AddSingleton();
-
-// --- Agent Setup ---
-AIAgent agent = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential())
- .GetResponsesClient()
- .AsAIAgent(
- options: new ChatClientAgentOptions
- {
- Name = "UnitConverterAgent",
- ChatOptions = new()
- {
- Instructions = "You are a helpful assistant that can convert units.",
- },
- AIContextProviders = [skillsProvider],
- },
- model: deploymentName,
- services: services.BuildServiceProvider());
-
-// --- Example: Unit conversion ---
-Console.WriteLine("Converting units with DI-powered 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}");
-
-// ---------------------------------------------------------------------------
-// Services
-// ---------------------------------------------------------------------------
-
-///
-/// Provides conversion rates between units.
-/// In a real application this could call an external API, read from a database,
-/// or apply time-varying exchange rates.
-///
-internal sealed class ConversionRateService
-{
- ///
- /// Returns a static markdown table of all supported conversions with factors.
- ///
- public string GetConversionTable() =>
- """
- # 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 |
- """;
-
- ///
- /// Converts a value by the given factor and returns a JSON result.
- ///
- public string Convert(double value, double factor)
- {
- double result = Math.Round(value * factor, 4);
- return JsonSerializer.Serialize(new { value, factor, result });
- }
-}
diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step05_CodeDefinedSkillsWithDI/README.md b/dotnet/samples/02-agents/AgentSkills/Agent_Step05_CodeDefinedSkillsWithDI/README.md
deleted file mode 100644
index aa4281ca000..00000000000
--- a/dotnet/samples/02-agents/AgentSkills/Agent_Step05_CodeDefinedSkillsWithDI/README.md
+++ /dev/null
@@ -1,38 +0,0 @@
-# Agent Skills with Dependency Injection
-
-This sample demonstrates how to use **Dependency Injection (DI)** with Agent Skills resources and script functions.
-
-## What It Shows
-
-- Registering application services in a `ServiceCollection`
-- Defining a code-defined skill resource that resolves services from `IServiceProvider`
-- Defining a code-defined skill script that resolves services from `IServiceProvider`
-- Passing the built `IServiceProvider` to the agent so skills can access DI services at execution time
-
-## How It Works
-
-1. A `ConversionRateService` is registered as a singleton in the DI container
-2. A code-defined skill resource declares `IServiceProvider` as a parameter — the framework injects it automatically
-3. The resource resolves `ConversionRateService` from the provider to build a supported-conversions table dynamically
-4. A code-defined skill script also declares `IServiceProvider` as a parameter to look up conversion factors at runtime
-5. The agent is created with the service provider, which flows through to skill resource and script execution
-
-## Prerequisites
-
-- .NET 10
-- An Azure OpenAI deployment
-
-## Configuration
-
-Set the following environment variables:
-
-| Variable | Description |
-|---|---|
-| `AZURE_OPENAI_ENDPOINT` | Your Azure OpenAI endpoint URL |
-| `AZURE_OPENAI_DEPLOYMENT_NAME` | Model deployment name (defaults to `gpt-4o-mini`) |
-
-## Running the Sample
-
-```bash
-dotnet run
-```
diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step06_ClassBasedSkillsWithDI/Agent_Step06_ClassBasedSkillsWithDI.csproj b/dotnet/samples/02-agents/AgentSkills/Agent_Step06_ClassBasedSkillsWithDI/Agent_Step06_ClassBasedSkillsWithDI.csproj
deleted file mode 100644
index 959fa29167a..00000000000
--- a/dotnet/samples/02-agents/AgentSkills/Agent_Step06_ClassBasedSkillsWithDI/Agent_Step06_ClassBasedSkillsWithDI.csproj
+++ /dev/null
@@ -1,22 +0,0 @@
-
-
-
- Exe
- net10.0
-
- enable
- enable
- $(NoWarn);MAAI001;CA1812
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step06_ClassBasedSkillsWithDI/Program.cs b/dotnet/samples/02-agents/AgentSkills/Agent_Step06_ClassBasedSkillsWithDI/Program.cs
deleted file mode 100644
index 08044ddfc7a..00000000000
--- a/dotnet/samples/02-agents/AgentSkills/Agent_Step06_ClassBasedSkillsWithDI/Program.cs
+++ /dev/null
@@ -1,140 +0,0 @@
-// Copyright (c) Microsoft. All rights reserved.
-
-// This sample demonstrates how to use Dependency Injection (DI) with class-based Agent Skills.
-// Unlike code-defined skills (Step05), class-based skills bundle all components into a single
-// class extending AgentClassSkill. Skill script and resource functions can still resolve
-// services from the DI container via IServiceProvider, combining class-based organization
-// with the flexibility of DI.
-
-using System.Text.Json;
-using Azure.AI.OpenAI;
-using Azure.Identity;
-using Microsoft.Agents.AI;
-using Microsoft.Extensions.DependencyInjection;
-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";
-
-// --- Class-Based Skill with DI ---
-// Instantiate the skill class. Its resources and scripts will resolve services from
-// the DI container at execution time.
-var unitConverter = new UnitConverterSkill();
-
-// --- Skills Provider ---
-var skillsProvider = new AgentSkillsProvider(unitConverter);
-
-// --- DI Container ---
-// Register application services that skill scripts can resolve at execution time.
-ServiceCollection services = new();
-services.AddSingleton();
-
-// --- Agent Setup ---
-AIAgent agent = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential())
- .GetResponsesClient()
- .AsAIAgent(
- options: new ChatClientAgentOptions
- {
- Name = "UnitConverterAgent",
- ChatOptions = new()
- {
- Instructions = "You are a helpful assistant that can convert units.",
- },
- AIContextProviders = [skillsProvider],
- },
- model: deploymentName,
- services: services.BuildServiceProvider());
-
-// --- Example: Unit conversion ---
-Console.WriteLine("Converting units with DI-powered class-based 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}");
-
-///
-/// A unit-converter skill defined as a C# class that uses Dependency Injection.
-///
-///
-/// This skill resolves from the DI container
-/// in both its resource and script functions. This enables clean separation of
-/// concerns and testability while retaining the class-based skill pattern.
-///
-internal sealed class UnitConverterSkill : AgentClassSkill
-{
- private IReadOnlyList? _resources;
- private IReadOnlyList? _scripts;
-
- ///
- public override AgentSkillFrontmatter Frontmatter { get; } = new(
- "unit-converter",
- "Convert between common units using a multiplication factor. Use when asked to convert miles, kilometers, pounds, or kilograms.");
-
- ///
- protected override string 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. Use the convert script, passing the value and factor from the table.
- 3. Present the result clearly with both units.
- """;
-
- ///
- public override IReadOnlyList? Resources => this._resources ??=
- [
- // Dynamic resource with DI: resolves ConversionRateService to build conversion table
- CreateResource("conversion-table", (IServiceProvider serviceProvider) =>
- {
- var rateService = serviceProvider.GetRequiredService();
- return rateService.GetConversionTable();
- }),
- ];
-
- ///
- public override IReadOnlyList? Scripts => this._scripts ??=
- [
- // Script with DI: resolves ConversionRateService to perform the conversion
- CreateScript("convert", (double value, double factor, IServiceProvider serviceProvider) =>
- {
- var rateService = serviceProvider.GetRequiredService();
- return rateService.Convert(value, factor);
- }),
- ];
-}
-
-///
-/// Provides conversion rates between units.
-/// In a real application this could call an external API, read from a database,
-/// or apply time-varying exchange rates.
-///
-internal sealed class ConversionRateService
-{
- ///
- /// Returns a static markdown table of all supported conversions with factors.
- ///
- public string GetConversionTable() =>
- """
- # 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 |
- """;
-
- ///
- /// Converts a value by the given factor and returns a JSON result.
- ///
- public string Convert(double value, double factor)
- {
- double result = Math.Round(value * factor, 4);
- return JsonSerializer.Serialize(new { value, factor, result });
- }
-}
diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step06_ClassBasedSkillsWithDI/README.md b/dotnet/samples/02-agents/AgentSkills/Agent_Step06_ClassBasedSkillsWithDI/README.md
deleted file mode 100644
index 1406dbb3093..00000000000
--- a/dotnet/samples/02-agents/AgentSkills/Agent_Step06_ClassBasedSkillsWithDI/README.md
+++ /dev/null
@@ -1,58 +0,0 @@
-# Class-Based Agent Skills with Dependency Injection
-
-This sample demonstrates how to use **Dependency Injection (DI)** with **class-based Agent Skills** (`AgentClassSkill`).
-
-## What It Shows
-
-- Defining a skill as a class that extends `AgentClassSkill`
-- Using `IServiceProvider` in skill resource delegates to resolve services from the DI container
-- Using `IServiceProvider` in skill script delegates to resolve services from the DI container
-- Registering application services in a `ServiceCollection` and passing the built provider to the agent
-
-## How It Works
-
-1. A `ConversionRateService` is registered as a singleton in the DI container
-2. `UnitConverterSkill` extends `AgentClassSkill` and declares its resources and scripts using `CreateResource` and `CreateScript` factory methods
-3. The resource delegate declares `IServiceProvider` as a parameter — the framework injects it automatically
-4. The resource resolves `ConversionRateService` from the provider to build a supported-conversions table dynamically
-5. The script delegate also declares `IServiceProvider` as a parameter to look up conversion factors at runtime
-6. The agent is created with the service provider, which flows through to skill resource and script execution
-
-## How It Differs from Other Samples
-
-| Sample | Skill Type | DI Support |
-|--------|-----------|------------|
-| [Step03](../Agent_Step03_ClassBasedSkills/) | Class-based (`AgentClassSkill`) | No — static resources |
-| [Step05](../Agent_Step05_CodeDefinedSkillsWithDI/) | Code-defined (`AgentInlineSkill`) | Yes — inline delegates |
-| **Step06 (this)** | **Class-based (`AgentClassSkill`)** | **Yes — class delegates** |
-
-## Prerequisites
-
-- .NET 10
-- An Azure OpenAI deployment
-
-## Configuration
-
-Set the following environment variables:
-
-| Variable | Description |
-|---|---|
-| `AZURE_OPENAI_ENDPOINT` | Your Azure OpenAI endpoint URL |
-| `AZURE_OPENAI_DEPLOYMENT_NAME` | Model deployment name (defaults to `gpt-4o-mini`) |
-
-## Running the Sample
-
-```bash
-dotnet run
-```
-
-### Expected Output
-
-```
-Converting units with DI-powered class-based skills
-------------------------------------------------------------
-Agent: Here are your conversions:
-
-1. **26.2 miles → 42.16 km** (a marathon distance)
-2. **75 kg → 165.35 lbs**
-```
From 60e45f0eebbf54011c0bfa88e3ffaa27043bdb15 Mon Sep 17 00:00:00 2001
From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com>
Date: Sat, 4 Apr 2026 00:30:06 +0000
Subject: [PATCH 07/10] address review comments
---
.../Skills/File/AgentFileSkillsSource.cs | 155 +++++++---
.../File/AgentFileSkillsSourceOptions.cs | 16 +-
.../AgentSkills/FileAgentSkillLoaderTests.cs | 282 ++++++++++++++++++
3 files changed, 404 insertions(+), 49 deletions(-)
diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSource.cs b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSource.cs
index 3795e90ca43..bc1f04cee1b 100644
--- a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSource.cs
+++ b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSource.cs
@@ -100,25 +100,23 @@ public AgentFileSkillsSource(
this._skillPaths = Throw.IfNull(skillPaths);
this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger();
- var resolvedOptions = options ?? new AgentFileSkillsSourceOptions();
-
- ValidateExtensions(resolvedOptions.AllowedResourceExtensions);
- ValidateExtensions(resolvedOptions.AllowedScriptExtensions);
+ ValidateExtensions(options?.AllowedResourceExtensions);
+ ValidateExtensions(options?.AllowedScriptExtensions);
this._allowedResourceExtensions = new HashSet(
- resolvedOptions.AllowedResourceExtensions ?? s_defaultResourceExtensions,
+ options?.AllowedResourceExtensions ?? s_defaultResourceExtensions,
StringComparer.OrdinalIgnoreCase);
this._allowedScriptExtensions = new HashSet(
- resolvedOptions.AllowedScriptExtensions ?? s_defaultScriptExtensions,
+ options?.AllowedScriptExtensions ?? s_defaultScriptExtensions,
StringComparer.OrdinalIgnoreCase);
- this._scriptFolders = resolvedOptions.ScriptFolders is not null
- ? [.. FilterValidFolderNames(resolvedOptions.ScriptFolders, this._logger)]
+ this._scriptFolders = options?.ScriptFolders is not null
+ ? [.. ValidateAndNormalizeFolderNames(options.ScriptFolders, this._logger)]
: s_defaultScriptFolders;
- this._resourceFolders = resolvedOptions.ResourceFolders is not null
- ? [.. FilterValidFolderNames(resolvedOptions.ResourceFolders, this._logger)]
+ this._resourceFolders = options?.ResourceFolders is not null
+ ? [.. ValidateAndNormalizeFolderNames(options.ResourceFolders, this._logger)]
: s_defaultResourceFolders;
this._scriptRunner = scriptRunner;
@@ -197,8 +195,13 @@ private static void SearchDirectoriesForSkills(string directory, List re
return null;
}
- var resources = this.DiscoverResourceFiles(skillDirectoryFullPath, frontmatter.Name);
- var scripts = this.DiscoverScriptFiles(skillDirectoryFullPath, frontmatter.Name);
+ // Append a trailing separator so path-containment checks don't false-match
+ // sibling directories. e.g. "/skills/myskill" matches "/skills/myskill-evil/",
+ // but "/skills/myskill/" does not.
+ string normalizedSkillDirectoryFullPath = skillDirectoryFullPath + Path.DirectorySeparatorChar;
+
+ var resources = this.DiscoverResourceFiles(normalizedSkillDirectoryFullPath, frontmatter.Name);
+ var scripts = this.DiscoverScriptFiles(normalizedSkillDirectoryFullPath, frontmatter.Name);
return new AgentFileSkill(
frontmatter: frontmatter,
@@ -311,20 +314,34 @@ private bool TryParseFrontmatter(string content, string skillFilePath, [NotNullW
///
private List DiscoverResourceFiles(string skillDirectoryFullPath, string skillName)
{
- string normalizedSkillDirectoryFullPath = skillDirectoryFullPath + Path.DirectorySeparatorChar;
var resources = new List();
foreach (string folder in this._resourceFolders.Distinct(StringComparer.OrdinalIgnoreCase))
{
- string targetDirectory = string.Equals(folder, RootFolderIndicator, StringComparison.Ordinal)
+ bool isRootFolder = string.Equals(folder, RootFolderIndicator, StringComparison.Ordinal);
+
+ string targetDirectory = isRootFolder
? skillDirectoryFullPath
- : Path.Combine(skillDirectoryFullPath, folder);
+ : Path.Combine(skillDirectoryFullPath, folder) + Path.DirectorySeparatorChar;
if (!Directory.Exists(targetDirectory))
{
continue;
}
+ // Directory-level symlink check: skip if targetDirectory (or any intermediate
+ // segment) is a reparse point. The root folder is excluded — it's a caller-supplied
+ // trusted path, and the security boundary guards files within it, not the path itself.
+ if (!isRootFolder && HasSymlinkInPath(targetDirectory, skillDirectoryFullPath))
+ {
+ if (this._logger.IsEnabled(LogLevel.Warning))
+ {
+ LogResourceSymlinkFolder(this._logger, skillName, SanitizePathForLog(folder));
+ }
+
+ continue;
+ }
+
#if NET
var enumerationOptions = new EnumerationOptions
{
@@ -358,11 +375,13 @@ private List DiscoverResourceFiles(string skillDirectory
continue;
}
- // Normalize the enumerated path to guard against non-canonical forms
+ // Normalize the enumerated path to guard against non-canonical forms.
+ // e.g. "references/../../../etc/shadow" → "/etc/shadow"
string resolvedFilePath = Path.GetFullPath(filePath);
- // Path containment check
- if (!resolvedFilePath.StartsWith(normalizedSkillDirectoryFullPath, StringComparison.OrdinalIgnoreCase))
+ // Path containment: reject if the resolved path escapes the target folder.
+ // e.g. "/etc/shadow".StartsWith("/skills/myskill/references/") → false → skip
+ if (!resolvedFilePath.StartsWith(targetDirectory, StringComparison.OrdinalIgnoreCase))
{
if (this._logger.IsEnabled(LogLevel.Warning))
{
@@ -372,8 +391,9 @@ private List DiscoverResourceFiles(string skillDirectory
continue;
}
- // Symlink check
- if (HasSymlinkInPath(resolvedFilePath, normalizedSkillDirectoryFullPath))
+ // Per-file symlink check: detects if the file (or any intermediate segment)
+ // is a reparse point. e.g. "references/secret.md" → symlink to "/etc/shadow"
+ if (HasSymlinkInPath(resolvedFilePath, targetDirectory))
{
if (this._logger.IsEnabled(LogLevel.Warning))
{
@@ -383,8 +403,9 @@ private List DiscoverResourceFiles(string skillDirectory
continue;
}
- // Compute relative path and normalize to forward slashes
- string relativePath = NormalizePath(resolvedFilePath.Substring(normalizedSkillDirectoryFullPath.Length));
+ // Compute relative path and normalize separators.
+ // e.g. "/skills/myskill/references/guide.md" → "references/guide.md"
+ string relativePath = NormalizePath(resolvedFilePath.Substring(skillDirectoryFullPath.Length));
resources.Add(new AgentFileSkillResource(relativePath, resolvedFilePath));
}
@@ -405,20 +426,34 @@ private List DiscoverResourceFiles(string skillDirectory
///
private List DiscoverScriptFiles(string skillDirectoryFullPath, string skillName)
{
- string normalizedSkillDirectoryFullPath = skillDirectoryFullPath + Path.DirectorySeparatorChar;
var scripts = new List();
foreach (string folder in this._scriptFolders.Distinct(StringComparer.OrdinalIgnoreCase))
{
- string targetDirectory = string.Equals(folder, RootFolderIndicator, StringComparison.Ordinal)
+ bool isRootFolder = string.Equals(folder, RootFolderIndicator, StringComparison.Ordinal);
+
+ string targetDirectory = isRootFolder
? skillDirectoryFullPath
- : Path.Combine(skillDirectoryFullPath, folder);
+ : Path.Combine(skillDirectoryFullPath, folder) + Path.DirectorySeparatorChar;
if (!Directory.Exists(targetDirectory))
{
continue;
}
+ // Directory-level symlink check: skip if targetDirectory (or any intermediate
+ // segment) is a reparse point. The root folder is excluded — it's a caller-supplied
+ // trusted path, and the security boundary guards files within it, not the path itself.
+ if (!isRootFolder && HasSymlinkInPath(targetDirectory, skillDirectoryFullPath))
+ {
+ if (this._logger.IsEnabled(LogLevel.Warning))
+ {
+ LogScriptSymlinkFolder(this._logger, skillName, SanitizePathForLog(folder));
+ }
+
+ continue;
+ }
+
#if NET
var enumerationOptions = new EnumerationOptions
{
@@ -439,11 +474,13 @@ private List DiscoverScriptFiles(string skillDirectoryFull
continue;
}
- // Normalize the enumerated path to guard against non-canonical forms
+ // Normalize the enumerated path to guard against non-canonical forms.
+ // e.g. "scripts/../../../etc/shadow" → "/etc/shadow"
string resolvedFilePath = Path.GetFullPath(filePath);
- // Path containment check
- if (!resolvedFilePath.StartsWith(normalizedSkillDirectoryFullPath, StringComparison.OrdinalIgnoreCase))
+ // Path containment: reject if the resolved path escapes the target folder.
+ // e.g. "/etc/shadow".StartsWith("/skills/myskill/scripts/") → false → skip
+ if (!resolvedFilePath.StartsWith(targetDirectory, StringComparison.OrdinalIgnoreCase))
{
if (this._logger.IsEnabled(LogLevel.Warning))
{
@@ -453,8 +490,9 @@ private List DiscoverScriptFiles(string skillDirectoryFull
continue;
}
- // Symlink check
- if (HasSymlinkInPath(resolvedFilePath, normalizedSkillDirectoryFullPath))
+ // Per-file symlink check: detects if the file (or any intermediate segment)
+ // is a reparse point. e.g. "scripts/run.py" → symlink to "/etc/shadow"
+ if (HasSymlinkInPath(resolvedFilePath, targetDirectory))
{
if (this._logger.IsEnabled(LogLevel.Warning))
{
@@ -464,8 +502,9 @@ private List DiscoverScriptFiles(string skillDirectoryFull
continue;
}
- // Compute relative path and normalize to forward slashes
- string relativePath = NormalizePath(resolvedFilePath.Substring(normalizedSkillDirectoryFullPath.Length));
+ // Compute relative path and normalize separators.
+ // e.g. "/skills/myskill/scripts/parsepdf.py" → "scripts/parsepdf.py"
+ string relativePath = NormalizePath(resolvedFilePath.Substring(skillDirectoryFullPath.Length));
scripts.Add(new AgentFileSkillScript(relativePath, resolvedFilePath, this._scriptRunner));
}
@@ -477,14 +516,14 @@ private List DiscoverScriptFiles(string skillDirectoryFull
///
/// Checks whether any segment in the path (relative to the directory) is a symlink.
///
- private static bool HasSymlinkInPath(string fullPath, string normalizedDirectoryPath)
+ private static bool HasSymlinkInPath(string pathToCheck, string trustedBasePath)
{
- string relativePath = fullPath.Substring(normalizedDirectoryPath.Length);
+ string relativePath = pathToCheck.Substring(trustedBasePath.Length);
string[] segments = relativePath.Split(
- new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar },
+ [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar],
StringSplitOptions.RemoveEmptyEntries);
- string currentPath = normalizedDirectoryPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
+ string currentPath = trustedBasePath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
foreach (string segment in segments)
{
@@ -500,19 +539,26 @@ private static bool HasSymlinkInPath(string fullPath, string normalizedDirectory
}
///
- /// Normalizes a relative path by replacing backslashes with forward slashes
- /// and trimming a leading "./" prefix.
+ /// Normalizes a relative path or folder name by stripping a leading "./"/".\",
+ /// trimming trailing directory separators, and replacing backslashes with forward
+ /// slashes.
///
private static string NormalizePath(string path)
{
- if (path.IndexOf('\\') >= 0)
+ // Strip leading "./" or ".\"
+ if (path.StartsWith("./", StringComparison.Ordinal) ||
+ path.StartsWith(".\\", StringComparison.Ordinal))
{
- path = path.Replace('\\', '/');
+ path = path.Substring(2);
}
- if (path.StartsWith("./", StringComparison.Ordinal))
+ // Trim trailing directory separators
+ path = path.TrimEnd('/', '\\');
+
+ // Normalize all separators to forward slashes
+ if (path.IndexOf('\\') >= 0)
{
- path = path.Substring(2);
+ path = path.Replace('\\', '/');
}
return path;
@@ -554,7 +600,7 @@ private static void ValidateExtensions(IEnumerable? extensions)
}
}
- private static IEnumerable FilterValidFolderNames(IEnumerable folders, ILogger logger)
+ private static IEnumerable ValidateAndNormalizeFolderNames(IEnumerable folders, ILogger logger)
{
foreach (string folder in folders)
{
@@ -571,14 +617,27 @@ private static IEnumerable FilterValidFolderNames(IEnumerable fo
}
// Reject absolute paths and any path segments that escape upward.
- if (Path.IsPathRooted(folder) || folder.Contains("..", StringComparison.Ordinal))
+ if (Path.IsPathRooted(folder) || ContainsParentTraversalSegment(folder))
{
LogFolderNameSkippedInvalid(logger, folder);
continue;
}
- yield return folder;
+ yield return NormalizePath(folder);
+ }
+ }
+
+ private static bool ContainsParentTraversalSegment(string folder)
+ {
+ foreach (string segment in folder.Split('/', '\\'))
+ {
+ if (segment == "..")
+ {
+ return true;
+ }
}
+
+ return false;
}
[LoggerMessage(LogLevel.Information, "Discovered {Count} potential skills")]
@@ -605,6 +664,9 @@ private static IEnumerable FilterValidFolderNames(IEnumerable fo
[LoggerMessage(LogLevel.Warning, "Skipping resource in skill '{SkillName}': '{ResourcePath}' is a symlink that resolves outside the skill directory")]
private static partial void LogResourceSymlinkEscape(ILogger logger, string skillName, string resourcePath);
+ [LoggerMessage(LogLevel.Warning, "Skipping resource folder '{FolderName}' in skill '{SkillName}': folder path contains a symlink")]
+ private static partial void LogResourceSymlinkFolder(ILogger logger, string skillName, string folderName);
+
[LoggerMessage(LogLevel.Debug, "Skipping file '{FilePath}' in skill '{SkillName}': extension '{Extension}' is not in the allowed list")]
private static partial void LogResourceSkippedExtension(ILogger logger, string skillName, string filePath, string extension);
@@ -614,6 +676,9 @@ private static IEnumerable FilterValidFolderNames(IEnumerable fo
[LoggerMessage(LogLevel.Warning, "Skipping script in skill '{SkillName}': '{ScriptPath}' is a symlink that resolves outside the skill directory")]
private static partial void LogScriptSymlinkEscape(ILogger logger, string skillName, string scriptPath);
+ [LoggerMessage(LogLevel.Warning, "Skipping script folder '{FolderName}' in skill '{SkillName}': folder path contains a symlink")]
+ private static partial void LogScriptSymlinkFolder(ILogger logger, string skillName, string folderName);
+
[LoggerMessage(LogLevel.Warning, "Skipping invalid folder name '{FolderName}': must be a relative path with no '..' segments")]
private static partial void LogFolderNameSkippedInvalid(ILogger logger, string folderName);
}
diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSourceOptions.cs b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSourceOptions.cs
index 7115b4164aa..fcd9398104b 100644
--- a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSourceOptions.cs
+++ b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSourceOptions.cs
@@ -32,8 +32,12 @@ public sealed class AgentFileSkillsSourceOptions
public IEnumerable? AllowedScriptExtensions { get; set; }
///
- /// Gets or sets the sub-folder names to scan for script files, relative to the skill directory.
- /// Use "." to include files directly at the skill root.
+ /// Gets or sets relative folder paths to scan for script files within each skill directory.
+ /// Values may be single-segment names (e.g., "scripts") or multi-segment relative
+ /// paths (e.g., "sub/scripts"). Use "." to include files directly at the
+ /// skill root. Leading "./" prefixes, trailing separators, and backslashes are
+ /// normalized automatically; paths containing ".." segments or absolute paths are
+ /// rejected.
/// When , defaults to scripts (per the
/// Agent Skills specification).
/// When set, replaces the defaults entirely.
@@ -41,8 +45,12 @@ public sealed class AgentFileSkillsSourceOptions
public IEnumerable? ScriptFolders { get; set; }
///
- /// Gets or sets the sub-folder names to scan for resource files, relative to the skill directory.
- /// Use "." to include files directly at the skill root.
+ /// Gets or sets relative folder paths to scan for resource files within each skill directory.
+ /// Values may be single-segment names (e.g., "references") or multi-segment relative
+ /// paths (e.g., "sub/resources"). Use "." to include files directly at the
+ /// skill root. Leading "./" prefixes, trailing separators, and backslashes are
+ /// normalized automatically; paths containing ".." segments or absolute paths are
+ /// rejected.
/// When , defaults to references and assets (per the
/// Agent Skills specification).
/// When set, replaces the defaults entirely.
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs
index 0f926450f1b..efebc540cfb 100644
--- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs
@@ -612,6 +612,124 @@ public async Task GetSkillsAsync_SymlinkInPath_SkipsSymlinkedResourcesAsync()
Assert.Single(skill.Resources!);
Assert.Equal("assets/legit.md", skill.Resources![0].Name);
}
+
+ [Fact]
+ public async Task GetSkillsAsync_SymlinkedResourceFolder_SkipsWithoutEnumeratingAsync()
+ {
+ // Arrange — references/ is a symlink pointing outside the skill directory.
+ // The directory-level check should skip it entirely (no file enumeration),
+ // so even files with valid extensions in the target are not discovered.
+ string skillDir = Path.Combine(this._testRoot, "symlink-folder-skip");
+ string assetsDir = Path.Combine(skillDir, "assets");
+ Directory.CreateDirectory(assetsDir);
+ File.WriteAllText(Path.Combine(assetsDir, "legit.md"), "legit content");
+
+ string outsideDir = Path.Combine(this._testRoot, "outside-resources");
+ Directory.CreateDirectory(outsideDir);
+ File.WriteAllText(Path.Combine(outsideDir, "external.md"), "external content");
+ File.WriteAllText(Path.Combine(outsideDir, "data.json"), "{}");
+
+ string refsLink = Path.Combine(skillDir, "references");
+ try
+ {
+ Directory.CreateSymbolicLink(refsLink, outsideDir);
+ }
+ catch (IOException)
+ {
+ // Symlink creation requires elevation on some platforms; skip gracefully.
+ return;
+ }
+
+ File.WriteAllText(
+ Path.Combine(skillDir, "SKILL.md"),
+ "---\nname: symlink-folder-skip\ndescription: Symlinked folder skip\n---\nBody.");
+ var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
+
+ // Act
+ var skills = await source.GetSkillsAsync();
+
+ // Assert — only assets/legit.md is found; the symlinked references/ folder is skipped entirely
+ var skill = skills.FirstOrDefault(s => s.Frontmatter.Name == "symlink-folder-skip");
+ Assert.NotNull(skill);
+ Assert.Single(skill.Resources!);
+ Assert.Equal("assets/legit.md", skill.Resources![0].Name);
+ }
+
+ [Fact]
+ public async Task GetSkillsAsync_SymlinkedScriptFolder_SkipsWithoutEnumeratingAsync()
+ {
+ // Arrange — scripts/ is a symlink pointing outside the skill directory.
+ // The directory-level check should skip it entirely.
+ string skillDir = Path.Combine(this._testRoot, "symlink-script-skip");
+ Directory.CreateDirectory(skillDir);
+
+ string outsideDir = Path.Combine(this._testRoot, "outside-scripts");
+ Directory.CreateDirectory(outsideDir);
+ File.WriteAllText(Path.Combine(outsideDir, "malicious.py"), "import os; os.system('rm -rf /')");
+
+ string scriptsLink = Path.Combine(skillDir, "scripts");
+ try
+ {
+ Directory.CreateSymbolicLink(scriptsLink, outsideDir);
+ }
+ catch (IOException)
+ {
+ return;
+ }
+
+ File.WriteAllText(
+ Path.Combine(skillDir, "SKILL.md"),
+ "---\nname: symlink-script-skip\ndescription: Symlinked script folder\n---\nBody.");
+ var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
+
+ // Act
+ var skills = await source.GetSkillsAsync();
+
+ // Assert — skill loads but scripts from the symlinked folder are not discovered
+ var skill = skills.FirstOrDefault(s => s.Frontmatter.Name == "symlink-script-skip");
+ Assert.NotNull(skill);
+ Assert.Empty(skill.Scripts!);
+ }
+
+ [Fact]
+ public async Task GetSkillsAsync_SymlinkedIntermediateSegment_SkipsCustomFolderAsync()
+ {
+ // Arrange — custom resource folder "sub/resources" where "sub" is a symlink.
+ // The directory-level HasSymlinkInPath check should detect the intermediate symlink.
+ string skillDir = Path.Combine(this._testRoot, "symlink-intermediate");
+ Directory.CreateDirectory(skillDir);
+
+ string outsideDir = Path.Combine(this._testRoot, "outside-intermediate");
+ string outsideResources = Path.Combine(outsideDir, "resources");
+ Directory.CreateDirectory(outsideResources);
+ File.WriteAllText(Path.Combine(outsideResources, "data.md"), "data");
+
+ string subLink = Path.Combine(skillDir, "sub");
+ try
+ {
+ Directory.CreateSymbolicLink(subLink, outsideDir);
+ }
+ catch (IOException)
+ {
+ return;
+ }
+
+ File.WriteAllText(
+ Path.Combine(skillDir, "SKILL.md"),
+ "---\nname: symlink-intermediate\ndescription: Intermediate symlink\n---\nBody.");
+ var source = new AgentFileSkillsSource(
+ this._testRoot,
+ s_noOpExecutor,
+ new AgentFileSkillsSourceOptions { ResourceFolders = ["sub/resources"] });
+
+ // Act
+ var skills = await source.GetSkillsAsync();
+
+ // Assert — the symlinked intermediate segment causes the folder to be skipped
+ var skill = skills.FirstOrDefault(s => s.Frontmatter.Name == "symlink-intermediate");
+ Assert.NotNull(skill);
+ Assert.Empty(skill.Resources!);
+ }
#endif
[Fact]
@@ -811,6 +929,7 @@ public void Constructor_NullOrWhitespaceFolderName_ThrowsArgumentException(strin
[InlineData(".")]
[InlineData("./scripts")]
[InlineData("./scripts/f1")]
+ [InlineData("my..scripts")]
public void Constructor_ValidFolderName_DoesNotThrow(string validFolder)
{
// Arrange & Act & Assert
@@ -818,6 +937,76 @@ public void Constructor_ValidFolderName_DoesNotThrow(string validFolder)
Assert.NotNull(source);
}
+ [Fact]
+ public async Task GetSkillsAsync_DuplicateFoldersAfterNormalization_NoDuplicateResourcesAsync()
+ {
+ // Arrange — "references" and "./references" refer to the same directory;
+ // after normalization they should be deduplicated so resources appear only once.
+ string skillDir = Path.Combine(this._testRoot, "dedup-folder-skill");
+ string refsDir = Path.Combine(skillDir, "references");
+ Directory.CreateDirectory(refsDir);
+ File.WriteAllText(Path.Combine(refsDir, "FAQ.md"), "FAQ content");
+ File.WriteAllText(
+ Path.Combine(skillDir, "SKILL.md"),
+ "---\nname: dedup-folder-skill\ndescription: Dedup test\n---\nBody.");
+ var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
+ new AgentFileSkillsSourceOptions { ResourceFolders = ["references", "./references"] });
+
+ // Act
+ var skills = await source.GetSkillsAsync();
+
+ // Assert — only one copy of the resource despite two equivalent folder entries
+ Assert.Single(skills);
+ Assert.Single(skills[0].Resources!);
+ Assert.Equal("references/FAQ.md", skills[0].Resources![0].Name);
+ }
+
+ [Fact]
+ public async Task GetSkillsAsync_TrailingSlashFolderNormalized_NoDuplicateResourcesAsync()
+ {
+ // Arrange — "references/" should be normalized to "references"
+ string skillDir = Path.Combine(this._testRoot, "trailing-slash-skill");
+ string refsDir = Path.Combine(skillDir, "references");
+ Directory.CreateDirectory(refsDir);
+ File.WriteAllText(Path.Combine(refsDir, "data.json"), "{}");
+ File.WriteAllText(
+ Path.Combine(skillDir, "SKILL.md"),
+ "---\nname: trailing-slash-skill\ndescription: Trailing slash test\n---\nBody.");
+ var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
+ new AgentFileSkillsSourceOptions { ResourceFolders = ["references", "references/"] });
+
+ // Act
+ var skills = await source.GetSkillsAsync();
+
+ // Assert — trailing slash variant deduplicated
+ Assert.Single(skills);
+ Assert.Single(skills[0].Resources!);
+ Assert.Equal("references/data.json", skills[0].Resources![0].Name);
+ }
+
+ [Fact]
+ public async Task GetSkillsAsync_BackslashFolderNormalized_NoDuplicateScriptsAsync()
+ {
+ // Arrange — ".\\scripts" should be normalized to "scripts"
+ string skillDir = Path.Combine(this._testRoot, "backslash-skill");
+ string scriptsDir = Path.Combine(skillDir, "scripts");
+ Directory.CreateDirectory(scriptsDir);
+ File.WriteAllText(Path.Combine(scriptsDir, "run.py"), "print('hello')");
+ File.WriteAllText(
+ Path.Combine(skillDir, "SKILL.md"),
+ "---\nname: backslash-skill\ndescription: Backslash test\n---\nBody.");
+ var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
+ new AgentFileSkillsSourceOptions { ScriptFolders = ["scripts", ".\\scripts"] });
+
+ // Act
+ var skills = await source.GetSkillsAsync();
+
+ // Assert — backslash variant deduplicated
+ Assert.Single(skills);
+ Assert.Single(skills[0].Scripts!);
+ Assert.Equal("scripts/run.py", skills[0].Scripts![0].Name);
+ }
+
[Theory]
[InlineData("./references")]
[InlineData("./assets/docs")]
@@ -886,4 +1075,97 @@ private string CreateSkillDirectoryWithRawContent(string directoryName, string r
File.WriteAllText(Path.Combine(skillDir, "SKILL.md"), rawContent);
return skillDir;
}
+
+ [Theory]
+ [InlineData("txt")]
+ [InlineData("")]
+ [InlineData(" ")]
+ public void Constructor_InvalidScriptExtension_ThrowsArgumentException(string badExtension)
+ {
+ // Arrange & Act & Assert
+ Assert.Throws(() => new AgentFileSkillsSource(
+ this._testRoot, s_noOpExecutor,
+ new AgentFileSkillsSourceOptions { AllowedScriptExtensions = new string[] { badExtension } }));
+ }
+
+ [Fact]
+ public async Task GetSkillsAsync_SkillBeyondMaxDepth_NotDiscoveredAsync()
+ {
+ // Arrange — create a skill at depth 3 (exceeds MaxSearchDepth = 2)
+ string deepDir = Path.Combine(this._testRoot, "l1", "l2", "l3", "deep-skill");
+ Directory.CreateDirectory(deepDir);
+ File.WriteAllText(
+ Path.Combine(deepDir, "SKILL.md"),
+ "---\nname: deep-skill\ndescription: Too deep\n---\nBody.");
+ var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
+
+ // Act
+ var skills = await source.GetSkillsAsync();
+
+ // Assert — skill at depth 3 should not be discovered
+ Assert.DoesNotContain(skills, s => s.Frontmatter.Name == "deep-skill");
+ }
+
+ [Fact]
+ public async Task GetSkillsAsync_ScriptInSkillRoot_DiscoveredWhenRootFolderConfiguredAsync()
+ {
+ // Arrange — script file directly in the skill directory with ScriptFolders = ["."]
+ string skillDir = Path.Combine(this._testRoot, "root-script-skill");
+ Directory.CreateDirectory(skillDir);
+ File.WriteAllText(Path.Combine(skillDir, "run.py"), "print('hello')");
+ File.WriteAllText(
+ Path.Combine(skillDir, "SKILL.md"),
+ "---\nname: root-script-skill\ndescription: Root script\n---\nBody.");
+ var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
+ new AgentFileSkillsSourceOptions { ScriptFolders = ["."] });
+
+ // Act
+ var skills = await source.GetSkillsAsync();
+
+ // 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);
+ }
+
+ [Fact]
+ public async Task GetSkillsAsync_SymlinkedFileInRealFolder_SkipsSymlinkedFileAsync()
+ {
+ // Arrange — references/ is a real directory, but one file inside it is a symlink
+ // pointing outside the skill directory. The per-file symlink check should skip it.
+ string skillDir = Path.Combine(this._testRoot, "symlink-file-skill");
+ string refsDir = Path.Combine(skillDir, "references");
+ Directory.CreateDirectory(refsDir);
+ File.WriteAllText(Path.Combine(refsDir, "legit.md"), "legit content");
+
+ string outsideDir = Path.Combine(this._testRoot, "outside-file");
+ Directory.CreateDirectory(outsideDir);
+ File.WriteAllText(Path.Combine(outsideDir, "secret.md"), "secret content");
+
+ string symlinkFile = Path.Combine(refsDir, "leak.md");
+ try
+ {
+ File.CreateSymbolicLink(symlinkFile, Path.Combine(outsideDir, "secret.md"));
+ }
+ catch (IOException)
+ {
+ // Symlink creation requires elevation on some platforms; skip gracefully.
+ return;
+ }
+
+ File.WriteAllText(
+ Path.Combine(skillDir, "SKILL.md"),
+ "---\nname: symlink-file-skill\ndescription: Symlinked file\n---\nBody.");
+ var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
+
+ // Act
+ var skills = await source.GetSkillsAsync();
+
+ // 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);
+ }
}
From ef5a945d97412f4849d3a0df76384953f5ba948c Mon Sep 17 00:00:00 2001
From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com>
Date: Tue, 7 Apr 2026 09:58:36 +0000
Subject: [PATCH 08/10] fix build error
---
.../AgentSkills/FileAgentSkillLoaderTests.cs | 2 ++
1 file changed, 2 insertions(+)
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs
index efebc540cfb..ca0884ea43e 100644
--- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs
@@ -1129,6 +1129,7 @@ public async Task GetSkillsAsync_ScriptInSkillRoot_DiscoveredWhenRootFolderConfi
Assert.Equal("run.py", skill.Scripts![0].Name);
}
+#if NET
[Fact]
public async Task GetSkillsAsync_SymlinkedFileInRealFolder_SkipsSymlinkedFileAsync()
{
@@ -1168,4 +1169,5 @@ public async Task GetSkillsAsync_SymlinkedFileInRealFolder_SkipsSymlinkedFileAsy
Assert.Single(skill.Resources!);
Assert.Equal("references/legit.md", skill.Resources![0].Name);
}
+#endif
}
From 24d216aeabd3587454001e97be1d1aa29153aa8f Mon Sep 17 00:00:00 2001
From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com>
Date: Tue, 7 Apr 2026 11:20:42 +0000
Subject: [PATCH 09/10] Fix mixed path separators in skill folder discovery on
.NET Framework
Path.Combine with forward-slash folder names (e.g. "scripts/f1") produces
mixed separators on Windows, causing the StartsWith containment check to
fail against Path.GetFullPath-resolved file paths. Wrap in Path.GetFullPath
to canonicalize separators before the containment comparison.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../Skills/File/AgentFileSkillsSource.cs | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSource.cs b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSource.cs
index bc1f04cee1b..79595f17a27 100644
--- a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSource.cs
+++ b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSource.cs
@@ -320,9 +320,10 @@ private List DiscoverResourceFiles(string skillDirectory
{
bool isRootFolder = string.Equals(folder, RootFolderIndicator, StringComparison.Ordinal);
+ // GetFullPath normalizes mixed separators (e.g. "C:\skill\scripts/f1" → "C:\skill\scripts\f1")
string targetDirectory = isRootFolder
? skillDirectoryFullPath
- : Path.Combine(skillDirectoryFullPath, folder) + Path.DirectorySeparatorChar;
+ : Path.GetFullPath(Path.Combine(skillDirectoryFullPath, folder)) + Path.DirectorySeparatorChar;
if (!Directory.Exists(targetDirectory))
{
@@ -432,9 +433,10 @@ private List DiscoverScriptFiles(string skillDirectoryFull
{
bool isRootFolder = string.Equals(folder, RootFolderIndicator, StringComparison.Ordinal);
+ // GetFullPath normalizes mixed separators (e.g. "C:\skill\scripts/f1" → "C:\skill\scripts\f1")
string targetDirectory = isRootFolder
? skillDirectoryFullPath
- : Path.Combine(skillDirectoryFullPath, folder) + Path.DirectorySeparatorChar;
+ : Path.GetFullPath(Path.Combine(skillDirectoryFullPath, folder)) + Path.DirectorySeparatorChar;
if (!Directory.Exists(targetDirectory))
{
From 8bf68ee0b6c5035b5288c4eed8d972b03ab7da6e Mon Sep 17 00:00:00 2001
From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com>
Date: Tue, 7 Apr 2026 11:59:06 +0000
Subject: [PATCH 10/10] address comment
---
.../AgentFileSkillsSourceScriptTests.cs | 25 +++++++++++++------
1 file changed, 17 insertions(+), 8 deletions(-)
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillsSourceScriptTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillsSourceScriptTests.cs
index 22dc5d40e6c..d524b6142a4 100644
--- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillsSourceScriptTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillsSourceScriptTests.cs
@@ -249,23 +249,32 @@ public async Task GetSkillsAsync_ScriptFoldersWithNestedPath_DiscoversScriptsAsy
[Theory]
[InlineData("./scripts")]
[InlineData("./scripts/f1")]
- public async Task GetSkillsAsync_ScriptFolderWithDotSlashPrefix_DiscoversScriptsAsync(string folder)
+ [InlineData("./scripts/f1", "./f2")]
+ public async Task GetSkillsAsync_ScriptFolderWithDotSlashPrefix_DiscoversScriptsAsync(params string[] folders)
{
- // Arrange — "./scripts" and "./scripts/f1" are equivalent to "scripts" and "scripts/f1";
+ // Arrange — "./"-prefixed folders are equivalent to their counterparts without the prefix;
// the leading "./" is transparently normalized by Path.GetFullPath during file enumeration.
- string folderWithoutDotSlash = folder.Substring(2); // strip "./"
string skillDir = CreateSkillDir(this._testRoot, "dotslash-script-skill", "Dot-slash prefix", "Body.");
- CreateFile(skillDir, $"{folderWithoutDotSlash}/run.py", "print('dotslash')");
+ foreach (string folder in folders)
+ {
+ string folderWithoutDotSlash = folder.Substring(2); // strip "./"
+ CreateFile(skillDir, $"{folderWithoutDotSlash}/run.py", "print('dotslash')");
+ }
+
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
- new AgentFileSkillsSourceOptions { ScriptFolders = [folder] });
+ new AgentFileSkillsSourceOptions { ScriptFolders = folders });
// Act
var skills = await source.GetSkillsAsync(CancellationToken.None);
- // Assert — script is discovered with a name identical to using the folder without "./"
+ // Assert — scripts are discovered with names identical to using folders without "./"
Assert.Single(skills);
- Assert.Single(skills[0].Scripts!);
- Assert.Equal($"{folderWithoutDotSlash}/run.py", skills[0].Scripts![0].Name);
+ Assert.Equal(folders.Length, skills[0].Scripts!.Count);
+ foreach (string folder in folders)
+ {
+ string expectedName = $"{folder.Substring(2)}/run.py";
+ Assert.Contains(skills[0].Scripts!, s => s.Name == expectedName);
+ }
}
private static string CreateSkillDir(string root, string name, string description, string body)