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 RunAsync(AgentSkill skill, AIFunctionArguments arguments, CancellationToken cancellationToken = default) diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentClassSkillTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentClassSkillTests.cs new file mode 100644 index 00000000000..cc706111602 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentClassSkillTests.cs @@ -0,0 +1,382 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.UnitTests.AgentSkills; + +/// +/// Unit tests for and . +/// +public sealed class AgentClassSkillTests +{ + [Fact] + public void Resources_DefaultsToNull_WhenNotOverridden() + { + // Arrange + var skill = new MinimalClassSkill(); + + // Act & Assert + Assert.Null(skill.Resources); + } + + [Fact] + public void Scripts_DefaultsToNull_WhenNotOverridden() + { + // Arrange + var skill = new MinimalClassSkill(); + + // Act & Assert + Assert.Null(skill.Scripts); + } + + [Fact] + public void Resources_ReturnsOverriddenList_WhenOverridden() + { + // Arrange + var skill = new FullClassSkill(); + + // Act + var resources = skill.Resources; + + // Assert + Assert.Single(resources!); + Assert.Equal("test-resource", resources![0].Name); + } + + [Fact] + public void Scripts_ReturnsOverriddenList_WhenOverridden() + { + // Arrange + var skill = new FullClassSkill(); + + // Act + var scripts = skill.Scripts; + + // Assert + Assert.Single(scripts!); + Assert.Equal("TestScript", scripts![0].Name); + } + + [Fact] + public void ResourcesAndScripts_CanBeLazyLoaded_AndCached() + { + // Arrange + var skill = new LazyLoadedSkill(); + + // Act & Assert + Assert.Equal(0, skill.ResourceCreationCount); + Assert.Equal(0, skill.ScriptCreationCount); + + var firstResources = skill.Resources; + var firstScripts = skill.Scripts; + var secondResources = skill.Resources; + var secondScripts = skill.Scripts; + + Assert.Single(firstResources!); + Assert.Single(firstScripts!); + Assert.Same(firstResources, secondResources); + Assert.Same(firstScripts, secondScripts); + Assert.Equal(1, skill.ResourceCreationCount); + Assert.Equal(1, skill.ScriptCreationCount); + } + + [Fact] + public void Name_Content_ReturnClassDefinedValues() + { + // Arrange + var skill = new MinimalClassSkill(); + + // Act & Assert + Assert.Equal("minimal", skill.Frontmatter.Name); + Assert.Contains("", skill.Content); + Assert.Contains("Minimal skill body.", skill.Content); + Assert.Contains("", skill.Content); + } + + [Fact] + public void Content_ReturnsSynthesizedXmlDocument() + { + // Arrange + var skill = new MinimalClassSkill(); + + // Act & Assert + Assert.Contains("minimal", skill.Content); + Assert.Contains("A minimal skill.", skill.Content); + Assert.Contains("", skill.Content); + Assert.Contains("Minimal skill body.", skill.Content); + } + + [Fact] + public async Task AgentInMemorySkillsSource_ReturnsAllSkills() + { + // Arrange + var skills = new AgentClassSkill[] { new MinimalClassSkill(), new FullClassSkill() }; + var source = new AgentInMemorySkillsSource(skills); + + // Act + var result = await source.GetSkillsAsync(CancellationToken.None); + + // Assert + Assert.Equal(2, result.Count); + Assert.Equal("minimal", result[0].Frontmatter.Name); + Assert.Equal("full", result[1].Frontmatter.Name); + } + + [Fact] + public void AgentClassSkill_InvalidFrontmatter_ThrowsArgumentException() + { + // Act & Assert + Assert.Throws(() => new AgentSkillFrontmatter("INVALID-NAME", "An invalid skill.")); + } + + [Fact] + public void SkillWithOnlyResources_HasNullScripts() + { + // Arrange + var skill = new ResourceOnlySkill(); + + // Act & Assert + Assert.Single(skill.Resources!); + Assert.Null(skill.Scripts); + } + + [Fact] + public void SkillWithOnlyScripts_HasNullResources() + { + // Arrange + var skill = new ScriptOnlySkill(); + + // Act & Assert + Assert.Null(skill.Resources); + Assert.Single(skill.Scripts!); + } + + [Fact] + public void Content_ReturnsCachedInstance_OnRepeatedAccess() + { + // Arrange + var skill = new FullClassSkill(); + + // Act + var first = skill.Content; + var second = skill.Content; + + // Assert + Assert.Same(first, second); + } + + [Fact] + public void Content_IncludesParametersSchema_WhenScriptsHaveParameters() + { + // Arrange + var skill = new FullClassSkill(); + + // Act + var content = skill.Content; + + // Assert — scripts with typed parameters should have their schema included + Assert.Contains("parameters_schema", content); + Assert.Contains("value", content); + } + + [Fact] + public void Content_IncludesDerivedResources_WhenResourcesUseBaseTypeOverrides() + { + // Arrange + var skill = new DerivedResourceSkill(); + + // Act + var content = skill.Content; + + // Assert + Assert.Contains("", content); + Assert.Contains("custom-resource", content); + Assert.Contains("Custom resource description.", content); + } + + [Fact] + public void Content_IncludesDerivedScripts_WhenScriptsUseBaseTypeOverrides() + { + // Arrange + var skill = new DerivedScriptSkill(); + + // Act + var content = skill.Content; + + // Assert + Assert.Contains("", content); + Assert.Contains("custom-script", content); + Assert.Contains("Custom script description.", content); + } + + [Fact] + public void Content_OmitsParametersSchema_WhenDerivedScriptDoesNotProvideOne() + { + // Arrange + var skill = new DerivedScriptSkill(); + + // Act + var content = skill.Content; + + // Assert + Assert.DoesNotContain("parameters_schema", content); + } + + #region Test skill classes + + private sealed class MinimalClassSkill : AgentClassSkill + { + public override AgentSkillFrontmatter Frontmatter { get; } = new("minimal", "A minimal skill."); + + protected override string Instructions => "Minimal skill body."; + + public override IReadOnlyList? Resources => null; + + public override IReadOnlyList? Scripts => null; + } + + private sealed class FullClassSkill : AgentClassSkill + { + private IReadOnlyList? _resources; + private IReadOnlyList? _scripts; + + public override AgentSkillFrontmatter Frontmatter { get; } = new("full", "A full skill with resources and scripts."); + + protected override string Instructions => "Full skill body."; + + public override IReadOnlyList? Resources => this._resources ??= + [ + CreateResource("test-resource", "resource content"), + ]; + + public override IReadOnlyList? Scripts => this._scripts ??= + [ + CreateScript("TestScript", TestScript), + ]; + + private static string TestScript(double value) => + JsonSerializer.Serialize(new { result = value * 2 }); + } + + private sealed class ResourceOnlySkill : AgentClassSkill + { + private IReadOnlyList? _resources; + + public override AgentSkillFrontmatter Frontmatter { get; } = new("resource-only", "Skill with resources only."); + + protected override string Instructions => "Body."; + + public override IReadOnlyList? Resources => this._resources ??= + [ + CreateResource("data", "some data"), + ]; + + public override IReadOnlyList? Scripts => null; + } + + private sealed class ScriptOnlySkill : AgentClassSkill + { + private IReadOnlyList? _scripts; + + public override AgentSkillFrontmatter Frontmatter { get; } = new("script-only", "Skill with scripts only."); + + protected override string Instructions => "Body."; + + public override IReadOnlyList? Resources => null; + + public override IReadOnlyList? Scripts => this._scripts ??= + [ + CreateScript("ToUpper", (string input) => input.ToUpperInvariant()), + ]; + } + + private sealed class DerivedResourceSkill : AgentClassSkill + { + private IReadOnlyList? _resources; + + public override AgentSkillFrontmatter Frontmatter { get; } = new("derived-resource", "Skill with a derived resource type."); + + protected override string Instructions => "Body."; + + public override IReadOnlyList? Resources => this._resources ??= + [ + new CustomResource("custom-resource", "Custom resource description."), + ]; + + public override IReadOnlyList? Scripts => null; + } + + private sealed class DerivedScriptSkill : AgentClassSkill + { + private IReadOnlyList? _scripts; + + public override AgentSkillFrontmatter Frontmatter { get; } = new("derived-script", "Skill with a derived script type."); + + protected override string Instructions => "Body."; + + public override IReadOnlyList? Resources => null; + + public override IReadOnlyList? Scripts => this._scripts ??= + [ + new CustomScript("custom-script", "Custom script description."), + ]; + } + + private sealed class LazyLoadedSkill : AgentClassSkill + { + private IReadOnlyList? _resources; + private IReadOnlyList? _scripts; + + public override AgentSkillFrontmatter Frontmatter { get; } = new("lazy-loaded", "Skill with lazily created resources and scripts."); + + protected override string Instructions => "Body."; + + public int ResourceCreationCount { get; private set; } + + public int ScriptCreationCount { get; private set; } + + public override IReadOnlyList? Resources => this._resources ??= this.CreateResources(); + + public override IReadOnlyList? Scripts => this._scripts ??= this.CreateScripts(); + + private IReadOnlyList CreateResources() + { + this.ResourceCreationCount++; + return [CreateResource("lazy-resource", "resource content")]; + } + + private IReadOnlyList CreateScripts() + { + this.ScriptCreationCount++; + return [CreateScript("LazyScript", () => "done")]; + } + } + + private sealed class CustomResource : AgentSkillResource + { + public CustomResource(string name, string? description = null) + : base(name, description) + { + } + + public override Task ReadAsync(IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default) + => Task.FromResult("resource-value"); + } + + private sealed class CustomScript : AgentSkillScript + { + public CustomScript(string name, string? description = null) + : base(name, description) + { + } + + public override Task RunAsync(AgentSkill skill, Extensions.AI.AIFunctionArguments arguments, CancellationToken cancellationToken = default) + => Task.FromResult("script-result"); + } + + #endregion +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderTests.cs index 87e98b3da3e..e86eb0894a2 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderTests.cs @@ -851,6 +851,61 @@ public async Task Constructor_InlineSkills_DeduplicatesAsync() Assert.Contains("First instructions.", content!.ToString()!); } + [Fact] + public async Task Constructor_ClassSkillsParams_ProvidesSkillsAsync() + { + // Arrange + var skill = new TestClassSkill("class-a", "Class A", "Class instructions."); + var provider = new AgentSkillsProvider(skill); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert + Assert.NotNull(result.Instructions); + Assert.Contains("class-a", result.Instructions); + } + + [Fact] + public async Task Constructor_ClassSkillsEnumerable_ProvidesSkillsAsync() + { + // Arrange + var skills = new List + { + new TestClassSkill("enum-class-a", "Class A", "Instructions A."), + new TestClassSkill("enum-class-b", "Class B", "Instructions B."), + }; + var provider = new AgentSkillsProvider(skills); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert + Assert.NotNull(result.Instructions); + Assert.Contains("enum-class-a", result.Instructions); + Assert.Contains("enum-class-b", result.Instructions); + } + + [Fact] + public async Task Constructor_ClassSkills_DeduplicatesAsync() + { + // Arrange — two class skills with the same name + var skill1 = new TestClassSkill("dup-class", "First", "First instructions."); + var skill2 = new TestClassSkill("dup-class", "Second", "Second instructions."); + var provider = new AgentSkillsProvider(skill1, skill2); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + var loadSkillTool = result.Tools!.First(t => t.Name == "load_skill") as AIFunction; + var content = await loadSkillTool!.InvokeAsync(new AIFunctionArguments(new Dictionary { ["skillName"] = "dup-class" })); + + // Assert — only first occurrence survives + Assert.Contains("First instructions.", content!.ToString()!); + } + /// /// A test skill source that counts how many times is called. /// @@ -872,4 +927,23 @@ public override Task> GetSkillsAsync(CancellationToken cancell return Task.FromResult(this._skills); } } + + private sealed class TestClassSkill : AgentClassSkill + { + private readonly string _instructions; + + public TestClassSkill(string name, string description, string instructions) + { + this.Frontmatter = new AgentSkillFrontmatter(name, description); + this._instructions = instructions; + } + + public override AgentSkillFrontmatter Frontmatter { get; } + + protected override string Instructions => this._instructions; + + public override IReadOnlyList? Resources => null; + + public override IReadOnlyList? Scripts => null; + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/DeduplicatingAgentSkillsSourceTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/DeduplicatingAgentSkillsSourceTests.cs index 78950236812..0ee6af9b3bc 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/DeduplicatingAgentSkillsSourceTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/DeduplicatingAgentSkillsSourceTests.cs @@ -13,7 +13,7 @@ namespace Microsoft.Agents.AI.UnitTests.AgentSkills; public sealed class DeduplicatingAgentSkillsSourceTests { [Fact] - public async Task GetSkillsAsync_NoDuplicates_ReturnsAllSkillsAsync() + public async Task GetSkillsAsync_NoDuplicates_ReturnsAllSkills() { // Arrange var inner = new AgentInMemorySkillsSource(new AgentSkill[] @@ -31,7 +31,7 @@ public async Task GetSkillsAsync_NoDuplicates_ReturnsAllSkillsAsync() } [Fact] - public async Task GetSkillsAsync_WithDuplicates_KeepsFirstOccurrenceAsync() + public async Task GetSkillsAsync_WithDuplicates_KeepsFirstOccurrence() { // Arrange var skills = new AgentSkill[] @@ -53,7 +53,7 @@ public async Task GetSkillsAsync_WithDuplicates_KeepsFirstOccurrenceAsync() } [Fact] - public async Task GetSkillsAsync_CaseInsensitiveDuplication_KeepsFirstAsync() + public async Task GetSkillsAsync_CaseInsensitiveDuplication_KeepsFirst() { // Arrange - Use a custom source that returns skills with same name but different casing var inner = new FakeDuplicateCaseSource(); @@ -68,7 +68,7 @@ public async Task GetSkillsAsync_CaseInsensitiveDuplication_KeepsFirstAsync() } [Fact] - public async Task GetSkillsAsync_EmptySource_ReturnsEmptyAsync() + public async Task GetSkillsAsync_EmptySource_ReturnsEmpty() { // Arrange var inner = new AgentInMemorySkillsSource(System.Array.Empty()); diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FilteringAgentSkillsSourceTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FilteringAgentSkillsSourceTests.cs index 12bdb28e05c..b24ef2e1b1a 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FilteringAgentSkillsSourceTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FilteringAgentSkillsSourceTests.cs @@ -12,7 +12,7 @@ namespace Microsoft.Agents.AI.UnitTests.AgentSkills; public sealed class FilteringAgentSkillsSourceTests { [Fact] - public async Task GetSkillsAsync_PredicateIncludesAll_ReturnsAllSkillsAsync() + public async Task GetSkillsAsync_PredicateIncludesAll_ReturnsAllSkills() { // Arrange var inner = new AgentInMemorySkillsSource(new AgentSkill[] @@ -30,7 +30,7 @@ public async Task GetSkillsAsync_PredicateIncludesAll_ReturnsAllSkillsAsync() } [Fact] - public async Task GetSkillsAsync_PredicateExcludesAll_ReturnsEmptyAsync() + public async Task GetSkillsAsync_PredicateExcludesAll_ReturnsEmpty() { // Arrange var inner = new AgentInMemorySkillsSource(new AgentSkill[] @@ -48,7 +48,7 @@ public async Task GetSkillsAsync_PredicateExcludesAll_ReturnsEmptyAsync() } [Fact] - public async Task GetSkillsAsync_PartialFilter_ReturnsMatchingSkillsOnlyAsync() + public async Task GetSkillsAsync_PartialFilter_ReturnsMatchingSkillsOnly() { // Arrange var inner = new AgentInMemorySkillsSource(new AgentSkill[] @@ -70,7 +70,7 @@ public async Task GetSkillsAsync_PartialFilter_ReturnsMatchingSkillsOnlyAsync() } [Fact] - public async Task GetSkillsAsync_EmptySource_ReturnsEmptyAsync() + public async Task GetSkillsAsync_EmptySource_ReturnsEmpty() { // Arrange var inner = new AgentInMemorySkillsSource(Array.Empty()); @@ -101,7 +101,7 @@ public void Constructor_NullInnerSource_Throws() } [Fact] - public async Task GetSkillsAsync_PreservesOrderAsync() + public async Task GetSkillsAsync_PreservesOrder() { // Arrange var inner = new AgentInMemorySkillsSource(new AgentSkill[] From 90932309d4053af6c4a993d4bbfacd8859110b43 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Date: Wed, 1 Apr 2026 12:34:22 +0000 Subject: [PATCH 02/10] address formating issues --- dotnet/AGENTS.md | 2 +- .../AgentSkills/AgentClassSkillTests.cs | 2 +- .../AgentSkills/DeduplicatingAgentSkillsSourceTests.cs | 8 ++++---- .../AgentSkills/FilteringAgentSkillsSourceTests.cs | 10 +++++----- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/dotnet/AGENTS.md b/dotnet/AGENTS.md index 4cb4b67e5fc..50f10c3e693 100644 --- a/dotnet/AGENTS.md +++ b/dotnet/AGENTS.md @@ -35,7 +35,7 @@ using types like `IChatClient`, `FunctionInvokingChatClient`, `AITool`, `AIFunct - **Async**: Use `Async` suffix for methods returning `Task`/`ValueTask` - **Private classes**: Should be `sealed` unless subclassed - **Config**: Read from environment variables with `UPPER_SNAKE_CASE` naming -- **Tests**: Add Arrange/Act/Assert comments; use Moq for mocking +- **Tests**: Add Arrange/Act/Assert comments; use Moq for mocking; test methods returning `Task`/`ValueTask` must use the `Async` suffix. ## Key Design Principles diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentClassSkillTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentClassSkillTests.cs index cc706111602..2c453f21b66 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentClassSkillTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentClassSkillTests.cs @@ -111,7 +111,7 @@ public void Content_ReturnsSynthesizedXmlDocument() } [Fact] - public async Task AgentInMemorySkillsSource_ReturnsAllSkills() + public async Task AgentInMemorySkillsSource_ReturnsAllSkillsAsync() { // Arrange var skills = new AgentClassSkill[] { new MinimalClassSkill(), new FullClassSkill() }; diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/DeduplicatingAgentSkillsSourceTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/DeduplicatingAgentSkillsSourceTests.cs index 0ee6af9b3bc..78950236812 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/DeduplicatingAgentSkillsSourceTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/DeduplicatingAgentSkillsSourceTests.cs @@ -13,7 +13,7 @@ namespace Microsoft.Agents.AI.UnitTests.AgentSkills; public sealed class DeduplicatingAgentSkillsSourceTests { [Fact] - public async Task GetSkillsAsync_NoDuplicates_ReturnsAllSkills() + public async Task GetSkillsAsync_NoDuplicates_ReturnsAllSkillsAsync() { // Arrange var inner = new AgentInMemorySkillsSource(new AgentSkill[] @@ -31,7 +31,7 @@ public async Task GetSkillsAsync_NoDuplicates_ReturnsAllSkills() } [Fact] - public async Task GetSkillsAsync_WithDuplicates_KeepsFirstOccurrence() + public async Task GetSkillsAsync_WithDuplicates_KeepsFirstOccurrenceAsync() { // Arrange var skills = new AgentSkill[] @@ -53,7 +53,7 @@ public async Task GetSkillsAsync_WithDuplicates_KeepsFirstOccurrence() } [Fact] - public async Task GetSkillsAsync_CaseInsensitiveDuplication_KeepsFirst() + public async Task GetSkillsAsync_CaseInsensitiveDuplication_KeepsFirstAsync() { // Arrange - Use a custom source that returns skills with same name but different casing var inner = new FakeDuplicateCaseSource(); @@ -68,7 +68,7 @@ public async Task GetSkillsAsync_CaseInsensitiveDuplication_KeepsFirst() } [Fact] - public async Task GetSkillsAsync_EmptySource_ReturnsEmpty() + public async Task GetSkillsAsync_EmptySource_ReturnsEmptyAsync() { // Arrange var inner = new AgentInMemorySkillsSource(System.Array.Empty()); diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FilteringAgentSkillsSourceTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FilteringAgentSkillsSourceTests.cs index b24ef2e1b1a..12bdb28e05c 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FilteringAgentSkillsSourceTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FilteringAgentSkillsSourceTests.cs @@ -12,7 +12,7 @@ namespace Microsoft.Agents.AI.UnitTests.AgentSkills; public sealed class FilteringAgentSkillsSourceTests { [Fact] - public async Task GetSkillsAsync_PredicateIncludesAll_ReturnsAllSkills() + public async Task GetSkillsAsync_PredicateIncludesAll_ReturnsAllSkillsAsync() { // Arrange var inner = new AgentInMemorySkillsSource(new AgentSkill[] @@ -30,7 +30,7 @@ public async Task GetSkillsAsync_PredicateIncludesAll_ReturnsAllSkills() } [Fact] - public async Task GetSkillsAsync_PredicateExcludesAll_ReturnsEmpty() + public async Task GetSkillsAsync_PredicateExcludesAll_ReturnsEmptyAsync() { // Arrange var inner = new AgentInMemorySkillsSource(new AgentSkill[] @@ -48,7 +48,7 @@ public async Task GetSkillsAsync_PredicateExcludesAll_ReturnsEmpty() } [Fact] - public async Task GetSkillsAsync_PartialFilter_ReturnsMatchingSkillsOnly() + public async Task GetSkillsAsync_PartialFilter_ReturnsMatchingSkillsOnlyAsync() { // Arrange var inner = new AgentInMemorySkillsSource(new AgentSkill[] @@ -70,7 +70,7 @@ public async Task GetSkillsAsync_PartialFilter_ReturnsMatchingSkillsOnly() } [Fact] - public async Task GetSkillsAsync_EmptySource_ReturnsEmpty() + public async Task GetSkillsAsync_EmptySource_ReturnsEmptyAsync() { // Arrange var inner = new AgentInMemorySkillsSource(Array.Empty()); @@ -101,7 +101,7 @@ public void Constructor_NullInnerSource_Throws() } [Fact] - public async Task GetSkillsAsync_PreservesOrder() + public async Task GetSkillsAsync_PreservesOrderAsync() { // Arrange var inner = new AgentInMemorySkillsSource(new AgentSkill[] From 1cff7bffb61c85d0d127383b5672289dc6bebb0e Mon Sep 17 00:00:00 2001 From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Date: Wed, 1 Apr 2026 14:58:11 +0000 Subject: [PATCH 03/10] Remove generated filtered-unit.slnx and add to .gitignore The filtered solution file is generated dynamically by eng/scripts/New-FilteredSolution.ps1 during CI. Checking it in risks it becoming stale and out-of-sync with the real solution. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/filtered-unit.slnx | 342 -------------------------------------- 1 file changed, 342 deletions(-) delete mode 100644 dotnet/filtered-unit.slnx diff --git a/dotnet/filtered-unit.slnx b/dotnet/filtered-unit.slnx deleted file mode 100644 index 1a3ad7e6b20..00000000000 --- a/dotnet/filtered-unit.slnx +++ /dev/null @@ -1,342 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file From 78863498178330a3d19b3170778c97b63ce813e8 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Date: Wed, 1 Apr 2026 14:58:11 +0000 Subject: [PATCH 04/10] Remove generated filtered-unit.slnx and add to .gitignore The filtered solution file is generated dynamically by eng/scripts/New-FilteredSolution.ps1 during CI. Checking it in risks it becoming stale and out-of-sync with the real solution. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitignore | 3 + dotnet/filtered-unit.slnx | 342 -------------------------------------- 2 files changed, 3 insertions(+), 342 deletions(-) delete mode 100644 dotnet/filtered-unit.slnx diff --git a/.gitignore b/.gitignore index 4dd5848e894..089abb53952 100644 --- a/.gitignore +++ b/.gitignore @@ -230,3 +230,6 @@ local.settings.json # Database files *.db python/dotnet-ref + +# Generated filtered solution files (created by eng/scripts/New-FilteredSolution.ps1) +dotnet/filtered-*.slnx diff --git a/dotnet/filtered-unit.slnx b/dotnet/filtered-unit.slnx deleted file mode 100644 index 1a3ad7e6b20..00000000000 --- a/dotnet/filtered-unit.slnx +++ /dev/null @@ -1,342 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file From d9158aeeedcdaefc1dd856d52eaeb7772e4efd54 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Date: Thu, 2 Apr 2026 11:50:14 +0000 Subject: [PATCH 05/10] discover scripts and resource from folders defined in spec --- .../Skills/File/AgentFileSkillsSource.cs | 246 ++++++++++++------ .../File/AgentFileSkillsSourceOptions.cs | 18 ++ .../AgentFileSkillsSourceScriptTests.cs | 52 +++- .../AgentSkills/FileAgentSkillLoaderTests.cs | 246 +++++++++++++++--- 4 files changed, 435 insertions(+), 127 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSource.cs b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSource.cs index c6dc3bc6294..3795e90ca43 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSource.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSource.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.IO; +using System.Linq; using System.Text; using System.Text.RegularExpressions; using System.Threading; @@ -31,9 +32,16 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource private const string SkillFileName = "SKILL.md"; private const int MaxSearchDepth = 2; + // "." means the skill directory root itself (no sub-folder descent constraint) + private const string RootFolderIndicator = "."; + private static readonly string[] s_defaultScriptExtensions = [".py", ".js", ".sh", ".ps1", ".cs", ".csx"]; private static readonly string[] s_defaultResourceExtensions = [".md", ".json", ".yaml", ".yml", ".csv", ".xml", ".txt"]; + // Standard sub-folder names per https://agentskills.io/specification#directory-structure + private static readonly string[] s_defaultScriptFolders = ["scripts"]; + private static readonly string[] s_defaultResourceFolders = ["references", "assets"]; + // Matches YAML frontmatter delimited by "---" lines. Group 1 = content between delimiters. // Multiline makes ^/$ match line boundaries; Singleline makes . match newlines across the block. // The \uFEFF? prefix allows an optional UTF-8 BOM that some editors prepend. @@ -55,6 +63,8 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource private readonly IEnumerable _skillPaths; private readonly HashSet _allowedResourceExtensions; private readonly HashSet _allowedScriptExtensions; + private readonly IReadOnlyList _scriptFolders; + private readonly IReadOnlyList _resourceFolders; private readonly AgentFileSkillScriptRunner? _scriptRunner; private readonly ILogger _logger; @@ -88,6 +98,7 @@ public AgentFileSkillsSource( ILoggerFactory? loggerFactory = null) { this._skillPaths = Throw.IfNull(skillPaths); + this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger(); var resolvedOptions = options ?? new AgentFileSkillsSourceOptions(); @@ -102,8 +113,15 @@ public AgentFileSkillsSource( resolvedOptions.AllowedScriptExtensions ?? s_defaultScriptExtensions, StringComparer.OrdinalIgnoreCase); + this._scriptFolders = resolvedOptions.ScriptFolders is not null + ? [.. FilterValidFolderNames(resolvedOptions.ScriptFolders, this._logger)] + : s_defaultScriptFolders; + + this._resourceFolders = resolvedOptions.ResourceFolders is not null + ? [.. FilterValidFolderNames(resolvedOptions.ResourceFolders, this._logger)] + : s_defaultResourceFolders; + this._scriptRunner = scriptRunner; - this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger(); } /// @@ -282,147 +300,175 @@ private bool TryParseFrontmatter(string content, string skillFilePath, [NotNullW } /// - /// Scans a skill directory for resource files matching the configured extensions. + /// Scans configured resource folders within a skill directory for resource files matching the configured extensions. /// /// - /// Recursively walks and collects files whose extension - /// matches the allowed set, excluding SKILL.md itself. Each candidate - /// is validated against path-traversal and symlink-escape checks; unsafe files are skipped with - /// a warning. + /// By default, scans references/ and assets/ sub-folders as specified by the + /// Agent Skills specification. + /// Configure to scan different or + /// additional directories, including "." for the skill root itself. + /// Each file is validated against path-traversal and symlink-escape checks; unsafe files are skipped. /// private List DiscoverResourceFiles(string skillDirectoryFullPath, string skillName) { string normalizedSkillDirectoryFullPath = skillDirectoryFullPath + Path.DirectorySeparatorChar; - var resources = new List(); -#if NET - var enumerationOptions = new EnumerationOptions - { - RecurseSubdirectories = true, - IgnoreInaccessible = true, - AttributesToSkip = FileAttributes.ReparsePoint, - }; - - foreach (string filePath in Directory.EnumerateFiles(skillDirectoryFullPath, "*", enumerationOptions)) -#else - foreach (string filePath in Directory.EnumerateFiles(skillDirectoryFullPath, "*", SearchOption.AllDirectories)) -#endif + foreach (string folder in this._resourceFolders.Distinct(StringComparer.OrdinalIgnoreCase)) { - string fileName = Path.GetFileName(filePath); + string targetDirectory = string.Equals(folder, RootFolderIndicator, StringComparison.Ordinal) + ? skillDirectoryFullPath + : Path.Combine(skillDirectoryFullPath, folder); - // Exclude SKILL.md itself - if (string.Equals(fileName, SkillFileName, StringComparison.OrdinalIgnoreCase)) + if (!Directory.Exists(targetDirectory)) { continue; } - // Filter by extension - string extension = Path.GetExtension(filePath); - if (string.IsNullOrEmpty(extension) || !this._allowedResourceExtensions.Contains(extension)) +#if NET + var enumerationOptions = new EnumerationOptions + { + RecurseSubdirectories = false, + IgnoreInaccessible = true, + AttributesToSkip = FileAttributes.ReparsePoint, + }; + + foreach (string filePath in Directory.EnumerateFiles(targetDirectory, "*", enumerationOptions)) +#else + foreach (string filePath in Directory.EnumerateFiles(targetDirectory, "*", SearchOption.TopDirectoryOnly)) +#endif { - if (this._logger.IsEnabled(LogLevel.Debug)) + string fileName = Path.GetFileName(filePath); + + // Exclude SKILL.md itself + if (string.Equals(fileName, SkillFileName, StringComparison.OrdinalIgnoreCase)) { - LogResourceSkippedExtension(this._logger, skillName, SanitizePathForLog(filePath), extension); + continue; } - continue; - } + // Filter by extension + string extension = Path.GetExtension(filePath); + if (string.IsNullOrEmpty(extension) || !this._allowedResourceExtensions.Contains(extension)) + { + if (this._logger.IsEnabled(LogLevel.Debug)) + { + LogResourceSkippedExtension(this._logger, skillName, SanitizePathForLog(filePath), extension); + } - // Normalize the enumerated path to guard against non-canonical forms - string resolvedFilePath = Path.GetFullPath(filePath); + continue; + } - // Path containment check - if (!resolvedFilePath.StartsWith(normalizedSkillDirectoryFullPath, StringComparison.OrdinalIgnoreCase)) - { - if (this._logger.IsEnabled(LogLevel.Warning)) + // Normalize the enumerated path to guard against non-canonical forms + string resolvedFilePath = Path.GetFullPath(filePath); + + // Path containment check + if (!resolvedFilePath.StartsWith(normalizedSkillDirectoryFullPath, StringComparison.OrdinalIgnoreCase)) { - LogResourcePathTraversal(this._logger, skillName, SanitizePathForLog(filePath)); - } + if (this._logger.IsEnabled(LogLevel.Warning)) + { + LogResourcePathTraversal(this._logger, skillName, SanitizePathForLog(filePath)); + } - continue; - } + continue; + } - // Symlink check - if (HasSymlinkInPath(resolvedFilePath, normalizedSkillDirectoryFullPath)) - { - if (this._logger.IsEnabled(LogLevel.Warning)) + // Symlink check + if (HasSymlinkInPath(resolvedFilePath, normalizedSkillDirectoryFullPath)) { - LogResourceSymlinkEscape(this._logger, skillName, SanitizePathForLog(filePath)); + if (this._logger.IsEnabled(LogLevel.Warning)) + { + LogResourceSymlinkEscape(this._logger, skillName, SanitizePathForLog(filePath)); + } + + continue; } - continue; - } + // Compute relative path and normalize to forward slashes + string relativePath = NormalizePath(resolvedFilePath.Substring(normalizedSkillDirectoryFullPath.Length)); - // Compute relative path and normalize to forward slashes - string relativePath = NormalizePath(resolvedFilePath.Substring(normalizedSkillDirectoryFullPath.Length)); - resources.Add(new AgentFileSkillResource(relativePath, resolvedFilePath)); + resources.Add(new AgentFileSkillResource(relativePath, resolvedFilePath)); + } } return resources; } /// - /// Scans a skill directory for script files matching the configured extensions. + /// Scans configured script folders within a skill directory for script files matching the configured extensions. /// /// - /// Recursively walks the skill directory and collects files whose extension - /// matches the allowed set. Each candidate is validated against path-traversal - /// and symlink-escape checks; unsafe files are skipped with a warning. + /// By default, scans the scripts/ sub-folder as specified by the + /// Agent Skills specification. + /// Configure to scan different or + /// additional directories, including "." for the skill root itself. + /// Each file is validated against path-traversal and symlink-escape checks; unsafe files are skipped. /// private List DiscoverScriptFiles(string skillDirectoryFullPath, string skillName) { string normalizedSkillDirectoryFullPath = skillDirectoryFullPath + Path.DirectorySeparatorChar; var scripts = new List(); -#if NET - var enumerationOptions = new EnumerationOptions + foreach (string folder in this._scriptFolders.Distinct(StringComparer.OrdinalIgnoreCase)) { - RecurseSubdirectories = true, - IgnoreInaccessible = true, - AttributesToSkip = FileAttributes.ReparsePoint, - }; + string targetDirectory = string.Equals(folder, RootFolderIndicator, StringComparison.Ordinal) + ? skillDirectoryFullPath + : Path.Combine(skillDirectoryFullPath, folder); - foreach (string filePath in Directory.EnumerateFiles(skillDirectoryFullPath, "*", enumerationOptions)) -#else - foreach (string filePath in Directory.EnumerateFiles(skillDirectoryFullPath, "*", SearchOption.AllDirectories)) -#endif - { - // Filter by extension - string extension = Path.GetExtension(filePath); - if (string.IsNullOrEmpty(extension) || !this._allowedScriptExtensions.Contains(extension)) + if (!Directory.Exists(targetDirectory)) { continue; } - // Normalize the enumerated path to guard against non-canonical forms - string resolvedFilePath = Path.GetFullPath(filePath); +#if NET + var enumerationOptions = new EnumerationOptions + { + RecurseSubdirectories = false, + IgnoreInaccessible = true, + AttributesToSkip = FileAttributes.ReparsePoint, + }; - // Path containment check - if (!resolvedFilePath.StartsWith(normalizedSkillDirectoryFullPath, StringComparison.OrdinalIgnoreCase)) + foreach (string filePath in Directory.EnumerateFiles(targetDirectory, "*", enumerationOptions)) +#else + foreach (string filePath in Directory.EnumerateFiles(targetDirectory, "*", SearchOption.TopDirectoryOnly)) +#endif { - if (this._logger.IsEnabled(LogLevel.Warning)) + // Filter by extension + string extension = Path.GetExtension(filePath); + if (string.IsNullOrEmpty(extension) || !this._allowedScriptExtensions.Contains(extension)) { - LogScriptPathTraversal(this._logger, skillName, SanitizePathForLog(filePath)); + continue; } - continue; - } + // Normalize the enumerated path to guard against non-canonical forms + string resolvedFilePath = Path.GetFullPath(filePath); - // Symlink check - if (HasSymlinkInPath(resolvedFilePath, normalizedSkillDirectoryFullPath)) - { - if (this._logger.IsEnabled(LogLevel.Warning)) + // Path containment check + if (!resolvedFilePath.StartsWith(normalizedSkillDirectoryFullPath, StringComparison.OrdinalIgnoreCase)) { - LogScriptSymlinkEscape(this._logger, skillName, SanitizePathForLog(filePath)); + if (this._logger.IsEnabled(LogLevel.Warning)) + { + LogScriptPathTraversal(this._logger, skillName, SanitizePathForLog(filePath)); + } + + continue; } - continue; - } + // Symlink check + if (HasSymlinkInPath(resolvedFilePath, normalizedSkillDirectoryFullPath)) + { + if (this._logger.IsEnabled(LogLevel.Warning)) + { + LogScriptSymlinkEscape(this._logger, skillName, SanitizePathForLog(filePath)); + } - // Compute relative path and normalize to forward slashes - string relativePath = NormalizePath(resolvedFilePath.Substring(normalizedSkillDirectoryFullPath.Length)); - scripts.Add(new AgentFileSkillScript(relativePath, resolvedFilePath, this._scriptRunner)); + continue; + } + + // Compute relative path and normalize to forward slashes + string relativePath = NormalizePath(resolvedFilePath.Substring(normalizedSkillDirectoryFullPath.Length)); + + scripts.Add(new AgentFileSkillScript(relativePath, resolvedFilePath, this._scriptRunner)); + } } return scripts; @@ -508,6 +554,33 @@ private static void ValidateExtensions(IEnumerable? extensions) } } + private static IEnumerable FilterValidFolderNames(IEnumerable folders, ILogger logger) + { + foreach (string folder in folders) + { + if (string.IsNullOrWhiteSpace(folder)) + { + throw new ArgumentException("Folder names must not be null or whitespace.", nameof(folders)); + } + + // "." is valid — it means the skill root directory. + if (string.Equals(folder, RootFolderIndicator, StringComparison.Ordinal)) + { + yield return folder; + continue; + } + + // Reject absolute paths and any path segments that escape upward. + if (Path.IsPathRooted(folder) || folder.Contains("..", StringComparison.Ordinal)) + { + LogFolderNameSkippedInvalid(logger, folder); + continue; + } + + yield return folder; + } + } + [LoggerMessage(LogLevel.Information, "Discovered {Count} potential skills")] private static partial void LogSkillsDiscovered(ILogger logger, int count); @@ -540,4 +613,7 @@ private static void ValidateExtensions(IEnumerable? extensions) [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 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 edaec327faf..7115b4164aa 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSourceOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSourceOptions.cs @@ -30,4 +30,22 @@ public sealed class AgentFileSkillsSourceOptions /// .ps1, .cs, .csx. /// 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)