diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx
index f01cd409d6..adc941d582 100644
--- a/dotnet/agent-framework-dotnet.slnx
+++ b/dotnet/agent-framework-dotnet.slnx
@@ -96,6 +96,10 @@
+
+
+
+
diff --git a/dotnet/samples/GettingStarted/AgentSkills/Agent_Step01_BasicSkills/Agent_Step01_BasicSkills.csproj b/dotnet/samples/GettingStarted/AgentSkills/Agent_Step01_BasicSkills/Agent_Step01_BasicSkills.csproj
new file mode 100644
index 0000000000..2a503bbfb2
--- /dev/null
+++ b/dotnet/samples/GettingStarted/AgentSkills/Agent_Step01_BasicSkills/Agent_Step01_BasicSkills.csproj
@@ -0,0 +1,28 @@
+
+
+
+ Exe
+ net10.0
+
+ enable
+ enable
+ $(NoWarn);MAAI001
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ PreserveNewest
+
+
+
+
diff --git a/dotnet/samples/GettingStarted/AgentSkills/Agent_Step01_BasicSkills/Program.cs b/dotnet/samples/GettingStarted/AgentSkills/Agent_Step01_BasicSkills/Program.cs
new file mode 100644
index 0000000000..290c3f9b6b
--- /dev/null
+++ b/dotnet/samples/GettingStarted/AgentSkills/Agent_Step01_BasicSkills/Program.cs
@@ -0,0 +1,49 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+// This sample demonstrates how to use Agent Skills with a ChatClientAgent.
+// Agent Skills are modular packages of instructions and resources that extend an agent's capabilities.
+// Skills follow the progressive disclosure pattern: advertise -> load -> read resources.
+//
+// This sample includes the expense-report skill:
+// - Policy-based expense filing with references and assets
+
+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";
+
+// --- Skills Provider ---
+// Discovers skills from the 'skills' directory and makes them available to the agent
+var skillsProvider = new FileAgentSkillsProvider(skillPath: Path.Combine(AppContext.BaseDirectory, "skills"));
+
+// --- Agent Setup ---
+AIAgent agent = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential())
+ .GetResponsesClient(deploymentName)
+ .AsAIAgent(new ChatClientAgentOptions
+ {
+ Name = "SkillsAgent",
+ ChatOptions = new()
+ {
+ Instructions = "You are a helpful assistant.",
+ },
+ AIContextProviders = [skillsProvider],
+ });
+
+// --- Example 1: Expense policy question (loads FAQ resource) ---
+Console.WriteLine("Example 1: Checking expense policy FAQ");
+Console.WriteLine("---------------------------------------");
+AgentResponse response1 = await agent.RunAsync("Are tips reimbursable? I left a 25% tip on a taxi ride and want to know if that's covered.");
+Console.WriteLine($"Agent: {response1.Text}\n");
+
+// --- Example 2: Filing an expense report (multi-turn with template asset) ---
+Console.WriteLine("Example 2: Filing an expense report");
+Console.WriteLine("---------------------------------------");
+AgentSession session = await agent.CreateSessionAsync();
+AgentResponse response2 = await agent.RunAsync("I had 3 client dinners and a $1,200 flight last week. Return a draft expense report and ask about any missing details.",
+ session);
+Console.WriteLine($"Agent: {response2.Text}\n");
diff --git a/dotnet/samples/GettingStarted/AgentSkills/Agent_Step01_BasicSkills/README.md b/dotnet/samples/GettingStarted/AgentSkills/Agent_Step01_BasicSkills/README.md
new file mode 100644
index 0000000000..78099fa8a5
--- /dev/null
+++ b/dotnet/samples/GettingStarted/AgentSkills/Agent_Step01_BasicSkills/README.md
@@ -0,0 +1,63 @@
+# Agent Skills Sample
+
+This sample demonstrates how to use **Agent Skills** with a `ChatClientAgent` in the Microsoft Agent Framework.
+
+## What are Agent Skills?
+
+Agent Skills are modular packages of instructions and resources that enable AI agents to perform specialized tasks. They follow the [Agent Skills specification](https://agentskills.io/) and implement the progressive disclosure pattern:
+
+1. **Advertise**: Skills are advertised with name + description (~100 tokens per skill)
+2. **Load**: Full instructions are loaded on-demand via `load_skill` tool
+3. **Resources**: References and other files loaded via `read_skill_resource` tool
+
+## Skills Included
+
+### expense-report
+Policy-based expense filing with spending limits, receipt requirements, and approval workflows.
+- `references/POLICY_FAQ.md` — Detailed expense policy Q&A
+- `assets/expense-report-template.md` — Submission template
+
+## Project Structure
+
+```
+Agent_Step01_BasicSkills/
+├── Program.cs
+├── Agent_Step01_BasicSkills.csproj
+└── skills/
+ └── expense-report/
+ ├── SKILL.md
+ ├── references/
+ │ └── POLICY_FAQ.md
+ └── assets/
+ └── expense-report-template.md
+```
+
+## Running the Sample
+
+### Prerequisites
+- .NET 10.0 SDK
+- Azure OpenAI endpoint with a deployed model
+
+### Setup
+1. Set environment variables:
+ ```bash
+ export AZURE_OPENAI_ENDPOINT="https://your-endpoint.openai.azure.com/"
+ export AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini"
+ ```
+
+2. Run the sample:
+ ```bash
+ dotnet run
+ ```
+
+### Examples
+
+The sample runs two examples:
+
+1. **Expense policy FAQ** — Asks about tip reimbursement; the agent loads the expense-report skill and reads the FAQ resource
+2. **Filing an expense report** — Multi-turn conversation to draft an expense report using the template asset
+
+## Learn More
+
+- [Agent Skills Specification](https://agentskills.io/)
+- [Microsoft Agent Framework Documentation](../../../../../docs/)
diff --git a/dotnet/samples/GettingStarted/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/SKILL.md b/dotnet/samples/GettingStarted/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/SKILL.md
new file mode 100644
index 0000000000..fc6c83cf30
--- /dev/null
+++ b/dotnet/samples/GettingStarted/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/SKILL.md
@@ -0,0 +1,40 @@
+---
+name: expense-report
+description: File and validate employee expense reports according to Contoso company policy. Use when asked about expense submissions, reimbursement rules, receipt requirements, spending limits, or expense categories.
+metadata:
+ author: contoso-finance
+ version: "2.1"
+---
+
+# Expense Report
+
+## Categories and Limits
+
+| Category | Limit | Receipt | Approval |
+|---|---|---|---|
+| Meals — solo | $50/day | >$25 | No |
+| Meals — team/client | $75/person | Always | Manager if >$200 total |
+| Lodging | $250/night | Always | Manager if >3 nights |
+| Ground transport | $100/day | >$15 | No |
+| Airfare | Economy | Always | Manager; VP if >$1,500 |
+| Conference/training | $2,000/event | Always | Manager + L&D |
+| Office supplies | $100 | Yes | No |
+| Software/subscriptions | $50/month | Yes | Manager if >$200/year |
+
+## Filing Process
+
+1. Collect receipts — must show vendor, date, amount, payment method.
+2. Categorize per table above.
+3. Use template: [assets/expense-report-template.md](assets/expense-report-template.md).
+4. For client/team meals: list attendee names and business purpose.
+5. Submit — auto-approved if <$500; manager if $500–$2,000; VP if >$2,000.
+6. Reimbursement: 10 business days via direct deposit.
+
+## Policy Rules
+
+- Submit within 30 days of transaction.
+- Alcohol is never reimbursable.
+- Foreign currency: convert to USD at transaction-date rate; note original currency and amount.
+- Mixed personal/business travel: only business portion reimbursable; provide comparison quotes.
+- Lost receipts (>$25): file Lost Receipt Affidavit from Finance. Max 2 per quarter.
+- For policy questions not covered above, consult the FAQ: [references/POLICY_FAQ.md](references/POLICY_FAQ.md). Answers should be based on what this document and the FAQ state.
diff --git a/dotnet/samples/GettingStarted/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/assets/expense-report-template.md b/dotnet/samples/GettingStarted/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/assets/expense-report-template.md
new file mode 100644
index 0000000000..3f7c7dc36c
--- /dev/null
+++ b/dotnet/samples/GettingStarted/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/assets/expense-report-template.md
@@ -0,0 +1,5 @@
+# Expense Report Template
+
+| Date | Category | Vendor | Description | Amount (USD) | Original Currency | Original Amount | Attendees | Business Purpose | Receipt Attached |
+|------|----------|--------|-------------|--------------|-------------------|-----------------|-----------|------------------|------------------|
+| | | | | | | | | | Yes or No |
diff --git a/dotnet/samples/GettingStarted/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/references/POLICY_FAQ.md b/dotnet/samples/GettingStarted/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/references/POLICY_FAQ.md
new file mode 100644
index 0000000000..8e971192f8
--- /dev/null
+++ b/dotnet/samples/GettingStarted/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/references/POLICY_FAQ.md
@@ -0,0 +1,55 @@
+# Expense Policy — Frequently Asked Questions
+
+## Meals
+
+**Q: Can I expense coffee or snacks during the workday?**
+A: Daily coffee/snacks under $10 are not reimbursable (considered personal). Coffee purchased during a client meeting or team working session is reimbursable as a team meal.
+
+**Q: What if a team dinner exceeds the per-person limit?**
+A: The $75/person limit applies as a guideline. Overages up to 20% are accepted with a written justification (e.g., "client dinner at venue chosen by client"). Overages beyond 20% require pre-approval from your VP.
+
+**Q: Do I need to list every attendee?**
+A: Yes. For client meals, list the client's name and company. For team meals, list all employee names. For groups over 10, you may attach a separate attendee list.
+
+## Travel
+
+**Q: Can I book a premium economy or business class flight?**
+A: Economy class is the standard. Premium economy is allowed for flights over 6 hours. Business class requires VP pre-approval and is generally reserved for flights over 10 hours or medical accommodation.
+
+**Q: What about ride-sharing (Uber/Lyft) vs. rental cars?**
+A: Use ride-sharing for trips under 30 miles round-trip. Rent a car for multi-day travel or when ride-sharing would exceed $100/day. Always choose the compact/standard category unless traveling with 3+ people.
+
+**Q: Are tips reimbursable?**
+A: Tips up to 20% are reimbursable for meals, taxi/ride-share, and hotel housekeeping. Tips above 20% require justification.
+
+## Lodging
+
+**Q: What if the $250/night limit isn't enough for the city I'm visiting?**
+A: For high-cost cities (New York, San Francisco, London, Tokyo, Sydney), the limit is automatically increased to $350/night. No additional approval is needed. For other locations where rates are unusually high (e.g., during a major conference), request a per-trip exception from your manager before booking.
+
+**Q: Can I stay with friends/family instead and get a per-diem?**
+A: No. Contoso reimburses actual lodging costs only, not per-diems.
+
+## Subscriptions and Software
+
+**Q: Can I expense a personal productivity tool?**
+A: Software must be directly related to your job function. Tools like IDE licenses, design software, or project management apps are reimbursable. General productivity apps (note-taking, personal calendar) are not, unless your manager confirms a business need in writing.
+
+**Q: What about annual subscriptions?**
+A: Annual subscriptions over $200 require manager approval before purchase. Submit the approval email with your expense report.
+
+## Receipts and Documentation
+
+**Q: My receipt is faded/damaged. What do I do?**
+A: Try to obtain a duplicate from the vendor. If not possible, submit a Lost Receipt Affidavit (available from the Finance SharePoint site). You're limited to 2 affidavits per quarter.
+
+**Q: Do I need a receipt for parking meters or tolls?**
+A: For amounts under $15, no receipt is required — just note the date, location, and amount. For $15 and above, a receipt or bank/credit card statement excerpt is required.
+
+## Approval and Reimbursement
+
+**Q: My manager is on leave. Who approves my report?**
+A: Expense reports can be approved by your skip-level manager or any manager designated as an alternate approver in the expense system.
+
+**Q: Can I submit expenses from a previous quarter?**
+A: The standard 30-day window applies. Expenses older than 30 days require a written explanation and VP approval. Expenses older than 90 days are not reimbursable except in extraordinary circumstances (extended leave, medical emergency) with CFO approval.
diff --git a/dotnet/samples/GettingStarted/AgentSkills/README.md b/dotnet/samples/GettingStarted/AgentSkills/README.md
new file mode 100644
index 0000000000..8488ec9eed
--- /dev/null
+++ b/dotnet/samples/GettingStarted/AgentSkills/README.md
@@ -0,0 +1,7 @@
+# AgentSkills Samples
+
+Samples demonstrating Agent Skills capabilities.
+
+| Sample | Description |
+|--------|-------------|
+| [Agent_Step01_BasicSkills](Agent_Step01_BasicSkills/) | Using Agent Skills with a ChatClientAgent, including progressive disclosure and skill resources |
diff --git a/dotnet/samples/GettingStarted/README.md b/dotnet/samples/GettingStarted/README.md
index 7a46d81a62..6fe68fc94f 100644
--- a/dotnet/samples/GettingStarted/README.md
+++ b/dotnet/samples/GettingStarted/README.md
@@ -18,3 +18,4 @@ of the agent framework.
|[Agent With Anthropic](./AgentWithAnthropic/README.md)|Getting started with agents using Anthropic Claude|
|[Workflow](./Workflows/README.md)|Getting started with Workflow|
|[Model Context Protocol](./ModelContextProtocol/README.md)|Getting started with Model Context Protocol|
+|[Agent Skills](./AgentSkills/README.md)|Getting started with Agent Skills|
diff --git a/dotnet/src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj b/dotnet/src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj
index a994afe75c..f036812900 100644
--- a/dotnet/src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj
+++ b/dotnet/src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj
@@ -2,12 +2,14 @@
true
- $(NoWarn);MEAI001
+ $(NoWarn);MEAI001;MAAI001
true
+ true
true
+ true
true
true
diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkill.cs b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkill.cs
new file mode 100644
index 0000000000..f28bad3ab0
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkill.cs
@@ -0,0 +1,56 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using Microsoft.Shared.Diagnostics;
+
+namespace Microsoft.Agents.AI;
+
+///
+/// Represents a loaded Agent Skill discovered from a filesystem directory.
+///
+///
+/// Each skill is backed by a SKILL.md file containing YAML frontmatter (name and description)
+/// and a markdown body with instructions. Resource files referenced in the body are validated at
+/// discovery time and read from disk on demand.
+///
+internal sealed class FileAgentSkill
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Parsed YAML frontmatter (name and description).
+ /// The SKILL.md content after the closing --- delimiter.
+ /// Absolute path to the directory containing this skill.
+ /// Relative paths of resource files referenced in the skill body.
+ public FileAgentSkill(
+ SkillFrontmatter frontmatter,
+ string body,
+ string sourcePath,
+ IReadOnlyList? resourceNames = null)
+ {
+ this.Frontmatter = Throw.IfNull(frontmatter);
+ this.Body = Throw.IfNull(body);
+ this.SourcePath = Throw.IfNullOrWhitespace(sourcePath);
+ this.ResourceNames = resourceNames ?? [];
+ }
+
+ ///
+ /// Gets the parsed YAML frontmatter (name and description).
+ ///
+ public SkillFrontmatter Frontmatter { get; }
+
+ ///
+ /// Gets the SKILL.md body content (without the YAML frontmatter).
+ ///
+ public string Body { get; }
+
+ ///
+ /// Gets the directory path where the skill was discovered.
+ ///
+ public string SourcePath { get; }
+
+ ///
+ /// Gets the relative paths of resource files referenced in the skill body (e.g., "references/FAQ.md").
+ ///
+ public IReadOnlyList ResourceNames { get; }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillLoader.cs b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillLoader.cs
new file mode 100644
index 0000000000..8c034b3122
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillLoader.cs
@@ -0,0 +1,407 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text;
+using System.Text.RegularExpressions;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+
+namespace Microsoft.Agents.AI;
+
+///
+/// Discovers, parses, and validates SKILL.md files from filesystem directories.
+///
+///
+/// Searches directories recursively (up to levels) for SKILL.md files.
+/// Each file is validated for YAML frontmatter and resource integrity. Invalid skills are excluded
+/// with logged warnings. Resource paths are checked against path traversal and symlink escape attacks.
+///
+internal sealed partial class FileAgentSkillLoader
+{
+ private const string SkillFileName = "SKILL.md";
+ private const int MaxSearchDepth = 2;
+ private const int MaxNameLength = 64;
+ private const int MaxDescriptionLength = 1024;
+
+ // 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.
+ // Example: "---\nname: foo\n---\nBody" → Group 1: "name: foo\n"
+ private static readonly Regex s_frontmatterRegex = new(@"\A\uFEFF?^---\s*$(.+?)^---\s*$", RegexOptions.Multiline | RegexOptions.Singleline | RegexOptions.Compiled, TimeSpan.FromSeconds(5));
+
+ // Matches markdown links to local resource files. Group 1 = relative file path.
+ // Supports optional ./ or ../ prefixes; excludes URLs (no ":" in the path character class).
+ // Intentionally conservative: only matches paths with word characters, hyphens, dots,
+ // and forward slashes. Paths with spaces or special characters are not supported.
+ // Examples: [doc](refs/FAQ.md) → "refs/FAQ.md", [s](./s.json) → "./s.json",
+ // [p](../shared/doc.txt) → "../shared/doc.txt"
+ private static readonly Regex s_resourceLinkRegex = new(@"\[.*?\]\((\.?\.?/?[\w][\w\-./]*\.\w+)\)", RegexOptions.Compiled, TimeSpan.FromSeconds(5));
+
+ // Matches YAML "key: value" lines. Group 1 = key, Group 2 = quoted value, Group 3 = unquoted value.
+ // Accepts single or double quotes; the lazy quantifier trims trailing whitespace on unquoted values.
+ // Examples: "name: foo" → (name, _, foo), "name: 'foo bar'" → (name, foo bar, _),
+ // "description: \"A skill\"" → (description, A skill, _)
+ private static readonly Regex s_yamlKeyValueRegex = new(@"^\s*(\w+)\s*:\s*(?:[""'](.+?)[""']|(.+?))\s*$", RegexOptions.Multiline | RegexOptions.Compiled, TimeSpan.FromSeconds(5));
+
+ // Validates skill names: lowercase letters, numbers, and hyphens only; must not start or end with a hyphen.
+ // Examples: "my-skill" ✓, "skill123" ✓, "-bad" ✗, "bad-" ✗, "Bad" ✗
+ private static readonly Regex s_validNameRegex = new(@"^[a-z0-9]([a-z0-9\-]*[a-z0-9])?$", RegexOptions.Compiled);
+
+ private readonly ILogger _logger;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The logger instance.
+ internal FileAgentSkillLoader(ILogger logger)
+ {
+ this._logger = logger;
+ }
+
+ ///
+ /// Discovers skill directories and loads valid skills from them.
+ ///
+ /// Paths to search for skills. Each path can point to an individual skill folder or a parent folder.
+ /// A dictionary of loaded skills keyed by skill name.
+ internal Dictionary DiscoverAndLoadSkills(IEnumerable skillPaths)
+ {
+ var skills = new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ var discoveredPaths = DiscoverSkillDirectories(skillPaths);
+
+ LogSkillsDiscovered(this._logger, discoveredPaths.Count);
+
+ foreach (string skillPath in discoveredPaths)
+ {
+ FileAgentSkill? skill = this.ParseSkillFile(skillPath);
+ if (skill is null)
+ {
+ continue;
+ }
+
+ if (skills.TryGetValue(skill.Frontmatter.Name, out FileAgentSkill? existing))
+ {
+ LogDuplicateSkillName(this._logger, skill.Frontmatter.Name, skillPath, existing.SourcePath);
+
+ // Skip duplicate skill names, keeping the first one found.
+ continue;
+ }
+
+ skills[skill.Frontmatter.Name] = skill;
+
+ LogSkillLoaded(this._logger, skill.Frontmatter.Name);
+ }
+
+ LogSkillsLoadedTotal(this._logger, skills.Count);
+
+ return skills;
+ }
+
+ ///
+ /// Reads a resource file from disk with path traversal and symlink guards.
+ ///
+ /// The skill that owns the resource.
+ /// Relative path of the resource within the skill directory.
+ /// Cancellation token.
+ /// The UTF-8 text content of the resource file.
+ ///
+ /// The resource is not registered, resolves outside the skill directory, or does not exist.
+ ///
+ internal async Task ReadSkillResourceAsync(FileAgentSkill skill, string resourceName, CancellationToken cancellationToken = default)
+ {
+ resourceName = NormalizeResourcePath(resourceName);
+
+ if (!skill.ResourceNames.Any(r => r.Equals(resourceName, StringComparison.OrdinalIgnoreCase)))
+ {
+ throw new InvalidOperationException($"Resource '{resourceName}' not found in skill '{skill.Frontmatter.Name}'.");
+ }
+
+ string fullPath = Path.GetFullPath(Path.Combine(skill.SourcePath, resourceName));
+ string normalizedSourcePath = Path.GetFullPath(skill.SourcePath) + Path.DirectorySeparatorChar;
+
+ if (!IsPathWithinDirectory(fullPath, normalizedSourcePath))
+ {
+ throw new InvalidOperationException($"Resource file '{resourceName}' references a path outside the skill directory.");
+ }
+
+ if (!File.Exists(fullPath))
+ {
+ throw new InvalidOperationException($"Resource file '{resourceName}' not found in skill '{skill.Frontmatter.Name}'.");
+ }
+
+ if (HasSymlinkInPath(fullPath, normalizedSourcePath))
+ {
+ throw new InvalidOperationException($"Resource file '{resourceName}' is a symlink that resolves outside the skill directory.");
+ }
+
+ LogResourceReading(this._logger, resourceName, skill.Frontmatter.Name);
+
+#if NET
+ return await File.ReadAllTextAsync(fullPath, Encoding.UTF8, cancellationToken).ConfigureAwait(false);
+#else
+ return await Task.FromResult(File.ReadAllText(fullPath, Encoding.UTF8)).ConfigureAwait(false);
+#endif
+ }
+
+ private static List DiscoverSkillDirectories(IEnumerable skillPaths)
+ {
+ var discoveredPaths = new List();
+
+ foreach (string rootDirectory in skillPaths)
+ {
+ if (string.IsNullOrWhiteSpace(rootDirectory) || !Directory.Exists(rootDirectory))
+ {
+ continue;
+ }
+
+ SearchDirectoriesForSkills(rootDirectory, discoveredPaths, currentDepth: 0);
+ }
+
+ return discoveredPaths;
+ }
+
+ private static void SearchDirectoriesForSkills(string directory, List results, int currentDepth)
+ {
+ string skillFilePath = Path.Combine(directory, SkillFileName);
+ if (File.Exists(skillFilePath))
+ {
+ results.Add(Path.GetFullPath(directory));
+ }
+
+ if (currentDepth >= MaxSearchDepth)
+ {
+ return;
+ }
+
+ foreach (string subdirectory in Directory.EnumerateDirectories(directory))
+ {
+ SearchDirectoriesForSkills(subdirectory, results, currentDepth + 1);
+ }
+ }
+
+ private FileAgentSkill? ParseSkillFile(string skillDirectoryPath)
+ {
+ string skillFilePath = Path.Combine(skillDirectoryPath, SkillFileName);
+
+ string content = File.ReadAllText(skillFilePath, Encoding.UTF8);
+
+ if (!this.TryParseSkillDocument(content, skillFilePath, out SkillFrontmatter frontmatter, out string body))
+ {
+ return null;
+ }
+
+ List resourceNames = ExtractResourcePaths(body);
+
+ if (!this.ValidateResources(skillDirectoryPath, resourceNames, frontmatter.Name))
+ {
+ return null;
+ }
+
+ return new FileAgentSkill(
+ frontmatter: frontmatter,
+ body: body,
+ sourcePath: skillDirectoryPath,
+ resourceNames: resourceNames);
+ }
+
+ private bool TryParseSkillDocument(string content, string skillFilePath, out SkillFrontmatter frontmatter, out string body)
+ {
+ frontmatter = null!;
+ body = null!;
+
+ Match match = s_frontmatterRegex.Match(content);
+ if (!match.Success)
+ {
+ LogInvalidFrontmatter(this._logger, skillFilePath);
+ return false;
+ }
+
+ string? name = null;
+ string? description = null;
+
+ string yamlContent = match.Groups[1].Value.Trim();
+
+ foreach (Match kvMatch in s_yamlKeyValueRegex.Matches(yamlContent))
+ {
+ string key = kvMatch.Groups[1].Value;
+ string value = kvMatch.Groups[2].Success ? kvMatch.Groups[2].Value : kvMatch.Groups[3].Value;
+
+ if (string.Equals(key, "name", StringComparison.OrdinalIgnoreCase))
+ {
+ name = value;
+ }
+ else if (string.Equals(key, "description", StringComparison.OrdinalIgnoreCase))
+ {
+ description = value;
+ }
+ }
+
+ if (string.IsNullOrWhiteSpace(name))
+ {
+ LogMissingFrontmatterField(this._logger, skillFilePath, "name");
+ return false;
+ }
+
+ if (name.Length > MaxNameLength || !s_validNameRegex.IsMatch(name))
+ {
+ LogInvalidFieldValue(this._logger, skillFilePath, "name", $"Must be {MaxNameLength} characters or fewer, using only lowercase letters, numbers, and hyphens, and must not start or end with a hyphen.");
+ return false;
+ }
+
+ if (string.IsNullOrWhiteSpace(description))
+ {
+ LogMissingFrontmatterField(this._logger, skillFilePath, "description");
+ return false;
+ }
+
+ if (description.Length > MaxDescriptionLength)
+ {
+ LogInvalidFieldValue(this._logger, skillFilePath, "description", $"Must be {MaxDescriptionLength} characters or fewer.");
+ return false;
+ }
+
+ frontmatter = new SkillFrontmatter(name, description);
+ body = content.Substring(match.Index + match.Length).TrimStart();
+
+ return true;
+ }
+
+ private bool ValidateResources(string skillDirectoryPath, List resourceNames, string skillName)
+ {
+ string normalizedSkillPath = Path.GetFullPath(skillDirectoryPath) + Path.DirectorySeparatorChar;
+
+ foreach (string resourceName in resourceNames)
+ {
+ string fullPath = Path.GetFullPath(Path.Combine(skillDirectoryPath, resourceName));
+
+ if (!IsPathWithinDirectory(fullPath, normalizedSkillPath))
+ {
+ LogResourcePathTraversal(this._logger, skillName, resourceName);
+ return false;
+ }
+
+ if (!File.Exists(fullPath))
+ {
+ LogMissingResource(this._logger, skillName, resourceName);
+ return false;
+ }
+
+ if (HasSymlinkInPath(fullPath, normalizedSkillPath))
+ {
+ LogResourceSymlinkEscape(this._logger, skillName, resourceName);
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ ///
+ /// Checks that is under ,
+ /// guarding against path traversal attacks.
+ ///
+ private static bool IsPathWithinDirectory(string fullPath, string normalizedDirectoryPath)
+ {
+ return fullPath.StartsWith(normalizedDirectoryPath, StringComparison.OrdinalIgnoreCase);
+ }
+
+ ///
+ /// Checks whether any segment in (relative to
+ /// ) is a symlink (reparse point).
+ /// Uses which is available on all target frameworks.
+ ///
+ private static bool HasSymlinkInPath(string fullPath, string normalizedDirectoryPath)
+ {
+ string relativePath = fullPath.Substring(normalizedDirectoryPath.Length);
+ string[] segments = relativePath.Split(
+ new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar },
+ StringSplitOptions.RemoveEmptyEntries);
+
+ string currentPath = normalizedDirectoryPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
+
+ foreach (string segment in segments)
+ {
+ currentPath = Path.Combine(currentPath, segment);
+
+ if ((File.GetAttributes(currentPath) & FileAttributes.ReparsePoint) != 0)
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static List ExtractResourcePaths(string content)
+ {
+ var seen = new HashSet(StringComparer.OrdinalIgnoreCase);
+ var paths = new List();
+ foreach (Match m in s_resourceLinkRegex.Matches(content))
+ {
+ string path = NormalizeResourcePath(m.Groups[1].Value);
+ if (seen.Add(path))
+ {
+ paths.Add(path);
+ }
+ }
+
+ return paths;
+ }
+
+ ///
+ /// Normalizes a relative resource path by trimming a leading ./ prefix and replacing
+ /// backslashes with forward slashes so that ./refs/doc.md and refs/doc.md are
+ /// treated as the same resource.
+ ///
+ private static string NormalizeResourcePath(string path)
+ {
+ if (path.IndexOf('\\') >= 0)
+ {
+ path = path.Replace('\\', '/');
+ }
+
+ if (path.StartsWith("./", StringComparison.Ordinal))
+ {
+ path = path.Substring(2);
+ }
+
+ return path;
+ }
+
+ [LoggerMessage(LogLevel.Information, "Discovered {Count} potential skills")]
+ private static partial void LogSkillsDiscovered(ILogger logger, int count);
+
+ [LoggerMessage(LogLevel.Information, "Loaded skill: {SkillName}")]
+ private static partial void LogSkillLoaded(ILogger logger, string skillName);
+
+ [LoggerMessage(LogLevel.Information, "Successfully loaded {Count} skills")]
+ private static partial void LogSkillsLoadedTotal(ILogger logger, int count);
+
+ [LoggerMessage(LogLevel.Error, "SKILL.md at '{SkillFilePath}' does not contain valid YAML frontmatter delimited by '---'")]
+ private static partial void LogInvalidFrontmatter(ILogger logger, string skillFilePath);
+
+ [LoggerMessage(LogLevel.Error, "SKILL.md at '{SkillFilePath}' is missing a '{FieldName}' field in frontmatter")]
+ private static partial void LogMissingFrontmatterField(ILogger logger, string skillFilePath, string fieldName);
+
+ [LoggerMessage(LogLevel.Error, "SKILL.md at '{SkillFilePath}' has an invalid '{FieldName}' value: {Reason}")]
+ private static partial void LogInvalidFieldValue(ILogger logger, string skillFilePath, string fieldName, string reason);
+
+ [LoggerMessage(LogLevel.Warning, "Excluding skill '{SkillName}': referenced resource '{ResourceName}' does not exist")]
+ private static partial void LogMissingResource(ILogger logger, string skillName, string resourceName);
+
+ [LoggerMessage(LogLevel.Warning, "Excluding skill '{SkillName}': resource '{ResourceName}' references a path outside the skill directory")]
+ private static partial void LogResourcePathTraversal(ILogger logger, string skillName, string resourceName);
+
+ [LoggerMessage(LogLevel.Warning, "Duplicate skill name '{SkillName}': skill from '{NewPath}' skipped in favor of existing skill from '{ExistingPath}'")]
+ private static partial void LogDuplicateSkillName(ILogger logger, string skillName, string newPath, string existingPath);
+
+ [LoggerMessage(LogLevel.Warning, "Excluding skill '{SkillName}': resource '{ResourceName}' is a symlink that resolves outside the skill directory")]
+ private static partial void LogResourceSymlinkEscape(ILogger logger, string skillName, string resourceName);
+
+ [LoggerMessage(LogLevel.Information, "Reading resource '{FileName}' from skill '{SkillName}'")]
+ private static partial void LogResourceReading(ILogger logger, string fileName, string skillName);
+}
diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProvider.cs b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProvider.cs
new file mode 100644
index 0000000000..847bf36a52
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProvider.cs
@@ -0,0 +1,213 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Linq;
+using System.Security;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.AI;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Shared.DiagnosticIds;
+using Microsoft.Shared.Diagnostics;
+
+namespace Microsoft.Agents.AI;
+
+///
+/// An that discovers and exposes Agent Skills from filesystem directories.
+///
+///
+///
+/// This provider implements the progressive disclosure pattern from the
+/// Agent Skills specification:
+///
+///
+/// - Advertise — skill names and descriptions are injected into the system prompt (~100 tokens per skill).
+/// - Load — the full SKILL.md body is returned via the load_skill tool.
+/// - Read resources — supplementary files are read from disk on demand via the read_skill_resource tool.
+///
+///
+/// Skills are discovered by searching the configured directories for SKILL.md files.
+/// Referenced resources are validated at initialization; invalid skills are excluded and logged.
+///
+///
+/// Security: this provider only reads static content. Skill metadata is XML-escaped
+/// before prompt embedding, and resource reads are guarded against path traversal and symlink escape.
+/// Only use skills from trusted sources.
+///
+///
+[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
+public sealed partial class FileAgentSkillsProvider : AIContextProvider
+{
+ private const string DefaultSkillsInstructionPrompt =
+ """
+ You have access to skills containing domain-specific knowledge and capabilities.
+ Each skill provides specialized instructions, reference documents, and assets for specific tasks.
+
+
+ {0}
+
+
+ When a task aligns with a skill's domain:
+ 1. Use `load_skill` to retrieve the skill's instructions
+ 2. Follow the provided guidance
+ 3. Use `read_skill_resource` to read any references or other files mentioned by the skill
+
+ Only load what is needed, when it is needed.
+ """;
+
+ private readonly Dictionary _skills;
+ private readonly ILogger _logger;
+ private readonly FileAgentSkillLoader _loader;
+ private readonly AITool[] _tools;
+ private readonly string? _skillsInstructionPrompt;
+
+ ///
+ /// Initializes a new instance of the class that searches a single directory for skills.
+ ///
+ /// Path to an individual skill folder (containing a SKILL.md file) or a parent folder with skill subdirectories.
+ /// Optional configuration for prompt customization.
+ /// Optional logger factory.
+ public FileAgentSkillsProvider(string skillPath, FileAgentSkillsProviderOptions? options = null, ILoggerFactory? loggerFactory = null)
+ : this([skillPath], options, loggerFactory)
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the class that searches multiple directories for skills.
+ ///
+ /// Paths to search. Each can be an individual skill folder or a parent folder with skill subdirectories.
+ /// Optional configuration for prompt customization.
+ /// Optional logger factory.
+ public FileAgentSkillsProvider(IEnumerable skillPaths, FileAgentSkillsProviderOptions? options = null, ILoggerFactory? loggerFactory = null)
+ {
+ _ = Throw.IfNull(skillPaths);
+
+ this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger();
+
+ this._loader = new FileAgentSkillLoader(this._logger);
+ this._skills = this._loader.DiscoverAndLoadSkills(skillPaths);
+
+ this._skillsInstructionPrompt = BuildSkillsInstructionPrompt(options, this._skills);
+
+ this._tools =
+ [
+ AIFunctionFactory.Create(
+ this.LoadSkill,
+ name: "load_skill",
+ description: "Loads the full instructions for a specific skill."),
+ AIFunctionFactory.Create(
+ this.ReadSkillResourceAsync,
+ name: "read_skill_resource",
+ description: "Reads a file associated with a skill, such as references or assets."),
+ ];
+ }
+
+ ///
+ protected override ValueTask ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default)
+ {
+ if (this._skills.Count == 0)
+ {
+ return base.ProvideAIContextAsync(context, cancellationToken);
+ }
+
+ return new ValueTask(new AIContext
+ {
+ Instructions = this._skillsInstructionPrompt,
+ Tools = this._tools
+ });
+ }
+
+ private string LoadSkill(string skillName)
+ {
+ if (string.IsNullOrWhiteSpace(skillName))
+ {
+ return "Error: Skill name cannot be empty.";
+ }
+
+ if (!this._skills.TryGetValue(skillName, out FileAgentSkill? skill))
+ {
+ return $"Error: Skill '{skillName}' not found.";
+ }
+
+ LogSkillLoading(this._logger, skillName);
+
+ return skill.Body;
+ }
+
+ private async Task ReadSkillResourceAsync(string skillName, string resourceName, CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrWhiteSpace(skillName))
+ {
+ return "Error: Skill name cannot be empty.";
+ }
+
+ if (string.IsNullOrWhiteSpace(resourceName))
+ {
+ return "Error: Resource name cannot be empty.";
+ }
+
+ if (!this._skills.TryGetValue(skillName, out FileAgentSkill? skill))
+ {
+ return $"Error: Skill '{skillName}' not found.";
+ }
+
+ try
+ {
+ return await this._loader.ReadSkillResourceAsync(skill, resourceName, cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ LogResourceReadError(this._logger, skillName, resourceName, ex);
+ return $"Error: Failed to read resource '{resourceName}' from skill '{skillName}'.";
+ }
+ }
+
+ private static string? BuildSkillsInstructionPrompt(FileAgentSkillsProviderOptions? options, Dictionary skills)
+ {
+ string promptTemplate = DefaultSkillsInstructionPrompt;
+
+ if (options?.SkillsInstructionPrompt is { } optionsInstructions)
+ {
+ try
+ {
+ promptTemplate = string.Format(optionsInstructions, string.Empty);
+ }
+ catch (FormatException ex)
+ {
+ throw new ArgumentException(
+ "The provided SkillsInstructionPrompt is not a valid format string. It must contain a '{0}' placeholder and escape any literal '{' or '}' by doubling them ('{{' or '}}').",
+ nameof(options),
+ ex);
+ }
+ }
+
+ if (skills.Count == 0)
+ {
+ return null;
+ }
+
+ var sb = new StringBuilder();
+
+ // Order by name for deterministic prompt output across process restarts
+ // (Dictionary enumeration order is not guaranteed and varies with hash randomization).
+ foreach (var skill in skills.Values.OrderBy(s => s.Frontmatter.Name, StringComparer.Ordinal))
+ {
+ sb.AppendLine(" ");
+ sb.AppendLine($" {SecurityElement.Escape(skill.Frontmatter.Name)}");
+ sb.AppendLine($" {SecurityElement.Escape(skill.Frontmatter.Description)}");
+ sb.AppendLine(" ");
+ }
+
+ return string.Format(promptTemplate, sb.ToString().TrimEnd());
+ }
+
+ [LoggerMessage(LogLevel.Information, "Loading skill: {SkillName}")]
+ private static partial void LogSkillLoading(ILogger logger, string skillName);
+
+ [LoggerMessage(LogLevel.Error, "Failed to read resource '{ResourceName}' from skill '{SkillName}'")]
+ private static partial void LogResourceReadError(ILogger logger, string skillName, string resourceName, Exception exception);
+}
diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProviderOptions.cs b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProviderOptions.cs
new file mode 100644
index 0000000000..a47841c260
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProviderOptions.cs
@@ -0,0 +1,20 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.Shared.DiagnosticIds;
+
+namespace Microsoft.Agents.AI;
+
+///
+/// Configuration options for .
+///
+[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
+public sealed class FileAgentSkillsProviderOptions
+{
+ ///
+ /// Gets or sets a custom system prompt template for advertising skills.
+ /// Use {0} as the placeholder for the generated skills list.
+ /// When , a default template is used.
+ ///
+ public string? SkillsInstructionPrompt { get; set; }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/SkillFrontmatter.cs b/dotnet/src/Microsoft.Agents.AI/Skills/SkillFrontmatter.cs
new file mode 100644
index 0000000000..123a6c43f4
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI/Skills/SkillFrontmatter.cs
@@ -0,0 +1,32 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using Microsoft.Shared.Diagnostics;
+
+namespace Microsoft.Agents.AI;
+
+///
+/// Parsed YAML frontmatter from a SKILL.md file, containing the skill's name and description.
+///
+internal sealed class SkillFrontmatter
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Skill name.
+ /// Skill description.
+ public SkillFrontmatter(string name, string description)
+ {
+ this.Name = Throw.IfNullOrWhitespace(name);
+ this.Description = Throw.IfNullOrWhitespace(description);
+ }
+
+ ///
+ /// Gets the skill name. Lowercase letters, numbers, and hyphens only.
+ ///
+ public string Name { get; }
+
+ ///
+ /// Gets the skill description. Used for discovery in the system prompt.
+ ///
+ public string Description { get; }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs
new file mode 100644
index 0000000000..c34eb6d7f2
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs
@@ -0,0 +1,561 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
+
+///
+/// Unit tests for the class.
+///
+public sealed class FileAgentSkillLoaderTests : IDisposable
+{
+ private static readonly string[] s_traversalResource = new[] { "../secret.txt" };
+
+ private readonly string _testRoot;
+ private readonly FileAgentSkillLoader _loader;
+
+ public FileAgentSkillLoaderTests()
+ {
+ this._testRoot = Path.Combine(Path.GetTempPath(), "agent-skills-tests-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(this._testRoot);
+ this._loader = new FileAgentSkillLoader(NullLogger.Instance);
+ }
+
+ public void Dispose()
+ {
+ if (Directory.Exists(this._testRoot))
+ {
+ Directory.Delete(this._testRoot, recursive: true);
+ }
+ }
+
+ [Fact]
+ public void DiscoverAndLoadSkills_ValidSkill_ReturnsSkill()
+ {
+ // Arrange
+ _ = this.CreateSkillDirectory("my-skill", "A test skill", "Use this skill to do things.");
+
+ // Act
+ var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
+
+ // Assert
+ Assert.Single(skills);
+ Assert.True(skills.ContainsKey("my-skill"));
+ Assert.Equal("A test skill", skills["my-skill"].Frontmatter.Description);
+ Assert.Equal("Use this skill to do things.", skills["my-skill"].Body);
+ }
+
+ [Fact]
+ public void DiscoverAndLoadSkills_QuotedFrontmatterValues_ParsesCorrectly()
+ {
+ // Arrange
+ string skillDir = Path.Combine(this._testRoot, "quoted-skill");
+ Directory.CreateDirectory(skillDir);
+ File.WriteAllText(
+ Path.Combine(skillDir, "SKILL.md"),
+ "---\nname: 'quoted-skill'\ndescription: \"A quoted description\"\n---\nBody text.");
+
+ // Act
+ var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
+
+ // Assert
+ Assert.Single(skills);
+ Assert.Equal("quoted-skill", skills["quoted-skill"].Frontmatter.Name);
+ Assert.Equal("A quoted description", skills["quoted-skill"].Frontmatter.Description);
+ }
+
+ [Fact]
+ public void DiscoverAndLoadSkills_MissingFrontmatter_ExcludesSkill()
+ {
+ // Arrange
+ string skillDir = Path.Combine(this._testRoot, "bad-skill");
+ Directory.CreateDirectory(skillDir);
+ File.WriteAllText(Path.Combine(skillDir, "SKILL.md"), "No frontmatter here.");
+
+ // Act
+ var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
+
+ // Assert
+ Assert.Empty(skills);
+ }
+
+ [Fact]
+ public void DiscoverAndLoadSkills_MissingNameField_ExcludesSkill()
+ {
+ // Arrange
+ string skillDir = Path.Combine(this._testRoot, "no-name");
+ Directory.CreateDirectory(skillDir);
+ File.WriteAllText(
+ Path.Combine(skillDir, "SKILL.md"),
+ "---\ndescription: A skill without a name\n---\nBody.");
+
+ // Act
+ var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
+
+ // Assert
+ Assert.Empty(skills);
+ }
+
+ [Fact]
+ public void DiscoverAndLoadSkills_MissingDescriptionField_ExcludesSkill()
+ {
+ // Arrange
+ string skillDir = Path.Combine(this._testRoot, "no-desc");
+ Directory.CreateDirectory(skillDir);
+ File.WriteAllText(
+ Path.Combine(skillDir, "SKILL.md"),
+ "---\nname: no-desc\n---\nBody.");
+
+ // Act
+ var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
+
+ // Assert
+ Assert.Empty(skills);
+ }
+
+ [Theory]
+ [InlineData("BadName")]
+ [InlineData("-leading-hyphen")]
+ [InlineData("trailing-hyphen-")]
+ [InlineData("has spaces")]
+ public void DiscoverAndLoadSkills_InvalidName_ExcludesSkill(string invalidName)
+ {
+ // Arrange
+ string skillDir = Path.Combine(this._testRoot, "invalid-name-test");
+ if (Directory.Exists(skillDir))
+ {
+ Directory.Delete(skillDir, recursive: true);
+ }
+
+ Directory.CreateDirectory(skillDir);
+ File.WriteAllText(
+ Path.Combine(skillDir, "SKILL.md"),
+ $"---\nname: {invalidName}\ndescription: A skill\n---\nBody.");
+
+ // Act
+ var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
+
+ // Assert
+ Assert.Empty(skills);
+ }
+
+ [Fact]
+ public void DiscoverAndLoadSkills_DuplicateNames_KeepsFirstOnly()
+ {
+ // Arrange
+ string dir1 = Path.Combine(this._testRoot, "skill-a");
+ string dir2 = Path.Combine(this._testRoot, "skill-b");
+ Directory.CreateDirectory(dir1);
+ Directory.CreateDirectory(dir2);
+ File.WriteAllText(
+ Path.Combine(dir1, "SKILL.md"),
+ "---\nname: dupe\ndescription: First\n---\nFirst body.");
+ File.WriteAllText(
+ Path.Combine(dir2, "SKILL.md"),
+ "---\nname: dupe\ndescription: Second\n---\nSecond body.");
+
+ // Act
+ var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
+
+ // Assert – filesystem enumeration order is not guaranteed, so we only
+ // verify that exactly one of the two duplicates was kept.
+ Assert.Single(skills);
+ string desc = skills["dupe"].Frontmatter.Description;
+ Assert.True(desc == "First" || desc == "Second", $"Unexpected description: {desc}");
+ }
+
+ [Fact]
+ public void DiscoverAndLoadSkills_WithValidResourceLinks_ExtractsResourceNames()
+ {
+ // Arrange
+ string skillDir = Path.Combine(this._testRoot, "resource-skill");
+ string refsDir = Path.Combine(skillDir, "refs");
+ Directory.CreateDirectory(refsDir);
+ File.WriteAllText(Path.Combine(refsDir, "FAQ.md"), "FAQ content");
+ File.WriteAllText(
+ Path.Combine(skillDir, "SKILL.md"),
+ "---\nname: resource-skill\ndescription: Has resources\n---\nSee [FAQ](refs/FAQ.md) for details.");
+
+ // Act
+ var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
+
+ // Assert
+ Assert.Single(skills);
+ var skill = skills["resource-skill"];
+ Assert.Single(skill.ResourceNames);
+ Assert.Equal("refs/FAQ.md", skill.ResourceNames[0]);
+ }
+
+ [Fact]
+ public void DiscoverAndLoadSkills_PathTraversal_ExcludesSkill()
+ {
+ // Arrange — resource links outside the skill directory
+ string skillDir = Path.Combine(this._testRoot, "traversal-skill");
+ Directory.CreateDirectory(skillDir);
+
+ // Create a file outside the skill dir that the traversal would resolve to
+ File.WriteAllText(Path.Combine(this._testRoot, "secret.txt"), "secret");
+
+ File.WriteAllText(
+ Path.Combine(skillDir, "SKILL.md"),
+ "---\nname: traversal-skill\ndescription: Traversal attempt\n---\nSee [doc](../secret.txt).");
+
+ // Act
+ var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
+
+ // Assert
+ Assert.Empty(skills);
+ }
+
+ [Fact]
+ public void DiscoverAndLoadSkills_EmptyPaths_ReturnsEmptyDictionary()
+ {
+ // Act
+ var skills = this._loader.DiscoverAndLoadSkills(Enumerable.Empty());
+
+ // Assert
+ Assert.Empty(skills);
+ }
+
+ [Fact]
+ public void DiscoverAndLoadSkills_NonExistentPath_ReturnsEmptyDictionary()
+ {
+ // Act
+ var skills = this._loader.DiscoverAndLoadSkills(new[] { Path.Combine(this._testRoot, "does-not-exist") });
+
+ // Assert
+ Assert.Empty(skills);
+ }
+
+ [Fact]
+ public void DiscoverAndLoadSkills_NestedSkillDirectory_DiscoveredWithinDepthLimit()
+ {
+ // Arrange — nested 1 level deep (MaxSearchDepth = 2, so depth 0 = testRoot, depth 1 = level1)
+ string nestedDir = Path.Combine(this._testRoot, "level1", "nested-skill");
+ Directory.CreateDirectory(nestedDir);
+ File.WriteAllText(
+ Path.Combine(nestedDir, "SKILL.md"),
+ "---\nname: nested-skill\ndescription: Nested\n---\nNested body.");
+
+ // Act
+ var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
+
+ // Assert
+ Assert.Single(skills);
+ Assert.True(skills.ContainsKey("nested-skill"));
+ }
+
+ [Fact]
+ public async Task ReadSkillResourceAsync_ValidResource_ReturnsContentAsync()
+ {
+ // Arrange
+ _ = this.CreateSkillDirectoryWithResource("read-skill", "A skill", "See [doc](refs/doc.md).", "refs/doc.md", "Document content here.");
+ var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
+ var skill = skills["read-skill"];
+
+ // Act
+ string content = await this._loader.ReadSkillResourceAsync(skill, "refs/doc.md");
+
+ // Assert
+ Assert.Equal("Document content here.", content);
+ }
+
+ [Fact]
+ public async Task ReadSkillResourceAsync_UnregisteredResource_ThrowsInvalidOperationExceptionAsync()
+ {
+ // Arrange
+ string skillDir = this.CreateSkillDirectory("simple-skill", "A skill", "No resources.");
+ var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
+ var skill = skills["simple-skill"];
+
+ // Act & Assert
+ await Assert.ThrowsAsync(
+ () => this._loader.ReadSkillResourceAsync(skill, "unknown.md"));
+ }
+
+ [Fact]
+ public async Task ReadSkillResourceAsync_PathTraversal_ThrowsInvalidOperationExceptionAsync()
+ {
+ // Arrange — skill with a legitimate resource, then try to read a traversal path at read time
+ _ = this.CreateSkillDirectoryWithResource("traverse-read", "A skill", "See [doc](refs/doc.md).", "refs/doc.md", "legit");
+ var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
+ var skill = skills["traverse-read"];
+
+ // Manually construct a skill with the traversal resource in its list to bypass discovery validation
+ var tampered = new FileAgentSkill(
+ skill.Frontmatter,
+ skill.Body,
+ skill.SourcePath,
+ s_traversalResource);
+
+ // Act & Assert
+ await Assert.ThrowsAsync(
+ () => this._loader.ReadSkillResourceAsync(tampered, "../secret.txt"));
+ }
+
+ [Fact]
+ public void DiscoverAndLoadSkills_NameExceedsMaxLength_ExcludesSkill()
+ {
+ // Arrange — name longer than 64 characters
+ string longName = new('a', 65);
+ string skillDir = Path.Combine(this._testRoot, "long-name");
+ Directory.CreateDirectory(skillDir);
+ File.WriteAllText(
+ Path.Combine(skillDir, "SKILL.md"),
+ $"---\nname: {longName}\ndescription: A skill\n---\nBody.");
+
+ // Act
+ var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
+
+ // Assert
+ Assert.Empty(skills);
+ }
+
+ [Fact]
+ public void DiscoverAndLoadSkills_DescriptionExceedsMaxLength_ExcludesSkill()
+ {
+ // Arrange — description longer than 1024 characters
+ string longDesc = new('x', 1025);
+ string skillDir = Path.Combine(this._testRoot, "long-desc");
+ Directory.CreateDirectory(skillDir);
+ File.WriteAllText(
+ Path.Combine(skillDir, "SKILL.md"),
+ $"---\nname: long-desc\ndescription: {longDesc}\n---\nBody.");
+
+ // Act
+ var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
+
+ // Assert
+ Assert.Empty(skills);
+ }
+
+ [Fact]
+ public void DiscoverAndLoadSkills_DuplicateResourceLinks_DeduplicatesResources()
+ {
+ // Arrange — body references the same resource twice
+ string skillDir = Path.Combine(this._testRoot, "dedup-skill");
+ string refsDir = Path.Combine(skillDir, "refs");
+ Directory.CreateDirectory(refsDir);
+ File.WriteAllText(Path.Combine(refsDir, "doc.md"), "content");
+ File.WriteAllText(
+ Path.Combine(skillDir, "SKILL.md"),
+ "---\nname: dedup-skill\ndescription: Dedup test\n---\nSee [doc](refs/doc.md) and [again](refs/doc.md).");
+
+ // Act
+ var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
+
+ // Assert
+ Assert.Single(skills);
+ Assert.Single(skills["dedup-skill"].ResourceNames);
+ }
+
+ [Fact]
+ public void DiscoverAndLoadSkills_DotSlashPrefix_NormalizesToBarePath()
+ {
+ // Arrange — body references a resource with ./ prefix
+ string skillDir = Path.Combine(this._testRoot, "dotslash-skill");
+ string refsDir = Path.Combine(skillDir, "refs");
+ Directory.CreateDirectory(refsDir);
+ File.WriteAllText(Path.Combine(refsDir, "doc.md"), "content");
+ File.WriteAllText(
+ Path.Combine(skillDir, "SKILL.md"),
+ "---\nname: dotslash-skill\ndescription: Dot-slash test\n---\nSee [doc](./refs/doc.md).");
+
+ // Act
+ var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
+
+ // Assert
+ Assert.Single(skills);
+ var skill = skills["dotslash-skill"];
+ Assert.Single(skill.ResourceNames);
+ Assert.Equal("refs/doc.md", skill.ResourceNames[0]);
+ }
+
+ [Fact]
+ public void DiscoverAndLoadSkills_DotSlashAndBarePath_DeduplicatesResources()
+ {
+ // Arrange — body references the same resource with and without ./ prefix
+ string skillDir = Path.Combine(this._testRoot, "mixed-prefix-skill");
+ string refsDir = Path.Combine(skillDir, "refs");
+ Directory.CreateDirectory(refsDir);
+ File.WriteAllText(Path.Combine(refsDir, "doc.md"), "content");
+ File.WriteAllText(
+ Path.Combine(skillDir, "SKILL.md"),
+ "---\nname: mixed-prefix-skill\ndescription: Mixed prefix test\n---\nSee [a](./refs/doc.md) and [b](refs/doc.md).");
+
+ // Act
+ var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
+
+ // Assert
+ Assert.Single(skills);
+ var skill = skills["mixed-prefix-skill"];
+ Assert.Single(skill.ResourceNames);
+ Assert.Equal("refs/doc.md", skill.ResourceNames[0]);
+ }
+
+ [Fact]
+ public async Task ReadSkillResourceAsync_DotSlashPrefix_MatchesNormalizedResourceAsync()
+ {
+ // Arrange — skill loaded with bare path, caller uses ./ prefix
+ _ = this.CreateSkillDirectoryWithResource("dotslash-read", "A skill", "See [doc](refs/doc.md).", "refs/doc.md", "Document content.");
+ var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
+ var skill = skills["dotslash-read"];
+
+ // Act — caller passes ./refs/doc.md which should match refs/doc.md
+ string content = await this._loader.ReadSkillResourceAsync(skill, "./refs/doc.md");
+
+ // Assert
+ Assert.Equal("Document content.", content);
+ }
+
+ [Fact]
+ public async Task ReadSkillResourceAsync_BackslashSeparator_MatchesNormalizedResourceAsync()
+ {
+ // Arrange — skill loaded with forward-slash path, caller uses backslashes
+ _ = this.CreateSkillDirectoryWithResource("backslash-read", "A skill", "See [doc](refs/doc.md).", "refs/doc.md", "Backslash content.");
+ var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
+ var skill = skills["backslash-read"];
+
+ // Act — caller passes refs\doc.md which should match refs/doc.md
+ string content = await this._loader.ReadSkillResourceAsync(skill, "refs\\doc.md");
+
+ // Assert
+ Assert.Equal("Backslash content.", content);
+ }
+
+ [Fact]
+ public async Task ReadSkillResourceAsync_DotSlashWithBackslash_MatchesNormalizedResourceAsync()
+ {
+ // Arrange — skill loaded with forward-slash path, caller uses .\ prefix with backslashes
+ _ = this.CreateSkillDirectoryWithResource("mixed-sep-read", "A skill", "See [doc](refs/doc.md).", "refs/doc.md", "Mixed separator content.");
+ var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
+ var skill = skills["mixed-sep-read"];
+
+ // Act — caller passes .\refs\doc.md which should match refs/doc.md
+ string content = await this._loader.ReadSkillResourceAsync(skill, ".\\refs\\doc.md");
+
+ // Assert
+ Assert.Equal("Mixed separator content.", content);
+ }
+
+#if NET
+ private static readonly string[] s_symlinkResource = ["refs/data.md"];
+
+ [Fact]
+ public void DiscoverAndLoadSkills_SymlinkInPath_ExcludesSkill()
+ {
+ // Arrange — a "refs" subdirectory is a symlink pointing outside the skill directory
+ string skillDir = Path.Combine(this._testRoot, "symlink-escape-skill");
+ Directory.CreateDirectory(skillDir);
+
+ 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");
+ 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-escape-skill\ndescription: Symlinked directory escape\n---\nSee [doc](refs/secret.md).");
+
+ // Act
+ var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
+
+ // Assert — skill should be excluded because refs/ is a symlink (reparse point)
+ Assert.False(skills.ContainsKey("symlink-escape-skill"));
+ }
+
+ [Fact]
+ public async Task ReadSkillResourceAsync_SymlinkInPath_ThrowsInvalidOperationExceptionAsync()
+ {
+ // Arrange — build a skill with a symlinked subdirectory
+ string skillDir = Path.Combine(this._testRoot, "symlink-read-skill");
+ string refsDir = Path.Combine(skillDir, "refs");
+ Directory.CreateDirectory(skillDir);
+
+ string outsideDir = Path.Combine(this._testRoot, "outside-read");
+ Directory.CreateDirectory(outsideDir);
+ File.WriteAllText(Path.Combine(outsideDir, "data.md"), "external data");
+
+ try
+ {
+ Directory.CreateSymbolicLink(refsDir, outsideDir);
+ }
+ catch (IOException)
+ {
+ // Symlink creation requires elevation on some platforms; skip gracefully.
+ return;
+ }
+
+ // Manually construct a skill that bypasses discovery validation
+ var frontmatter = new SkillFrontmatter("symlink-read-skill", "A skill");
+ var skill = new FileAgentSkill(
+ frontmatter: frontmatter,
+ body: "See [doc](refs/data.md).",
+ sourcePath: skillDir,
+ resourceNames: s_symlinkResource);
+
+ // Act & Assert
+ await Assert.ThrowsAsync(
+ () => this._loader.ReadSkillResourceAsync(skill, "refs/data.md"));
+ }
+#endif
+
+ [Fact]
+ public void DiscoverAndLoadSkills_FileWithUtf8Bom_ParsesSuccessfully()
+ {
+ // Arrange — prepend a UTF-8 BOM (\uFEFF) before the frontmatter
+ _ = this.CreateSkillDirectoryWithRawContent(
+ "bom-skill",
+ "\uFEFF---\nname: bom-skill\ndescription: Skill with BOM\n---\nBody content.");
+
+ // Act
+ var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
+
+ // Assert
+ Assert.Single(skills);
+ Assert.True(skills.ContainsKey("bom-skill"));
+ Assert.Equal("Skill with BOM", skills["bom-skill"].Frontmatter.Description);
+ Assert.Equal("Body content.", skills["bom-skill"].Body);
+ }
+
+ private string CreateSkillDirectory(string name, string description, string body)
+ {
+ string skillDir = Path.Combine(this._testRoot, name);
+ Directory.CreateDirectory(skillDir);
+ File.WriteAllText(
+ Path.Combine(skillDir, "SKILL.md"),
+ $"---\nname: {name}\ndescription: {description}\n---\n{body}");
+ return skillDir;
+ }
+
+ private string CreateSkillDirectoryWithRawContent(string directoryName, string rawContent)
+ {
+ string skillDir = Path.Combine(this._testRoot, directoryName);
+ Directory.CreateDirectory(skillDir);
+ File.WriteAllText(Path.Combine(skillDir, "SKILL.md"), rawContent);
+ return skillDir;
+ }
+
+ private string CreateSkillDirectoryWithResource(string name, string description, string body, string resourceRelativePath, string resourceContent)
+ {
+ string skillDir = this.CreateSkillDirectory(name, description, body);
+ string resourcePath = Path.Combine(skillDir, resourceRelativePath);
+ Directory.CreateDirectory(Path.GetDirectoryName(resourcePath)!);
+ File.WriteAllText(resourcePath, resourceContent);
+ return skillDir;
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillsProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillsProviderTests.cs
new file mode 100644
index 0000000000..6bfaf1b546
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillsProviderTests.cs
@@ -0,0 +1,228 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.AI;
+
+namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
+
+///
+/// Unit tests for the class.
+///
+public sealed class FileAgentSkillsProviderTests : IDisposable
+{
+ private readonly string _testRoot;
+ private readonly TestAIAgent _agent = new();
+
+ public FileAgentSkillsProviderTests()
+ {
+ this._testRoot = Path.Combine(Path.GetTempPath(), "skills-provider-tests-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(this._testRoot);
+ }
+
+ public void Dispose()
+ {
+ if (Directory.Exists(this._testRoot))
+ {
+ Directory.Delete(this._testRoot, recursive: true);
+ }
+ }
+
+ [Fact]
+ public async Task InvokingCoreAsync_NoSkills_ReturnsInputContextUnchangedAsync()
+ {
+ // Arrange
+ var provider = new FileAgentSkillsProvider(this._testRoot);
+ var inputContext = new AIContext { Instructions = "Original instructions" };
+ var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext);
+
+ // Act
+ var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
+
+ // Assert
+ Assert.Equal("Original instructions", result.Instructions);
+ Assert.Null(result.Tools);
+ }
+
+ [Fact]
+ public async Task InvokingCoreAsync_WithSkills_AppendsInstructionsAndToolsAsync()
+ {
+ // Arrange
+ this.CreateSkill("provider-skill", "Provider skill test", "Skill instructions body.");
+ var provider = new FileAgentSkillsProvider(this._testRoot);
+ var inputContext = new AIContext { Instructions = "Base instructions" };
+ var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext);
+
+ // Act
+ var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
+
+ // Assert
+ Assert.NotNull(result.Instructions);
+ Assert.Contains("Base instructions", result.Instructions);
+ Assert.Contains("provider-skill", result.Instructions);
+ Assert.Contains("Provider skill test", result.Instructions);
+
+ // Should have load_skill and read_skill_resource tools
+ Assert.NotNull(result.Tools);
+ var toolNames = result.Tools!.Select(t => t.Name).ToList();
+ Assert.Contains("load_skill", toolNames);
+ Assert.Contains("read_skill_resource", toolNames);
+ }
+
+ [Fact]
+ public async Task InvokingCoreAsync_NullInputInstructions_SetsInstructionsAsync()
+ {
+ // Arrange
+ this.CreateSkill("null-instr-skill", "Null instruction test", "Body.");
+ var provider = new FileAgentSkillsProvider(this._testRoot);
+ var inputContext = new AIContext();
+ var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext);
+
+ // Act
+ var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
+
+ // Assert
+ Assert.NotNull(result.Instructions);
+ Assert.Contains("null-instr-skill", result.Instructions);
+ }
+
+ [Fact]
+ public async Task InvokingCoreAsync_CustomPromptTemplate_UsesCustomTemplateAsync()
+ {
+ // Arrange
+ this.CreateSkill("custom-prompt-skill", "Custom prompt", "Body.");
+ var options = new FileAgentSkillsProviderOptions
+ {
+ SkillsInstructionPrompt = "Custom template: {0}"
+ };
+ var provider = new FileAgentSkillsProvider(this._testRoot, options);
+ var inputContext = new AIContext();
+ var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext);
+
+ // Act
+ var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
+
+ // Assert
+ Assert.NotNull(result.Instructions);
+ Assert.StartsWith("Custom template:", result.Instructions);
+ }
+
+ [Fact]
+ public void Constructor_InvalidPromptTemplate_ThrowsArgumentException()
+ {
+ // Arrange — template with unescaped braces and no valid {0} placeholder
+ var options = new FileAgentSkillsProviderOptions
+ {
+ SkillsInstructionPrompt = "Bad template with {unescaped} braces"
+ };
+
+ // Act & Assert
+ var ex = Assert.Throws(() => new FileAgentSkillsProvider(this._testRoot, options));
+ Assert.Contains("SkillsInstructionPrompt", ex.Message);
+ Assert.Equal("options", ex.ParamName);
+ }
+
+ [Fact]
+ public async Task InvokingCoreAsync_SkillNamesAreXmlEscapedAsync()
+ {
+ // Arrange — description with XML-sensitive characters
+ string skillDir = Path.Combine(this._testRoot, "xml-skill");
+ Directory.CreateDirectory(skillDir);
+ File.WriteAllText(
+ Path.Combine(skillDir, "SKILL.md"),
+ "---\nname: xml-skill\ndescription: Uses & \"quotes\"\n---\nBody.");
+ var provider = new FileAgentSkillsProvider(this._testRoot);
+ var inputContext = new AIContext();
+ var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext);
+
+ // Act
+ var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
+
+ // Assert
+ Assert.NotNull(result.Instructions);
+ Assert.Contains("<tags>", result.Instructions);
+ Assert.Contains("&", result.Instructions);
+ }
+
+ [Fact]
+ public async Task Constructor_WithMultiplePaths_LoadsFromAllAsync()
+ {
+ // Arrange
+ string dir1 = Path.Combine(this._testRoot, "dir1");
+ string dir2 = Path.Combine(this._testRoot, "dir2");
+ CreateSkillIn(dir1, "skill-a", "Skill A", "Body A.");
+ CreateSkillIn(dir2, "skill-b", "Skill B", "Body B.");
+
+ // Act
+ var provider = new FileAgentSkillsProvider(new[] { dir1, dir2 });
+ var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
+
+ // Assert
+ var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
+ Assert.NotNull(result.Instructions);
+ Assert.Contains("skill-a", result.Instructions);
+ Assert.Contains("skill-b", result.Instructions);
+ }
+
+ [Fact]
+ public async Task InvokingCoreAsync_PreservesExistingInputToolsAsync()
+ {
+ // Arrange
+ this.CreateSkill("tools-skill", "Tools test", "Body.");
+ var provider = new FileAgentSkillsProvider(this._testRoot);
+
+ var existingTool = AIFunctionFactory.Create(() => "test", name: "existing_tool", description: "An existing tool.");
+ var inputContext = new AIContext { Tools = new[] { existingTool } };
+ var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext);
+
+ // Act
+ var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
+
+ // Assert — existing tool should be preserved alongside the new skill tools
+ Assert.NotNull(result.Tools);
+ var toolNames = result.Tools!.Select(t => t.Name).ToList();
+ Assert.Contains("existing_tool", toolNames);
+ Assert.Contains("load_skill", toolNames);
+ Assert.Contains("read_skill_resource", toolNames);
+ }
+
+ [Fact]
+ public async Task InvokingCoreAsync_SkillsListIsSortedByNameAsync()
+ {
+ // Arrange — create skills in reverse alphabetical order
+ this.CreateSkill("zulu-skill", "Zulu skill", "Body Z.");
+ this.CreateSkill("alpha-skill", "Alpha skill", "Body A.");
+ this.CreateSkill("mike-skill", "Mike skill", "Body M.");
+ var provider = new FileAgentSkillsProvider(this._testRoot);
+ var inputContext = new AIContext();
+ var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext);
+
+ // Act
+ var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
+
+ // Assert — skills should appear in alphabetical order in the prompt
+ Assert.NotNull(result.Instructions);
+ int alphaIndex = result.Instructions!.IndexOf("alpha-skill", StringComparison.Ordinal);
+ int mikeIndex = result.Instructions.IndexOf("mike-skill", StringComparison.Ordinal);
+ int zuluIndex = result.Instructions.IndexOf("zulu-skill", StringComparison.Ordinal);
+ Assert.True(alphaIndex < mikeIndex, "alpha-skill should appear before mike-skill");
+ Assert.True(mikeIndex < zuluIndex, "mike-skill should appear before zulu-skill");
+ }
+
+ private void CreateSkill(string name, string description, string body)
+ {
+ CreateSkillIn(this._testRoot, name, description, body);
+ }
+
+ private static void CreateSkillIn(string root, string name, string description, string body)
+ {
+ string skillDir = Path.Combine(root, name);
+ Directory.CreateDirectory(skillDir);
+ File.WriteAllText(
+ Path.Combine(skillDir, "SKILL.md"),
+ $"---\nname: {name}\ndescription: {description}\n---\n{body}");
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Microsoft.Agents.AI.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Microsoft.Agents.AI.UnitTests.csproj
index cf16b00b34..7fa417b184 100644
--- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Microsoft.Agents.AI.UnitTests.csproj
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Microsoft.Agents.AI.UnitTests.csproj
@@ -1,5 +1,9 @@
+
+ $(NoWarn);MAAI001
+
+
false