From ffaa0988e6806400fba1850261adb3f51ba8f37a Mon Sep 17 00:00:00 2001
From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com>
Date: Fri, 20 Feb 2026 10:36:02 +0000
Subject: [PATCH 01/16] support agent skills
---
dotnet/agent-framework-dotnet.slnx | 4 +
.../Agent_Step01_BasicSkills.csproj | 27 ++
.../Agent_Step01_BasicSkills/Program.cs | 49 +++
.../Agent_Step01_BasicSkills/README.md | 64 +++
.../skills/expense-report/SKILL.md | 40 ++
.../assets/expense-report-template.md | 5 +
.../expense-report/references/POLICY_FAQ.md | 55 +++
.../GettingStarted/AgentSkills/README.md | 7 +
dotnet/samples/GettingStarted/README.md | 1 +
.../Skills/FileAgentSkill.cs | 56 +++
.../Skills/FileAgentSkillLoader.cs | 394 ++++++++++++++++++
.../Skills/FileAgentSkillsProvider.cs | 193 +++++++++
.../Skills/FileAgentSkillsProviderOptions.cs | 16 +
.../Skills/SkillFrontmatter.cs | 32 ++
.../AgentSkills/FileAgentSkillLoaderTests.cs | 372 +++++++++++++++++
.../FileAgentSkillsProviderTests.cs | 190 +++++++++
16 files changed, 1505 insertions(+)
create mode 100644 dotnet/samples/GettingStarted/AgentSkills/Agent_Step01_BasicSkills/Agent_Step01_BasicSkills.csproj
create mode 100644 dotnet/samples/GettingStarted/AgentSkills/Agent_Step01_BasicSkills/Program.cs
create mode 100644 dotnet/samples/GettingStarted/AgentSkills/Agent_Step01_BasicSkills/README.md
create mode 100644 dotnet/samples/GettingStarted/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/SKILL.md
create mode 100644 dotnet/samples/GettingStarted/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/assets/expense-report-template.md
create mode 100644 dotnet/samples/GettingStarted/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/references/POLICY_FAQ.md
create mode 100644 dotnet/samples/GettingStarted/AgentSkills/README.md
create mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkill.cs
create mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillLoader.cs
create mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProvider.cs
create mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProviderOptions.cs
create mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/SkillFrontmatter.cs
create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs
create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillsProviderTests.cs
diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx
index b3a1fd81f3..c4a3073f1a 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..935713bb7a
--- /dev/null
+++ b/dotnet/samples/GettingStarted/AgentSkills/Agent_Step01_BasicSkills/Agent_Step01_BasicSkills.csproj
@@ -0,0 +1,27 @@
+
+
+
+ Exe
+ net10.0
+
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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..5a9b763d99
--- /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(Directory.GetCurrentDirectory(), "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..59fd36b130
--- /dev/null
+++ b/dotnet/samples/GettingStarted/AgentSkills/Agent_Step01_BasicSkills/README.md
@@ -0,0 +1,64 @@
+# 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/)
+- [AIContextProvider Pattern](../Agent_Step20_AdditionalAIContext/)
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/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..398ddc8810
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillLoader.cs
@@ -0,0 +1,394 @@
+// 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.
+ // Example: "---\nname: foo\n---\nBody" → Group 1: "name: foo\n"
+#if NET
+ private static readonly Regex s_frontmatterRegex = new(@"^---\s*$(.+?)^---\s*$", RegexOptions.Multiline | RegexOptions.Singleline | RegexOptions.Compiled, TimeSpan.FromSeconds(5));
+#else
+ private static readonly Regex s_frontmatterRegex = new(@"^---\s*$(.+?)^---\s*$", RegexOptions.Multiline | RegexOptions.Singleline | RegexOptions.Compiled);
+#endif
+
+ // 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"
+#if NET
+ private static readonly Regex s_resourceLinkRegex = new(@"\[.*?\]\((\.?\.?/?[\w][\w\-./]*\.\w+)\)", RegexOptions.Compiled, TimeSpan.FromSeconds(5));
+#else
+ private static readonly Regex s_resourceLinkRegex = new(@"\[.*?\]\((\.?\.?/?[\w][\w\-./]*\.\w+)\)", RegexOptions.Compiled);
+#endif
+
+ // 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, _)
+#if NET
+ private static readonly Regex s_yamlKeyValueRegex = new(@"^\s*(\w+)\s*:\s*(?:[""'](.+?)[""']|(.+?))\s*$", RegexOptions.Multiline | RegexOptions.Compiled, TimeSpan.FromSeconds(5));
+#else
+ private static readonly Regex s_yamlKeyValueRegex = new(@"^\s*(\w+)\s*:\s*(?:[""'](.+?)[""']|(.+?))\s*$", RegexOptions.Multiline | RegexOptions.Compiled);
+#endif
+
+ // 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)
+ {
+ 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 NET
+ if (!IsSymlinkWithinDirectory(fullPath, normalizedSourcePath))
+ {
+ throw new InvalidOperationException($"Resource file '{resourceName}' is a symlink that resolves outside the skill directory.");
+ }
+#endif
+
+ if (!File.Exists(fullPath))
+ {
+ throw new InvalidOperationException($"Resource file '{resourceName}' not found at '{fullPath}'.");
+ }
+
+ 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(directory);
+ }
+
+ if (currentDepth >= MaxSearchDepth)
+ {
+ return;
+ }
+
+ foreach (string subdirectory in Directory.GetDirectories(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 NET
+ if (!IsSymlinkWithinDirectory(fullPath, normalizedSkillPath))
+ {
+ LogResourceSymlinkEscape(this._logger, skillName, resourceName);
+ return false;
+ }
+#endif
+
+ if (!File.Exists(fullPath))
+ {
+ LogMissingResource(this._logger, skillName, resourceName, fullPath);
+ 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);
+ }
+
+#if NET
+ ///
+ /// Checks that a symlink at does not resolve outside
+ /// .
+ ///
+ private static bool IsSymlinkWithinDirectory(string fullPath, string normalizedDirectoryPath)
+ {
+ // ResolveLinkTarget with returnFinalTarget: true follows the full symlink chain,
+ // guarding against chained symlinks that escape the skill directory.
+ string? resolvedTarget = File.ResolveLinkTarget(fullPath, returnFinalTarget: true)?.FullName;
+ if (resolvedTarget != null)
+ {
+ return resolvedTarget.StartsWith(normalizedDirectoryPath, StringComparison.OrdinalIgnoreCase);
+ }
+
+ return true;
+ }
+#endif
+
+ 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 = m.Groups[1].Value;
+ if (seen.Add(path))
+ {
+ paths.Add(path);
+ }
+ }
+
+ return paths;
+ }
+
+ [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 at '{FullPath}'")]
+ private static partial void LogMissingResource(ILogger logger, string skillName, string resourceName, string fullPath);
+
+ [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);
+
+#if NET
+ [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);
+#endif
+
+ [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..538048a488
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProvider.cs
@@ -0,0 +1,193 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+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;
+
+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.
+///
+///
+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)
+ {
+ this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger();
+
+ this._loader = new FileAgentSkillLoader(this._logger);
+ this._skills = this._loader.DiscoverAndLoadSkills(skillPaths);
+
+ string promptTemplate = options?.SkillsInstructionPrompt ?? DefaultSkillsInstructionPrompt;
+ this._skillsInstructionPrompt = this._skills.Count > 0
+ ? string.Format(promptTemplate, this.BuildSkillsListPrompt())
+ : string.Empty;
+
+ 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 InvokingCoreAsync(InvokingContext context, CancellationToken cancellationToken = default)
+ {
+ var inputContext = context.AIContext;
+
+ if (this._skills.Count == 0)
+ {
+ return new ValueTask(inputContext);
+ }
+
+ string fullPrompt = this._skillsInstructionPrompt;
+
+ string? instructions = inputContext.Instructions is not null
+ ? inputContext.Instructions + "\n" + fullPrompt
+ : fullPrompt;
+
+ return new ValueTask(new AIContext
+ {
+ Instructions = instructions,
+ Messages = inputContext.Messages,
+ Tools = (inputContext.Tools ?? []).Concat(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 string BuildSkillsListPrompt()
+ {
+ var sb = new StringBuilder();
+ foreach (var skill in this._skills.Values)
+ {
+ sb.AppendLine(" ");
+ sb.AppendLine($" {SecurityElement.Escape(skill.Frontmatter.Name)}");
+ sb.AppendLine($" {SecurityElement.Escape(skill.Frontmatter.Description)}");
+ sb.AppendLine(" ");
+ }
+ return 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..ea435774ae
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProviderOptions.cs
@@ -0,0 +1,16 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+namespace Microsoft.Agents.AI;
+
+///
+/// Configuration options for .
+///
+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..b1f3bcb82b
--- /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 (lowercase, hyphens, max 64 characters).
+ /// Skill description (max 1024 characters).
+ 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..c430a48b08
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs
@@ -0,0 +1,372 @@
+// 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
+ Assert.Single(skills);
+ Assert.Equal("First", skills["dupe"].Frontmatter.Description);
+ }
+
+ [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 string('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 string('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);
+ }
+
+ 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 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..32c97fef9e
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillsProviderTests.cs
@@ -0,0 +1,190 @@
+// 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 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);
+ }
+
+ 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}");
+ }
+}
\ No newline at end of file
From 7fbb3bd644cd296f42821ae31115cb8d022d8a70 Mon Sep 17 00:00:00 2001
From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com>
Date: Fri, 20 Feb 2026 11:03:04 +0000
Subject: [PATCH 02/16] make the new agent skill provider experimental
---
.../Agent_Step01_BasicSkills/Agent_Step01_BasicSkills.csproj | 1 +
dotnet/src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj | 4 +++-
.../src/Microsoft.Agents.AI/Skills/FileAgentSkillsProvider.cs | 3 +++
.../Microsoft.Agents.AI.UnitTests.csproj | 4 ++++
4 files changed, 11 insertions(+), 1 deletion(-)
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
index 935713bb7a..2a503bbfb2 100644
--- a/dotnet/samples/GettingStarted/AgentSkills/Agent_Step01_BasicSkills/Agent_Step01_BasicSkills.csproj
+++ b/dotnet/samples/GettingStarted/AgentSkills/Agent_Step01_BasicSkills/Agent_Step01_BasicSkills.csproj
@@ -6,6 +6,7 @@
enable
enable
+ $(NoWarn);MAAI001
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/FileAgentSkillsProvider.cs b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProvider.cs
index 538048a488..9b71227722 100644
--- a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProvider.cs
+++ b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProvider.cs
@@ -2,6 +2,7 @@
using System;
using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Security;
using System.Text;
@@ -10,6 +11,7 @@
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
@@ -36,6 +38,7 @@ namespace Microsoft.Agents.AI;
/// Only use skills from trusted sources.
///
///
+[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed partial class FileAgentSkillsProvider : AIContextProvider
{
private const string DefaultSkillsInstructionPrompt =
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
From ee96900f2702215b3b129636c7ae731963b9a7b4 Mon Sep 17 00:00:00 2001
From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com>
Date: Fri, 20 Feb 2026 11:11:30 +0000
Subject: [PATCH 03/16] Fix file encoding: add UTF-8 BOM to .cs files
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../AgentSkills/Agent_Step01_BasicSkills/Program.cs | 2 +-
dotnet/src/Microsoft.Agents.AI/Skills/SkillFrontmatter.cs | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/dotnet/samples/GettingStarted/AgentSkills/Agent_Step01_BasicSkills/Program.cs b/dotnet/samples/GettingStarted/AgentSkills/Agent_Step01_BasicSkills/Program.cs
index 5a9b763d99..2cc9fb0474 100644
--- a/dotnet/samples/GettingStarted/AgentSkills/Agent_Step01_BasicSkills/Program.cs
+++ b/dotnet/samples/GettingStarted/AgentSkills/Agent_Step01_BasicSkills/Program.cs
@@ -1,4 +1,4 @@
-// Copyright (c) Microsoft. All rights reserved.
+// 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.
diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/SkillFrontmatter.cs b/dotnet/src/Microsoft.Agents.AI/Skills/SkillFrontmatter.cs
index b1f3bcb82b..de9c116ebe 100644
--- a/dotnet/src/Microsoft.Agents.AI/Skills/SkillFrontmatter.cs
+++ b/dotnet/src/Microsoft.Agents.AI/Skills/SkillFrontmatter.cs
@@ -1,4 +1,4 @@
-// Copyright (c) Microsoft. All rights reserved.
+// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Shared.Diagnostics;
From bc23952ce23046b0d2f17b83e018e87cd38e617f Mon Sep 17 00:00:00 2001
From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com>
Date: Fri, 20 Feb 2026 11:15:42 +0000
Subject: [PATCH 04/16] Fix final newline and simplify new expressions
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../AgentSkills/FileAgentSkillLoaderTests.cs | 4 ++--
.../AgentSkills/FileAgentSkillsProviderTests.cs | 2 +-
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs
index c430a48b08..77701935e7 100644
--- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs
@@ -299,7 +299,7 @@ await Assert.ThrowsAsync(
public void DiscoverAndLoadSkills_NameExceedsMaxLength_ExcludesSkill()
{
// Arrange — name longer than 64 characters
- string longName = new string('a', 65);
+ string longName = new('a', 65);
string skillDir = Path.Combine(this._testRoot, "long-name");
Directory.CreateDirectory(skillDir);
File.WriteAllText(
@@ -317,7 +317,7 @@ public void DiscoverAndLoadSkills_NameExceedsMaxLength_ExcludesSkill()
public void DiscoverAndLoadSkills_DescriptionExceedsMaxLength_ExcludesSkill()
{
// Arrange — description longer than 1024 characters
- string longDesc = new string('x', 1025);
+ string longDesc = new('x', 1025);
string skillDir = Path.Combine(this._testRoot, "long-desc");
Directory.CreateDirectory(skillDir);
File.WriteAllText(
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillsProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillsProviderTests.cs
index 32c97fef9e..2ca7accfe1 100644
--- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillsProviderTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillsProviderTests.cs
@@ -187,4 +187,4 @@ private static void CreateSkillIn(string root, string name, string description,
Path.Combine(skillDir, "SKILL.md"),
$"---\nname: {name}\ndescription: {description}\n---\n{body}");
}
-}
\ No newline at end of file
+}
From 6b33e2216716b1ce6f9a3e9596d77b5eb3f52a95 Mon Sep 17 00:00:00 2001
From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com>
Date: Fri, 20 Feb 2026 11:17:04 +0000
Subject: [PATCH 05/16] Fix broken links in Agent Skills sample README
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../AgentSkills/Agent_Step01_BasicSkills/README.md | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/dotnet/samples/GettingStarted/AgentSkills/Agent_Step01_BasicSkills/README.md b/dotnet/samples/GettingStarted/AgentSkills/Agent_Step01_BasicSkills/README.md
index 59fd36b130..78099fa8a5 100644
--- a/dotnet/samples/GettingStarted/AgentSkills/Agent_Step01_BasicSkills/README.md
+++ b/dotnet/samples/GettingStarted/AgentSkills/Agent_Step01_BasicSkills/README.md
@@ -60,5 +60,4 @@ The sample runs two examples:
## Learn More
- [Agent Skills Specification](https://agentskills.io/)
-- [Microsoft Agent Framework Documentation](../../../../docs/)
-- [AIContextProvider Pattern](../Agent_Step20_AdditionalAIContext/)
+- [Microsoft Agent Framework Documentation](../../../../../docs/)
From 2db5638acc8b89068cdc5e677b0d52f323028c54 Mon Sep 17 00:00:00 2001
From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com>
Date: Fri, 20 Feb 2026 11:21:58 +0000
Subject: [PATCH 06/16] Add null check for skillPaths parameter
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../src/Microsoft.Agents.AI/Skills/FileAgentSkillsProvider.cs | 3 +++
1 file changed, 3 insertions(+)
diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProvider.cs b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProvider.cs
index 9b71227722..19ce713f6a 100644
--- a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProvider.cs
+++ b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProvider.cs
@@ -12,6 +12,7 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Shared.DiagnosticIds;
+using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
@@ -83,6 +84,8 @@ public FileAgentSkillsProvider(string skillPath, FileAgentSkillsProviderOptions?
/// 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);
From 39b183cc53738adedb3ea2124a4e4de728e150da Mon Sep 17 00:00:00 2001
From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com>
Date: Fri, 20 Feb 2026 12:40:19 +0000
Subject: [PATCH 07/16] Normalize references
---
.../Skills/FileAgentSkillLoader.cs | 21 ++++-
.../AgentSkills/FileAgentSkillLoaderTests.cs | 89 +++++++++++++++++++
2 files changed, 109 insertions(+), 1 deletion(-)
diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillLoader.cs b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillLoader.cs
index 398ddc8810..a9cc916f01 100644
--- a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillLoader.cs
+++ b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillLoader.cs
@@ -124,6 +124,8 @@ internal Dictionary DiscoverAndLoadSkills(IEnumerable
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}'.");
@@ -347,7 +349,7 @@ private static List ExtractResourcePaths(string content)
var paths = new List();
foreach (Match m in s_resourceLinkRegex.Matches(content))
{
- string path = m.Groups[1].Value;
+ string path = NormalizeResourcePath(m.Groups[1].Value);
if (seen.Add(path))
{
paths.Add(path);
@@ -357,6 +359,23 @@ private static List ExtractResourcePaths(string content)
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)
+ {
+ 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);
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs
index 77701935e7..cd80925f35 100644
--- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs
@@ -351,6 +351,95 @@ public void DiscoverAndLoadSkills_DuplicateResourceLinks_DeduplicatesResources()
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);
+ }
+
private string CreateSkillDirectory(string name, string description, string body)
{
string skillDir = Path.Combine(this._testRoot, name);
From 5c30999130e924868734f1dffdc23da2d3c74c12 Mon Sep 17 00:00:00 2001
From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com>
Date: Fri, 20 Feb 2026 12:58:49 +0000
Subject: [PATCH 08/16] normilize skill path
---
dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillLoader.cs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillLoader.cs b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillLoader.cs
index a9cc916f01..e181c4f47d 100644
--- a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillLoader.cs
+++ b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillLoader.cs
@@ -182,7 +182,7 @@ private static void SearchDirectoriesForSkills(string directory, List re
string skillFilePath = Path.Combine(directory, SkillFileName);
if (File.Exists(skillFilePath))
{
- results.Add(directory);
+ results.Add(Path.GetFullPath(directory));
}
if (currentDepth >= MaxSearchDepth)
From 2b8c0c50a828c5c64eccd96fca0ef131efc3329c Mon Sep 17 00:00:00 2001
From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com>
Date: Fri, 20 Feb 2026 14:08:37 +0000
Subject: [PATCH 09/16] address comments regarding symlink check
---
.../Skills/FileAgentSkillLoader.cs | 59 ++++++++-------
.../AgentSkills/FileAgentSkillLoaderTests.cs | 72 +++++++++++++++++++
2 files changed, 106 insertions(+), 25 deletions(-)
diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillLoader.cs b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillLoader.cs
index e181c4f47d..43b11f2a8e 100644
--- a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillLoader.cs
+++ b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillLoader.cs
@@ -139,16 +139,14 @@ internal async Task ReadSkillResourceAsync(FileAgentSkill skill, string
throw new InvalidOperationException($"Resource file '{resourceName}' references a path outside the skill directory.");
}
-#if NET
- if (!IsSymlinkWithinDirectory(fullPath, normalizedSourcePath))
+ if (!File.Exists(fullPath))
{
- throw new InvalidOperationException($"Resource file '{resourceName}' is a symlink that resolves outside the skill directory.");
+ throw new InvalidOperationException($"Resource file '{resourceName}' not found at '{fullPath}'.");
}
-#endif
- if (!File.Exists(fullPath))
+ if (HasSymlinkInPath(fullPath, normalizedSourcePath))
{
- throw new InvalidOperationException($"Resource file '{resourceName}' not found at '{fullPath}'.");
+ throw new InvalidOperationException($"Resource file '{resourceName}' is a symlink that resolves outside the skill directory.");
}
LogResourceReading(this._logger, resourceName, skill.Frontmatter.Name);
@@ -297,17 +295,15 @@ private bool ValidateResources(string skillDirectoryPath, List resourceN
return false;
}
-#if NET
- if (!IsSymlinkWithinDirectory(fullPath, normalizedSkillPath))
+ if (!File.Exists(fullPath))
{
- LogResourceSymlinkEscape(this._logger, skillName, resourceName);
+ LogMissingResource(this._logger, skillName, resourceName, fullPath);
return false;
}
-#endif
- if (!File.Exists(fullPath))
+ if (HasSymlinkInPath(fullPath, normalizedSkillPath))
{
- LogMissingResource(this._logger, skillName, resourceName, fullPath);
+ LogResourceSymlinkEscape(this._logger, skillName, resourceName);
return false;
}
}
@@ -324,24 +320,39 @@ private static bool IsPathWithinDirectory(string fullPath, string normalizedDire
return fullPath.StartsWith(normalizedDirectoryPath, StringComparison.OrdinalIgnoreCase);
}
-#if NET
///
- /// Checks that a symlink at does not resolve outside
- /// .
+ /// Checks whether any segment in (relative to
+ /// ) is a symlink (reparse point).
+ /// Uses which is available on all target frameworks.
///
- private static bool IsSymlinkWithinDirectory(string fullPath, string normalizedDirectoryPath)
+ private static bool HasSymlinkInPath(string fullPath, string normalizedDirectoryPath)
{
- // ResolveLinkTarget with returnFinalTarget: true follows the full symlink chain,
- // guarding against chained symlinks that escape the skill directory.
- string? resolvedTarget = File.ResolveLinkTarget(fullPath, returnFinalTarget: true)?.FullName;
- if (resolvedTarget != null)
+ 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)
{
- return resolvedTarget.StartsWith(normalizedDirectoryPath, StringComparison.OrdinalIgnoreCase);
+ currentPath = Path.Combine(currentPath, segment);
+
+ if (Directory.Exists(currentPath) &&
+ (new DirectoryInfo(currentPath).Attributes & FileAttributes.ReparsePoint) != 0)
+ {
+ return true;
+ }
+
+ if (File.Exists(currentPath) &&
+ (new FileInfo(currentPath).Attributes & FileAttributes.ReparsePoint) != 0)
+ {
+ return true;
+ }
}
- return true;
+ return false;
}
-#endif
private static List ExtractResourcePaths(string content)
{
@@ -403,10 +414,8 @@ private static string NormalizeResourcePath(string path)
[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);
-#if NET
[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);
-#endif
[LoggerMessage(LogLevel.Information, "Reading resource '{FileName}' from skill '{SkillName}'")]
private static partial void LogResourceReading(ILogger logger, string fileName, string skillName);
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs
index cd80925f35..f883cf02d0 100644
--- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs
@@ -440,6 +440,78 @@ public async Task ReadSkillResourceAsync_DotSlashWithBackslash_MatchesNormalized
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
+
private string CreateSkillDirectory(string name, string description, string body)
{
string skillDir = Path.Combine(this._testRoot, name);
From 7a39dd0b7f3d79c8fa972d72120e58b00f7b6f4c Mon Sep 17 00:00:00 2001
From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com>
Date: Fri, 20 Feb 2026 14:51:55 +0000
Subject: [PATCH 10/16] address comments
---
.../Skills/FileAgentSkillLoader.cs | 5 +-
.../Skills/FileAgentSkillsProvider.cs | 51 ++++++++++++++-----
.../AgentSkills/FileAgentSkillLoaderTests.cs | 26 ++++++++++
.../FileAgentSkillsProviderTests.cs | 38 ++++++++++++++
4 files changed, 105 insertions(+), 15 deletions(-)
diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillLoader.cs b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillLoader.cs
index 43b11f2a8e..9f55302de8 100644
--- a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillLoader.cs
+++ b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillLoader.cs
@@ -29,11 +29,12 @@ internal sealed partial class FileAgentSkillLoader
// 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"
#if NET
- private static readonly Regex s_frontmatterRegex = new(@"^---\s*$(.+?)^---\s*$", RegexOptions.Multiline | RegexOptions.Singleline | RegexOptions.Compiled, TimeSpan.FromSeconds(5));
+ private static readonly Regex s_frontmatterRegex = new(@"\A\uFEFF?^---\s*$(.+?)^---\s*$", RegexOptions.Multiline | RegexOptions.Singleline | RegexOptions.Compiled, TimeSpan.FromSeconds(5));
#else
- private static readonly Regex s_frontmatterRegex = new(@"^---\s*$(.+?)^---\s*$", RegexOptions.Multiline | RegexOptions.Singleline | RegexOptions.Compiled);
+ private static readonly Regex s_frontmatterRegex = new(@"\A\uFEFF?^---\s*$(.+?)^---\s*$", RegexOptions.Multiline | RegexOptions.Singleline | RegexOptions.Compiled);
#endif
// Matches markdown links to local resource files. Group 1 = relative file path.
diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProvider.cs b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProvider.cs
index 19ce713f6a..3732a7449e 100644
--- a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProvider.cs
+++ b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProvider.cs
@@ -63,7 +63,7 @@ 3. Use `read_skill_resource` to read any references or other files mentioned by
private readonly ILogger _logger;
private readonly FileAgentSkillLoader _loader;
private readonly AITool[] _tools;
- private readonly string _skillsInstructionPrompt;
+ private readonly string? _skillsInstructionPrompt;
///
/// Initializes a new instance of the class that searches a single directory for skills.
@@ -91,10 +91,7 @@ public FileAgentSkillsProvider(IEnumerable skillPaths, FileAgentSkillsPr
this._loader = new FileAgentSkillLoader(this._logger);
this._skills = this._loader.DiscoverAndLoadSkills(skillPaths);
- string promptTemplate = options?.SkillsInstructionPrompt ?? DefaultSkillsInstructionPrompt;
- this._skillsInstructionPrompt = this._skills.Count > 0
- ? string.Format(promptTemplate, this.BuildSkillsListPrompt())
- : string.Empty;
+ this._skillsInstructionPrompt = BuildSkillsInstructionPrompt(options, this._skills);
this._tools =
[
@@ -119,11 +116,13 @@ protected override ValueTask InvokingCoreAsync(InvokingContext contex
return new ValueTask(inputContext);
}
- string fullPrompt = this._skillsInstructionPrompt;
-
- string? instructions = inputContext.Instructions is not null
- ? inputContext.Instructions + "\n" + fullPrompt
- : fullPrompt;
+ string? instructions = inputContext.Instructions;
+ if (!string.IsNullOrEmpty(this._skillsInstructionPrompt))
+ {
+ instructions = instructions is not null
+ ? instructions + "\n" + this._skillsInstructionPrompt
+ : this._skillsInstructionPrompt;
+ }
return new ValueTask(new AIContext
{
@@ -178,17 +177,43 @@ private async Task ReadSkillResourceAsync(string skillName, string resou
}
}
- private string BuildSkillsListPrompt()
+ private static string? BuildSkillsInstructionPrompt(FileAgentSkillsProviderOptions? options, Dictionary skills)
{
+ string promptTemplate = options?.SkillsInstructionPrompt ?? DefaultSkillsInstructionPrompt;
+
+ if (options?.SkillsInstructionPrompt is not null)
+ {
+ try
+ {
+ _ = string.Format(promptTemplate, 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();
- foreach (var skill in this._skills.Values)
+
+ // 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 sb.ToString().TrimEnd();
+
+ return string.Format(promptTemplate, sb.ToString().TrimEnd());
}
[LoggerMessage(LogLevel.Information, "Loading skill: {SkillName}")]
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs
index f883cf02d0..5c416f532a 100644
--- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs
@@ -512,6 +512,24 @@ await Assert.ThrowsAsync(
}
#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);
@@ -522,6 +540,14 @@ private string CreateSkillDirectory(string name, string description, string 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);
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillsProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillsProviderTests.cs
index 2ca7accfe1..6bfaf1b546 100644
--- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillsProviderTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillsProviderTests.cs
@@ -110,6 +110,21 @@ public async Task InvokingCoreAsync_CustomPromptTemplate_UsesCustomTemplateAsync
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()
{
@@ -174,6 +189,29 @@ public async Task InvokingCoreAsync_PreservesExistingInputToolsAsync()
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);
From 3fcdd26c2f0a8ec1f5efa018129b6530905e6e1d Mon Sep 17 00:00:00 2001
From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com>
Date: Fri, 20 Feb 2026 15:10:05 +0000
Subject: [PATCH 11/16] fix failing test + regex improvements
---
.../Skills/FileAgentSkillLoader.cs | 20 ++++---------------
.../AgentSkills/FileAgentSkillLoaderTests.cs | 6 ++++--
2 files changed, 8 insertions(+), 18 deletions(-)
diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillLoader.cs b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillLoader.cs
index 9f55302de8..79c9662c82 100644
--- a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillLoader.cs
+++ b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillLoader.cs
@@ -31,11 +31,7 @@ internal sealed partial class FileAgentSkillLoader
// 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"
-#if NET
private static readonly Regex s_frontmatterRegex = new(@"\A\uFEFF?^---\s*$(.+?)^---\s*$", RegexOptions.Multiline | RegexOptions.Singleline | RegexOptions.Compiled, TimeSpan.FromSeconds(5));
-#else
- private static readonly Regex s_frontmatterRegex = new(@"\A\uFEFF?^---\s*$(.+?)^---\s*$", RegexOptions.Multiline | RegexOptions.Singleline | RegexOptions.Compiled);
-#endif
// Matches markdown links to local resource files. Group 1 = relative file path.
// Supports optional ./ or ../ prefixes; excludes URLs (no ":" in the path character class).
@@ -43,21 +39,13 @@ internal sealed partial class FileAgentSkillLoader
// 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"
-#if NET
private static readonly Regex s_resourceLinkRegex = new(@"\[.*?\]\((\.?\.?/?[\w][\w\-./]*\.\w+)\)", RegexOptions.Compiled, TimeSpan.FromSeconds(5));
-#else
- private static readonly Regex s_resourceLinkRegex = new(@"\[.*?\]\((\.?\.?/?[\w][\w\-./]*\.\w+)\)", RegexOptions.Compiled);
-#endif
// 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, _)
-#if NET
private static readonly Regex s_yamlKeyValueRegex = new(@"^\s*(\w+)\s*:\s*(?:[""'](.+?)[""']|(.+?))\s*$", RegexOptions.Multiline | RegexOptions.Compiled, TimeSpan.FromSeconds(5));
-#else
- private static readonly Regex s_yamlKeyValueRegex = new(@"^\s*(\w+)\s*:\s*(?:[""'](.+?)[""']|(.+?))\s*$", RegexOptions.Multiline | RegexOptions.Compiled);
-#endif
// Validates skill names: lowercase letters, numbers, and hyphens only; must not start or end with a hyphen.
// Examples: "my-skill" ✓, "skill123" ✓, "-bad" ✗, "bad-" ✗, "Bad" ✗
@@ -142,7 +130,7 @@ internal async Task ReadSkillResourceAsync(FileAgentSkill skill, string
if (!File.Exists(fullPath))
{
- throw new InvalidOperationException($"Resource file '{resourceName}' not found at '{fullPath}'.");
+ throw new InvalidOperationException($"Resource file '{resourceName}' not found in skill '{skill.Frontmatter.Name}'.");
}
if (HasSymlinkInPath(fullPath, normalizedSourcePath))
@@ -298,7 +286,7 @@ private bool ValidateResources(string skillDirectoryPath, List resourceN
if (!File.Exists(fullPath))
{
- LogMissingResource(this._logger, skillName, resourceName, fullPath);
+ LogMissingResource(this._logger, skillName, resourceName);
return false;
}
@@ -406,8 +394,8 @@ private static string NormalizeResourcePath(string path)
[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 at '{FullPath}'")]
- private static partial void LogMissingResource(ILogger logger, string skillName, string resourceName, string fullPath);
+ [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);
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs
index 5c416f532a..c34eb6d7f2 100644
--- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs
@@ -161,9 +161,11 @@ public void DiscoverAndLoadSkills_DuplicateNames_KeepsFirstOnly()
// Act
var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
- // Assert
+ // Assert – filesystem enumeration order is not guaranteed, so we only
+ // verify that exactly one of the two duplicates was kept.
Assert.Single(skills);
- Assert.Equal("First", skills["dupe"].Frontmatter.Description);
+ string desc = skills["dupe"].Frontmatter.Description;
+ Assert.True(desc == "First" || desc == "Second", $"Unexpected description: {desc}");
}
[Fact]
From 09911379d7ff38b33d7c77d5af4e8cc76f4dac52 Mon Sep 17 00:00:00 2001
From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com>
Date: Fri, 20 Feb 2026 15:18:39 +0000
Subject: [PATCH 12/16] small optimizations and improvments
---
.../Skills/FileAgentSkillLoader.cs | 16 ++++++----------
1 file changed, 6 insertions(+), 10 deletions(-)
diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillLoader.cs b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillLoader.cs
index 79c9662c82..8c034b3122 100644
--- a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillLoader.cs
+++ b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillLoader.cs
@@ -177,7 +177,7 @@ private static void SearchDirectoriesForSkills(string directory, List re
return;
}
- foreach (string subdirectory in Directory.GetDirectories(directory))
+ foreach (string subdirectory in Directory.EnumerateDirectories(directory))
{
SearchDirectoriesForSkills(subdirectory, results, currentDepth + 1);
}
@@ -327,14 +327,7 @@ private static bool HasSymlinkInPath(string fullPath, string normalizedDirectory
{
currentPath = Path.Combine(currentPath, segment);
- if (Directory.Exists(currentPath) &&
- (new DirectoryInfo(currentPath).Attributes & FileAttributes.ReparsePoint) != 0)
- {
- return true;
- }
-
- if (File.Exists(currentPath) &&
- (new FileInfo(currentPath).Attributes & FileAttributes.ReparsePoint) != 0)
+ if ((File.GetAttributes(currentPath) & FileAttributes.ReparsePoint) != 0)
{
return true;
}
@@ -366,7 +359,10 @@ private static List ExtractResourcePaths(string content)
///
private static string NormalizeResourcePath(string path)
{
- path = path.Replace('\\', '/');
+ if (path.IndexOf('\\') >= 0)
+ {
+ path = path.Replace('\\', '/');
+ }
if (path.StartsWith("./", StringComparison.Ordinal))
{
From b2939f771369f3089684432b35ddfb54831179dd Mon Sep 17 00:00:00 2001
From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com>
Date: Fri, 20 Feb 2026 18:22:58 +0000
Subject: [PATCH 13/16] address pr review comments
---
.../Agent_Step01_BasicSkills/Program.cs | 2 +-
.../Skills/FileAgentSkillsProvider.cs | 19 ++++---------------
.../Skills/SkillFrontmatter.cs | 4 ++--
3 files changed, 7 insertions(+), 18 deletions(-)
diff --git a/dotnet/samples/GettingStarted/AgentSkills/Agent_Step01_BasicSkills/Program.cs b/dotnet/samples/GettingStarted/AgentSkills/Agent_Step01_BasicSkills/Program.cs
index 2cc9fb0474..290c3f9b6b 100644
--- a/dotnet/samples/GettingStarted/AgentSkills/Agent_Step01_BasicSkills/Program.cs
+++ b/dotnet/samples/GettingStarted/AgentSkills/Agent_Step01_BasicSkills/Program.cs
@@ -19,7 +19,7 @@
// --- Skills Provider ---
// Discovers skills from the 'skills' directory and makes them available to the agent
-var skillsProvider = new FileAgentSkillsProvider(skillPath: Path.Combine(Directory.GetCurrentDirectory(), "skills"));
+var skillsProvider = new FileAgentSkillsProvider(skillPath: Path.Combine(AppContext.BaseDirectory, "skills"));
// --- Agent Setup ---
AIAgent agent = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential())
diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProvider.cs b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProvider.cs
index 3732a7449e..bd5e9a064c 100644
--- a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProvider.cs
+++ b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProvider.cs
@@ -107,28 +107,17 @@ public FileAgentSkillsProvider(IEnumerable skillPaths, FileAgentSkillsPr
}
///
- protected override ValueTask InvokingCoreAsync(InvokingContext context, CancellationToken cancellationToken = default)
+ protected override ValueTask ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default)
{
- var inputContext = context.AIContext;
-
if (this._skills.Count == 0)
{
- return new ValueTask(inputContext);
- }
-
- string? instructions = inputContext.Instructions;
- if (!string.IsNullOrEmpty(this._skillsInstructionPrompt))
- {
- instructions = instructions is not null
- ? instructions + "\n" + this._skillsInstructionPrompt
- : this._skillsInstructionPrompt;
+ return base.ProvideAIContextAsync(context, cancellationToken);
}
return new ValueTask(new AIContext
{
- Instructions = instructions,
- Messages = inputContext.Messages,
- Tools = (inputContext.Tools ?? []).Concat(this._tools)
+ Instructions = this._skillsInstructionPrompt,
+ Tools = this._tools
});
}
diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/SkillFrontmatter.cs b/dotnet/src/Microsoft.Agents.AI/Skills/SkillFrontmatter.cs
index de9c116ebe..123a6c43f4 100644
--- a/dotnet/src/Microsoft.Agents.AI/Skills/SkillFrontmatter.cs
+++ b/dotnet/src/Microsoft.Agents.AI/Skills/SkillFrontmatter.cs
@@ -12,8 +12,8 @@ internal sealed class SkillFrontmatter
///
/// Initializes a new instance of the class.
///
- /// Skill name (lowercase, hyphens, max 64 characters).
- /// Skill description (max 1024 characters).
+ /// Skill name.
+ /// Skill description.
public SkillFrontmatter(string name, string description)
{
this.Name = Throw.IfNullOrWhitespace(name);
From 1ded3afc51142e0b8da50698f1f6bb85a3c1dc35 Mon Sep 17 00:00:00 2001
From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com>
Date: Fri, 20 Feb 2026 18:49:57 +0000
Subject: [PATCH 14/16] Update
dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProvider.cs
Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com>
---
.../Microsoft.Agents.AI/Skills/FileAgentSkillsProvider.cs | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProvider.cs b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProvider.cs
index bd5e9a064c..6f2af4f96a 100644
--- a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProvider.cs
+++ b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProvider.cs
@@ -168,13 +168,13 @@ private async Task ReadSkillResourceAsync(string skillName, string resou
private static string? BuildSkillsInstructionPrompt(FileAgentSkillsProviderOptions? options, Dictionary skills)
{
- string promptTemplate = options?.SkillsInstructionPrompt ?? DefaultSkillsInstructionPrompt;
+ string promptTemplate = DefaultSkillsInstructionPrompt;
- if (options?.SkillsInstructionPrompt is not null)
+ if (options?.SkillsInstructionPrompt is not null optionsInstructions)
{
try
{
- _ = string.Format(promptTemplate, string.Empty);
+ promptTemplate = string.Format(optionsInstructions, string.Empty);
}
catch (FormatException ex)
{
From 03d557e22d1400b1378aad92250eea106442a14e Mon Sep 17 00:00:00 2001
From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com>
Date: Fri, 20 Feb 2026 18:54:36 +0000
Subject: [PATCH 15/16] address pr review comments
---
.../Skills/FileAgentSkillsProviderOptions.cs | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProviderOptions.cs b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProviderOptions.cs
index ea435774ae..a47841c260 100644
--- a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProviderOptions.cs
+++ b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProviderOptions.cs
@@ -1,10 +1,14 @@
// 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
{
///
From ad73840b3b3212b4bc1f4e6254789bf27e4d863d Mon Sep 17 00:00:00 2001
From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com>
Date: Fri, 20 Feb 2026 18:59:53 +0000
Subject: [PATCH 16/16] address pr review comments
---
.../src/Microsoft.Agents.AI/Skills/FileAgentSkillsProvider.cs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProvider.cs b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProvider.cs
index 6f2af4f96a..847bf36a52 100644
--- a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProvider.cs
+++ b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProvider.cs
@@ -170,7 +170,7 @@ private async Task ReadSkillResourceAsync(string skillName, string resou
{
string promptTemplate = DefaultSkillsInstructionPrompt;
- if (options?.SkillsInstructionPrompt is not null optionsInstructions)
+ if (options?.SkillsInstructionPrompt is { } optionsInstructions)
{
try
{