diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx
index af7ca9f0be..681fecceec 100644
--- a/dotnet/agent-framework-dotnet.slnx
+++ b/dotnet/agent-framework-dotnet.slnx
@@ -101,7 +101,7 @@
-
+
diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/Program.cs b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/Program.cs
deleted file mode 100644
index 9b0a4b4f99..0000000000
--- a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/Program.cs
+++ /dev/null
@@ -1,50 +0,0 @@
-// Copyright (c) Microsoft. All rights reserved.
-
-// This sample demonstrates how to use Agent Skills with a ChatClientAgent.
-// Agent Skills are modular packages of instructions and resources that extend an agent's capabilities.
-// Skills follow the progressive disclosure pattern: advertise -> load -> read resources.
-//
-// This sample includes the expense-report skill:
-// - Policy-based expense filing with references and assets
-
-using Azure.AI.OpenAI;
-using Azure.Identity;
-using Microsoft.Agents.AI;
-using OpenAI.Responses;
-
-// --- Configuration ---
-string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT")
- ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
-string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
-
-// --- Skills Provider ---
-// Discovers skills from the 'skills' directory and makes them available to the agent
-var skillsProvider = new FileAgentSkillsProvider(skillPath: Path.Combine(AppContext.BaseDirectory, "skills"));
-
-// --- Agent Setup ---
-AIAgent agent = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential())
- .GetResponsesClient()
- .AsAIAgent(new ChatClientAgentOptions
- {
- Name = "SkillsAgent",
- ChatOptions = new()
- {
- Instructions = "You are a helpful assistant.",
- },
- AIContextProviders = [skillsProvider],
- },
- model: deploymentName);
-
-// --- 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/02-agents/AgentSkills/Agent_Step01_BasicSkills/README.md b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/README.md
deleted file mode 100644
index 78099fa8a5..0000000000
--- a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/README.md
+++ /dev/null
@@ -1,63 +0,0 @@
-# Agent Skills Sample
-
-This sample demonstrates how to use **Agent Skills** with a `ChatClientAgent` in the Microsoft Agent Framework.
-
-## What are Agent Skills?
-
-Agent Skills are modular packages of instructions and resources that enable AI agents to perform specialized tasks. They follow the [Agent Skills specification](https://agentskills.io/) and implement the progressive disclosure pattern:
-
-1. **Advertise**: Skills are advertised with name + description (~100 tokens per skill)
-2. **Load**: Full instructions are loaded on-demand via `load_skill` tool
-3. **Resources**: References and other files loaded via `read_skill_resource` tool
-
-## Skills Included
-
-### expense-report
-Policy-based expense filing with spending limits, receipt requirements, and approval workflows.
-- `references/POLICY_FAQ.md` — Detailed expense policy Q&A
-- `assets/expense-report-template.md` — Submission template
-
-## Project Structure
-
-```
-Agent_Step01_BasicSkills/
-├── Program.cs
-├── Agent_Step01_BasicSkills.csproj
-└── skills/
- └── expense-report/
- ├── SKILL.md
- ├── references/
- │ └── POLICY_FAQ.md
- └── assets/
- └── expense-report-template.md
-```
-
-## Running the Sample
-
-### Prerequisites
-- .NET 10.0 SDK
-- Azure OpenAI endpoint with a deployed model
-
-### Setup
-1. Set environment variables:
- ```bash
- export AZURE_OPENAI_ENDPOINT="https://your-endpoint.openai.azure.com/"
- export AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini"
- ```
-
-2. Run the sample:
- ```bash
- dotnet run
- ```
-
-### Examples
-
-The sample runs two examples:
-
-1. **Expense policy FAQ** — Asks about tip reimbursement; the agent loads the expense-report skill and reads the FAQ resource
-2. **Filing an expense report** — Multi-turn conversation to draft an expense report using the template asset
-
-## Learn More
-
-- [Agent Skills Specification](https://agentskills.io/)
-- [Microsoft Agent Framework Documentation](../../../../../docs/)
diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/SKILL.md b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/SKILL.md
deleted file mode 100644
index fc6c83cf30..0000000000
--- a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/SKILL.md
+++ /dev/null
@@ -1,40 +0,0 @@
----
-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/02-agents/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/assets/expense-report-template.md b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/assets/expense-report-template.md
deleted file mode 100644
index 3f7c7dc36c..0000000000
--- a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/assets/expense-report-template.md
+++ /dev/null
@@ -1,5 +0,0 @@
-# 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/02-agents/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/references/POLICY_FAQ.md b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/references/POLICY_FAQ.md
deleted file mode 100644
index 8e971192f8..0000000000
--- a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/references/POLICY_FAQ.md
+++ /dev/null
@@ -1,55 +0,0 @@
-# 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/02-agents/AgentSkills/Agent_Step01_BasicSkills/Agent_Step01_BasicSkills.csproj b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/Agent_Step01_FileBasedSkills.csproj
similarity index 86%
rename from dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/Agent_Step01_BasicSkills.csproj
rename to dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/Agent_Step01_FileBasedSkills.csproj
index 2a503bbfb2..7e7e9ef0fa 100644
--- a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/Agent_Step01_BasicSkills.csproj
+++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/Agent_Step01_FileBasedSkills.csproj
@@ -14,6 +14,10 @@
+
+
+
+
diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/Program.cs b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/Program.cs
new file mode 100644
index 0000000000..b787bb86a3
--- /dev/null
+++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/Program.cs
@@ -0,0 +1,48 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+// This sample demonstrates how to use file-based Agent Skills with a ChatClientAgent.
+// Skills are discovered from SKILL.md files on disk and follow the progressive disclosure pattern:
+// 1. Advertise — skill names and descriptions in the system prompt
+// 2. Load — full instructions loaded on demand via load_skill tool
+// 3. Read resources — reference files read via read_skill_resource tool
+// 4. Run scripts — scripts executed via run_skill_script tool with a subprocess executor
+//
+// This sample uses a unit-converter skill that converts between miles, kilometers, pounds, and kilograms.
+
+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 containing SKILL.md files.
+// The script runner runs file-based scripts (e.g. Python) as local subprocesses.
+var skillsProvider = new AgentSkillsProvider(
+ Path.Combine(AppContext.BaseDirectory, "skills"),
+ SubprocessScriptRunner.RunAsync);
+// --- Agent Setup ---
+AIAgent agent = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential())
+ .GetResponsesClient()
+ .AsAIAgent(new ChatClientAgentOptions
+ {
+ Name = "UnitConverterAgent",
+ ChatOptions = new()
+ {
+ Instructions = "You are a helpful assistant that can convert units.",
+ },
+ AIContextProviders = [skillsProvider],
+ },
+ model: deploymentName);
+
+// --- Example: Unit conversion ---
+Console.WriteLine("Converting units with file-based skills");
+Console.WriteLine(new string('-', 60));
+
+AgentResponse response = await agent.RunAsync(
+ "How many kilometers is a marathon (26.2 miles)? And how many pounds is 75 kilograms?");
+
+Console.WriteLine($"Agent: {response.Text}");
diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/README.md b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/README.md
new file mode 100644
index 0000000000..41b813b98f
--- /dev/null
+++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/README.md
@@ -0,0 +1,51 @@
+# File-Based Agent Skills Sample
+
+This sample demonstrates how to use **file-based Agent Skills** with a `ChatClientAgent`.
+
+## What it demonstrates
+
+- Discovering skills from `SKILL.md` files on disk via `AgentFileSkillsSource`
+- The progressive disclosure pattern: advertise → load → read resources → run scripts
+- Using the `AgentSkillsProvider` constructor with a skill directory path and script executor
+- Running file-based scripts (Python) via a subprocess-based executor
+
+## Skills Included
+
+### unit-converter
+
+Converts between common units (miles↔km, pounds↔kg) using a multiplication factor.
+
+- `references/conversion-table.md` — Conversion factor table
+- `scripts/convert.py` — Python script that performs the conversion
+
+## Running the Sample
+
+### Prerequisites
+
+- .NET 10.0 SDK
+- Azure OpenAI endpoint with a deployed model
+- Python 3 installed and available as `python3` on your PATH
+
+### Setup
+
+```bash
+export AZURE_OPENAI_ENDPOINT="https://your-endpoint.openai.azure.com/"
+export AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini"
+```
+
+### Run
+
+```bash
+dotnet run
+```
+
+### Expected Output
+
+```
+Converting units with file-based skills
+------------------------------------------------------------
+Agent: Here are your conversions:
+
+1. **26.2 miles → 42.16 km** (a marathon distance)
+2. **75 kg → 165.35 lbs**
+```
diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/skills/unit-converter/SKILL.md b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/skills/unit-converter/SKILL.md
new file mode 100644
index 0000000000..6a8e692ff2
--- /dev/null
+++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/skills/unit-converter/SKILL.md
@@ -0,0 +1,11 @@
+---
+name: unit-converter
+description: Convert between common units using a multiplication factor. Use when asked to convert miles, kilometers, pounds, or kilograms.
+---
+
+## Usage
+
+When the user requests a unit conversion:
+1. First, review `references/conversion-table.md` to find the correct factor
+2. Run the `scripts/convert.py` script with `--value --factor ` (e.g. `--value 26.2 --factor 1.60934`)
+3. Present the converted value clearly with both units
diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/skills/unit-converter/references/conversion-table.md b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/skills/unit-converter/references/conversion-table.md
new file mode 100644
index 0000000000..7a0160b854
--- /dev/null
+++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/skills/unit-converter/references/conversion-table.md
@@ -0,0 +1,10 @@
+# Conversion Tables
+
+Formula: **result = value × factor**
+
+| From | To | Factor |
+|-------------|-------------|----------|
+| miles | kilometers | 1.60934 |
+| kilometers | miles | 0.621371 |
+| pounds | kilograms | 0.453592 |
+| kilograms | pounds | 2.20462 |
diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/skills/unit-converter/scripts/convert.py b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/skills/unit-converter/scripts/convert.py
new file mode 100644
index 0000000000..228c8809ff
--- /dev/null
+++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/skills/unit-converter/scripts/convert.py
@@ -0,0 +1,29 @@
+# Unit conversion script
+# Converts a value using a multiplication factor: result = value × factor
+#
+# Usage:
+# python scripts/convert.py --value 26.2 --factor 1.60934
+# python scripts/convert.py --value 75 --factor 2.20462
+
+import argparse
+import json
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(
+ description="Convert a value using a multiplication factor.",
+ epilog="Examples:\n"
+ " python scripts/convert.py --value 26.2 --factor 1.60934\n"
+ " python scripts/convert.py --value 75 --factor 2.20462",
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ )
+ parser.add_argument("--value", type=float, required=True, help="The numeric value to convert.")
+ parser.add_argument("--factor", type=float, required=True, help="The conversion factor from the table.")
+ args = parser.parse_args()
+
+ result = round(args.value * args.factor, 4)
+ print(json.dumps({"value": args.value, "factor": args.factor, "result": result}))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/dotnet/samples/02-agents/AgentSkills/README.md b/dotnet/samples/02-agents/AgentSkills/README.md
index 8488ec9eed..75b850f077 100644
--- a/dotnet/samples/02-agents/AgentSkills/README.md
+++ b/dotnet/samples/02-agents/AgentSkills/README.md
@@ -4,4 +4,4 @@ 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 |
+| [Agent_Step01_FileBasedSkills](Agent_Step01_FileBasedSkills/) | Define skills as `SKILL.md` files on disk with reference documents. Uses a unit-converter skill. |
diff --git a/dotnet/samples/02-agents/AgentSkills/SubprocessScriptRunner.cs b/dotnet/samples/02-agents/AgentSkills/SubprocessScriptRunner.cs
new file mode 100644
index 0000000000..e95bde61df
--- /dev/null
+++ b/dotnet/samples/02-agents/AgentSkills/SubprocessScriptRunner.cs
@@ -0,0 +1,137 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+// Sample subprocess-based skill script runner.
+// Executes file-based skill scripts as local subprocesses.
+// This is provided for demonstration purposes only.
+
+using System.Diagnostics;
+using Microsoft.Agents.AI;
+using Microsoft.Extensions.AI;
+
+///
+/// Executes file-based skill scripts as local subprocesses.
+///
+///
+/// This runner uses the script's absolute path, converts the arguments
+/// to CLI flags, and returns captured output. It is intended for
+/// demonstration purposes only.
+///
+internal static class SubprocessScriptRunner
+{
+ ///
+ /// Runs a skill script as a local subprocess.
+ ///
+ public static async Task RunAsync(
+ AgentFileSkill skill,
+ AgentFileSkillScript script,
+ AIFunctionArguments arguments,
+ CancellationToken cancellationToken)
+ {
+ if (!File.Exists(script.FullPath))
+ {
+ return $"Error: Script file not found: {script.FullPath}";
+ }
+
+ string extension = Path.GetExtension(script.FullPath);
+ string? interpreter = extension switch
+ {
+ ".py" => "python3",
+ ".js" => "node",
+ ".sh" => "bash",
+ ".ps1" => "pwsh",
+ _ => null,
+ };
+
+ var startInfo = new ProcessStartInfo
+ {
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ WorkingDirectory = Path.GetDirectoryName(script.FullPath) ?? ".",
+ };
+
+ if (interpreter is not null)
+ {
+ startInfo.FileName = interpreter;
+ startInfo.ArgumentList.Add(script.FullPath);
+ }
+ else
+ {
+ startInfo.FileName = script.FullPath;
+ }
+
+ if (arguments is not null)
+ {
+ foreach (var (key, value) in arguments)
+ {
+ if (value is bool boolValue)
+ {
+ if (boolValue)
+ {
+ startInfo.ArgumentList.Add(NormalizeKey(key));
+ }
+ }
+ else if (value is not null)
+ {
+ startInfo.ArgumentList.Add(NormalizeKey(key));
+ startInfo.ArgumentList.Add(value.ToString()!);
+ }
+ }
+ }
+
+ Process? process = null;
+ try
+ {
+ process = Process.Start(startInfo);
+ if (process is null)
+ {
+ return $"Error: Failed to start process for script '{script.Name}'.";
+ }
+
+ Task outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken);
+ Task errorTask = process.StandardError.ReadToEndAsync(cancellationToken);
+
+ await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
+
+ string output = await outputTask.ConfigureAwait(false);
+ string error = await errorTask.ConfigureAwait(false);
+
+ if (!string.IsNullOrEmpty(error))
+ {
+ output += $"\nStderr:\n{error}";
+ }
+
+ if (process.ExitCode != 0)
+ {
+ output += $"\nScript exited with code {process.ExitCode}";
+ }
+
+ return string.IsNullOrEmpty(output) ? "(no output)" : output.Trim();
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ // Kill the process on cancellation to avoid leaving orphaned subprocesses.
+ process?.Kill(entireProcessTree: true);
+ throw;
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ return $"Error: Failed to execute script '{script.Name}': {ex.Message}";
+ }
+ finally
+ {
+ process?.Dispose();
+ }
+ }
+
+ ///
+ /// Normalizes a parameter key to a consistent --flag format.
+ /// Models may return keys with or without leading dashes (e.g., "value" vs "--value").
+ ///
+ private static string NormalizeKey(string key) => "--" + key.TrimStart('-');
+}
diff --git a/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/HostAgentFactory.cs b/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/HostAgentFactory.cs
index 584b7db422..1149f9a293 100644
--- a/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/HostAgentFactory.cs
+++ b/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/HostAgentFactory.cs
@@ -59,7 +59,7 @@ private static AgentCard GetInvoiceAgentCard()
PushNotifications = false,
};
- var invoiceQuery = new AgentSkill()
+ var invoiceQuery = new A2A.AgentSkill()
{
Id = "id_invoice_agent",
Name = "InvoiceQuery",
@@ -91,7 +91,7 @@ private static AgentCard GetPolicyAgentCard()
PushNotifications = false,
};
- var policyQuery = new AgentSkill()
+ var policyQuery = new A2A.AgentSkill()
{
Id = "id_policy_agent",
Name = "PolicyAgent",
@@ -123,7 +123,7 @@ private static AgentCard GetLogisticsAgentCard()
PushNotifications = false,
};
- var logisticsQuery = new AgentSkill()
+ var logisticsQuery = new A2A.AgentSkill()
{
Id = "id_logistics_agent",
Name = "LogisticsQuery",
diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkill.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkill.cs
new file mode 100644
index 0000000000..5f0a66808d
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkill.cs
@@ -0,0 +1,58 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.Shared.DiagnosticIds;
+
+namespace Microsoft.Agents.AI;
+
+///
+/// Abstract base class for all agent skills.
+///
+///
+///
+/// A skill represents a domain-specific capability with instructions, resources, and scripts.
+/// Concrete implementations include (filesystem-backed).
+///
+///
+/// Skill metadata follows the Agent Skills specification .
+///
+///
+[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
+public abstract class AgentSkill
+{
+ ///
+ /// Gets the frontmatter metadata for this skill.
+ ///
+ ///
+ /// Contains the L1 discovery metadata (name, description, license, compatibility, etc.)
+ /// as defined by the Agent Skills specification .
+ ///
+ public abstract AgentSkillFrontmatter Frontmatter { get; }
+
+ ///
+ /// Gets the full skill content.
+ ///
+ ///
+ /// For file-based skills this is the raw SKILL.md file content.
+ ///
+ public abstract string Content { get; }
+
+ ///
+ /// Gets the resources associated with this skill, or if none.
+ ///
+ ///
+ /// The default implementation returns .
+ /// Override this property in derived classes to provide skill-specific resources.
+ ///
+ public virtual IReadOnlyList? Resources => null;
+
+ ///
+ /// Gets the scripts associated with this skill, or if none.
+ ///
+ ///
+ /// The default implementation returns .
+ /// Override this property in derived classes to provide skill-specific scripts.
+ ///
+ public virtual IReadOnlyList? Scripts => null;
+}
diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillFrontmatter.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillFrontmatter.cs
new file mode 100644
index 0000000000..df087ff2bb
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillFrontmatter.cs
@@ -0,0 +1,196 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Diagnostics.CodeAnalysis;
+using System.Text.RegularExpressions;
+using Microsoft.Extensions.AI;
+using Microsoft.Shared.DiagnosticIds;
+
+namespace Microsoft.Agents.AI;
+
+///
+/// Represents the YAML frontmatter metadata parsed from a SKILL.md file.
+///
+///
+///
+/// Frontmatter is the L1 (discovery) layer of the
+/// Agent Skills specification .
+/// It contains the minimal metadata needed to advertise a skill in the system prompt
+/// without loading the full skill content.
+///
+///
+/// The constructor validates the name and description against specification rules
+/// and throws if either value is invalid.
+///
+///
+[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
+public sealed class AgentSkillFrontmatter
+{
+ ///
+ /// Maximum allowed length for the skill name.
+ ///
+ internal const int MaxNameLength = 64;
+
+ ///
+ /// Maximum allowed length for the skill description.
+ ///
+ internal const int MaxDescriptionLength = 1024;
+
+ ///
+ /// Maximum allowed length for the compatibility field.
+ ///
+ internal const int MaxCompatibilityLength = 500;
+
+ // Validates skill names per the Agent Skills specification (https://agentskills.io/specification#frontmatter):
+ // lowercase letters, numbers, and hyphens only; must not start or end with a hyphen; must not contain consecutive hyphens.
+ private static readonly Regex s_validNameRegex = new("^[a-z0-9]([a-z0-9]*-[a-z0-9])*[a-z0-9]*$", RegexOptions.Compiled);
+
+ private string? _compatibility;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Skill name in kebab-case.
+ /// Skill description for discovery.
+ /// Optional compatibility information (max 500 chars).
+ ///
+ /// Thrown when , , or violates the
+ /// Agent Skills specification rules.
+ ///
+ public AgentSkillFrontmatter(string name, string description, string? compatibility = null)
+ {
+ if (!ValidateName(name, out string? reason) ||
+ !ValidateDescription(description, out reason) ||
+ !ValidateCompatibility(compatibility, out reason))
+ {
+ throw new ArgumentException(reason);
+ }
+
+ this.Name = name;
+ this.Description = description;
+ this._compatibility = compatibility;
+ }
+
+ ///
+ /// Gets the skill name. Lowercase letters, numbers, and hyphens only; no leading, trailing, or consecutive hyphens.
+ ///
+ public string Name { get; }
+
+ ///
+ /// Gets the skill description. Used for discovery in the system prompt.
+ ///
+ public string Description { get; }
+
+ ///
+ /// Gets or sets an optional license name or reference.
+ ///
+ public string? License { get; set; }
+
+ ///
+ /// Gets or sets optional compatibility information (max 500 chars).
+ ///
+ ///
+ /// Thrown when the value exceeds characters.
+ ///
+ public string? Compatibility
+ {
+ get => this._compatibility;
+ set
+ {
+ if (!ValidateCompatibility(value, out string? reason))
+ {
+ throw new ArgumentException(reason);
+ }
+
+ this._compatibility = value;
+ }
+ }
+
+ ///
+ /// Gets or sets optional space-delimited list of pre-approved tools.
+ ///
+ public string? AllowedTools { get; set; }
+
+ ///
+ /// Gets or sets the arbitrary key-value metadata for this skill.
+ ///
+ public AdditionalPropertiesDictionary? Metadata { get; set; }
+
+ ///
+ /// Validates a skill name against specification rules.
+ ///
+ /// The skill name to validate (may be ).
+ /// When validation fails, contains a human-readable description of the failure.
+ /// if the name is valid; otherwise, .
+ public static bool ValidateName(
+ string? name,
+ [NotNullWhen(false)] out string? reason)
+ {
+ if (string.IsNullOrWhiteSpace(name))
+ {
+ reason = "Skill name is required.";
+ return false;
+ }
+
+ if (name.Length > MaxNameLength)
+ {
+ reason = $"Skill name must be {MaxNameLength} characters or fewer.";
+ return false;
+ }
+
+ if (!s_validNameRegex.IsMatch(name))
+ {
+ reason = "Skill name must use only lowercase letters, numbers, and hyphens, and must not start or end with a hyphen or contain consecutive hyphens.";
+ return false;
+ }
+
+ reason = null;
+ return true;
+ }
+
+ ///
+ /// Validates a skill description against specification rules.
+ ///
+ /// The skill description to validate (may be ).
+ /// When validation fails, contains a human-readable description of the failure.
+ /// if the description is valid; otherwise, .
+ public static bool ValidateDescription(
+ string? description,
+ [NotNullWhen(false)] out string? reason)
+ {
+ if (string.IsNullOrWhiteSpace(description))
+ {
+ reason = "Skill description is required.";
+ return false;
+ }
+
+ if (description.Length > MaxDescriptionLength)
+ {
+ reason = $"Skill description must be {MaxDescriptionLength} characters or fewer.";
+ return false;
+ }
+
+ reason = null;
+ return true;
+ }
+
+ ///
+ /// Validates an optional skill compatibility value against specification rules.
+ ///
+ /// The optional compatibility value to validate (may be ).
+ /// When validation fails, contains a human-readable description of the failure.
+ /// if the value is valid; otherwise, .
+ public static bool ValidateCompatibility(
+ string? compatibility,
+ [NotNullWhen(false)] out string? reason)
+ {
+ if (compatibility?.Length > MaxCompatibilityLength)
+ {
+ reason = $"Skill compatibility must be {MaxCompatibilityLength} characters or fewer.";
+ return false;
+ }
+
+ reason = null;
+ return true;
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillResource.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillResource.cs
new file mode 100644
index 0000000000..b3cfc3f117
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillResource.cs
@@ -0,0 +1,46 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Diagnostics.CodeAnalysis;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Shared.DiagnosticIds;
+using Microsoft.Shared.Diagnostics;
+
+namespace Microsoft.Agents.AI;
+
+///
+/// Abstract base class for skill resources. A resource provides supplementary content (references, assets) to a skill.
+///
+[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
+public abstract class AgentSkillResource
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The resource name (e.g., relative path or identifier).
+ /// An optional description of the resource.
+ protected AgentSkillResource(string name, string? description = null)
+ {
+ this.Name = Throw.IfNullOrWhitespace(name);
+ this.Description = description;
+ }
+
+ ///
+ /// Gets the resource name.
+ ///
+ public string Name { get; }
+
+ ///
+ /// Gets the optional resource description.
+ ///
+ public string? Description { get; }
+
+ ///
+ /// Reads the resource content asynchronously.
+ ///
+ /// Optional service provider for dependency injection.
+ /// Cancellation token.
+ /// The resource content.
+ public abstract Task ReadAsync(IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default);
+}
diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillScript.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillScript.cs
new file mode 100644
index 0000000000..ad647d2eb0
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillScript.cs
@@ -0,0 +1,47 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Diagnostics.CodeAnalysis;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.AI;
+using Microsoft.Shared.DiagnosticIds;
+using Microsoft.Shared.Diagnostics;
+
+namespace Microsoft.Agents.AI;
+
+///
+/// Abstract base class for skill scripts. A script represents an executable action associated with a skill.
+///
+[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
+public abstract class AgentSkillScript
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The script name.
+ /// An optional description of the script.
+ protected AgentSkillScript(string name, string? description = null)
+ {
+ this.Name = Throw.IfNullOrWhitespace(name);
+ this.Description = description;
+ }
+
+ ///
+ /// Gets the script name.
+ ///
+ public string Name { get; }
+
+ ///
+ /// Gets the optional script description.
+ ///
+ public string? Description { get; }
+
+ ///
+ /// Runs the script with the given arguments.
+ ///
+ /// The skill that owns this script.
+ /// Arguments for script execution.
+ /// Cancellation token.
+ /// The script execution result.
+ public abstract Task RunAsync(AgentSkill skill, AIFunctionArguments arguments, CancellationToken cancellationToken = default);
+}
diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProvider.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProvider.cs
new file mode 100644
index 0000000000..f2f87851c0
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProvider.cs
@@ -0,0 +1,383 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Linq;
+using System.Security;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.AI;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Shared.DiagnosticIds;
+using Microsoft.Shared.Diagnostics;
+
+namespace Microsoft.Agents.AI;
+
+///
+/// An that exposes agent skills from one or more instances.
+///
+///
+///
+/// This provider implements the progressive disclosure pattern from the
+/// Agent Skills specification :
+///
+///
+/// Advertise — skill names and descriptions are injected into the system prompt.
+/// Load — the full skill body is returned via the load_skill tool.
+/// Read resources — supplementary content is read on demand via the read_skill_resource tool.
+/// Run scripts — scripts are executed via the run_skill_script tool (when scripts exist).
+///
+///
+[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
+public sealed partial class AgentSkillsProvider : AIContextProvider
+{
+ ///
+ /// Placeholder token for the generated skills list in the prompt template.
+ ///
+ private const string SkillsPlaceholder = "{skills}";
+
+ ///
+ /// Placeholder token for the script instructions in the prompt template.
+ ///
+ private const string ScriptInstructionsPlaceholder = "{script_instructions}";
+
+ ///
+ /// Placeholder token for the resource instructions in the prompt template.
+ ///
+ private const string ResourceInstructionsPlaceholder = "{resource_instructions}";
+
+ 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.
+
+
+ {skills}
+
+
+ When a task aligns with a skill's domain, follow these steps in exact order:
+ - Use `load_skill` to retrieve the skill's instructions.
+ - Follow the provided guidance.
+ {resource_instructions}
+ {script_instructions}
+ Only load what is needed, when it is needed.
+ """;
+
+ private readonly AgentSkillsSource _source;
+ private readonly AgentSkillsProviderOptions? _options;
+ private readonly ILogger _logger;
+ private Task? _contextTask;
+
+ ///
+ /// Initializes a new instance of the class
+ /// that discovers file-based skills from a single directory.
+ /// Duplicate skill names are automatically deduplicated (first occurrence wins).
+ ///
+ /// Path to search for skills.
+ /// Optional delegate that runs file-based scripts. Required only when skills contain scripts.
+ /// Optional options that control skill discovery behavior.
+ /// Optional provider configuration.
+ /// Optional logger factory.
+ public AgentSkillsProvider(
+ string skillPath,
+ AgentFileSkillScriptRunner? scriptRunner = null,
+ AgentFileSkillsSourceOptions? fileOptions = null,
+ AgentSkillsProviderOptions? options = null,
+ ILoggerFactory? loggerFactory = null)
+ : this([Throw.IfNull(skillPath)], scriptRunner, fileOptions, options, loggerFactory)
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the class
+ /// that discovers file-based skills from multiple directories.
+ /// Duplicate skill names are automatically deduplicated (first occurrence wins).
+ ///
+ /// Paths to search for skills.
+ /// Optional delegate that runs file-based scripts. Required only when skills contain scripts.
+ /// Optional options that control skill discovery behavior.
+ /// Optional provider configuration.
+ /// Optional logger factory.
+ public AgentSkillsProvider(
+ IEnumerable skillPaths,
+ AgentFileSkillScriptRunner? scriptRunner = null,
+ AgentFileSkillsSourceOptions? fileOptions = null,
+ AgentSkillsProviderOptions? options = null,
+ ILoggerFactory? loggerFactory = null)
+ : this(
+ new DeduplicatingAgentSkillsSource(
+ new AgentFileSkillsSource(skillPaths, scriptRunner, fileOptions, loggerFactory),
+ loggerFactory),
+ options,
+ loggerFactory)
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the class
+ /// from a custom . Unlike other constructors, this one does not
+ /// apply automatic deduplication, allowing callers to customize deduplication behavior via the source pipeline.
+ ///
+ /// The skill source providing skills.
+ /// Optional configuration.
+ /// Optional logger factory.
+ public AgentSkillsProvider(AgentSkillsSource source, AgentSkillsProviderOptions? options = null, ILoggerFactory? loggerFactory = null)
+ {
+ this._source = Throw.IfNull(source);
+ this._options = options;
+ this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger();
+
+ if (options?.SkillsInstructionPrompt is string prompt)
+ {
+ ValidatePromptTemplate(prompt, nameof(options));
+ }
+ }
+
+ ///
+ protected override async ValueTask ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default)
+ {
+ if (this._options?.DisableCaching == true)
+ {
+ return await this.CreateContextAsync(context, cancellationToken).ConfigureAwait(false);
+ }
+
+ return await this.GetOrCreateContextAsync(context, cancellationToken).ConfigureAwait(false);
+ }
+
+ private async Task CreateContextAsync(InvokingContext context, CancellationToken cancellationToken)
+ {
+ var skills = await this._source.GetSkillsAsync(cancellationToken).ConfigureAwait(false);
+ if (skills is not { Count: > 0 })
+ {
+ return await base.ProvideAIContextAsync(context, cancellationToken).ConfigureAwait(false);
+ }
+
+ bool hasScripts = skills.Any(s => s.Scripts is { Count: > 0 });
+ bool hasResources = skills.Any(s => s.Resources is { Count: > 0 });
+
+ return new AIContext
+ {
+ Instructions = this.BuildSkillsInstructions(skills, includeScriptInstructions: hasScripts, hasResources),
+ Tools = this.BuildTools(skills, hasScripts, hasResources),
+ };
+ }
+
+ private async Task GetOrCreateContextAsync(InvokingContext context, CancellationToken cancellationToken)
+ {
+ var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ if (Interlocked.CompareExchange(ref this._contextTask, tcs.Task, null) is { } existing)
+ {
+ return await existing.ConfigureAwait(false);
+ }
+
+ try
+ {
+ var result = await this.CreateContextAsync(context, cancellationToken).ConfigureAwait(false);
+ tcs.SetResult(result);
+ return result;
+ }
+ catch (Exception ex)
+ {
+ this._contextTask = null;
+ tcs.TrySetException(ex);
+ throw;
+ }
+ }
+
+ private IList BuildTools(IList skills, bool hasScripts, bool hasResources)
+ {
+ IList tools =
+ [
+ AIFunctionFactory.Create(
+ (string skillName) => this.LoadSkill(skills, skillName),
+ name: "load_skill",
+ description: "Loads the full content of a specific skill"),
+ ];
+
+ if (hasResources)
+ {
+ tools.Add(AIFunctionFactory.Create(
+ (string skillName, string resourceName, IServiceProvider? serviceProvider, CancellationToken cancellationToken = default) =>
+ this.ReadSkillResourceAsync(skills, skillName, resourceName, serviceProvider, cancellationToken),
+ name: "read_skill_resource",
+ description: "Reads a resource associated with a skill, such as references, assets, or dynamic data."));
+ }
+
+ if (!hasScripts)
+ {
+ return tools;
+ }
+
+ AIFunction scriptFunction = AIFunctionFactory.Create(
+ (string skillName, string scriptName, IDictionary? arguments = null, IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default) =>
+ this.RunSkillScriptAsync(skills, skillName, scriptName, arguments, serviceProvider, cancellationToken),
+ name: "run_skill_script",
+ description: "Runs a script associated with a skill.");
+
+ if (this._options?.ScriptApproval == true)
+ {
+ return [.. tools, new ApprovalRequiredAIFunction(scriptFunction)];
+ }
+
+ return [.. tools, scriptFunction];
+ }
+
+ private string? BuildSkillsInstructions(IList skills, bool includeScriptInstructions, bool includeResourceInstructions)
+ {
+ string promptTemplate = this._options?.SkillsInstructionPrompt ?? DefaultSkillsInstructionPrompt;
+
+ var sb = new StringBuilder();
+ foreach (var skill in skills.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(" ");
+ }
+
+ string resourceInstruction = includeResourceInstructions
+ ? """
+ - Use `read_skill_resource` to read any referenced resources, using the name exactly as listed
+ (e.g. `"style-guide"` not `"style-guide.md"`, `"references/FAQ.md"` not `"FAQ.md"`).
+ """
+ : string.Empty;
+
+ string scriptInstruction = includeScriptInstructions
+ ? "- Use `run_skill_script` to run referenced scripts, using the name exactly as listed."
+ : string.Empty;
+
+ return new StringBuilder(promptTemplate)
+ .Replace(SkillsPlaceholder, sb.ToString().TrimEnd())
+ .Replace(ResourceInstructionsPlaceholder, resourceInstruction)
+ .Replace(ScriptInstructionsPlaceholder, scriptInstruction)
+ .ToString();
+ }
+
+ private string LoadSkill(IList skills, string skillName)
+ {
+ if (string.IsNullOrWhiteSpace(skillName))
+ {
+ return "Error: Skill name cannot be empty.";
+ }
+
+ var skill = skills?.FirstOrDefault(skill => skill.Frontmatter.Name == skillName);
+ if (skill == null)
+ {
+ return $"Error: Skill '{skillName}' not found.";
+ }
+
+ LogSkillLoading(this._logger, skillName);
+
+ return skill.Content;
+ }
+
+ private async Task ReadSkillResourceAsync(IList skills, string skillName, string resourceName, IServiceProvider? serviceProvider, 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.";
+ }
+
+ var skill = skills?.FirstOrDefault(skill => skill.Frontmatter.Name == skillName);
+ if (skill == null)
+ {
+ return $"Error: Skill '{skillName}' not found.";
+ }
+
+ var resource = skill.Resources?.FirstOrDefault(resource => resource.Name == resourceName);
+ if (resource is null)
+ {
+ return $"Error: Resource '{resourceName}' not found in skill '{skillName}'.";
+ }
+
+ try
+ {
+ return await resource.ReadAsync(serviceProvider, cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ LogResourceReadError(this._logger, skillName, resourceName, ex);
+ return $"Error: Failed to read resource '{resourceName}' from skill '{skillName}'.";
+ }
+ }
+
+ private async Task RunSkillScriptAsync(IList skills, string skillName, string scriptName, IDictionary? arguments = null, IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrWhiteSpace(skillName))
+ {
+ return "Error: Skill name cannot be empty.";
+ }
+
+ if (string.IsNullOrWhiteSpace(scriptName))
+ {
+ return "Error: Script name cannot be empty.";
+ }
+
+ var skill = skills?.FirstOrDefault(skill => skill.Frontmatter.Name == skillName);
+ if (skill == null)
+ {
+ return $"Error: Skill '{skillName}' not found.";
+ }
+
+ var script = skill.Scripts?.FirstOrDefault(resource => resource.Name == scriptName);
+ if (script is null)
+ {
+ return $"Error: Script '{scriptName}' not found in skill '{skillName}'.";
+ }
+
+ try
+ {
+ return await script.RunAsync(skill, new AIFunctionArguments(arguments) { Services = serviceProvider }, cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ LogScriptExecutionError(this._logger, skillName, scriptName, ex);
+ return $"Error: Failed to execute script '{scriptName}' from skill '{skillName}'.";
+ }
+ }
+
+ ///
+ /// Validates that a custom prompt template contains the required placeholder tokens.
+ ///
+ private static void ValidatePromptTemplate(string template, string paramName)
+ {
+ if (template.IndexOf(SkillsPlaceholder, StringComparison.Ordinal) < 0)
+ {
+ throw new ArgumentException(
+ $"The custom prompt template must contain the '{SkillsPlaceholder}' placeholder for the generated skills list.",
+ paramName);
+ }
+
+ if (template.IndexOf(ResourceInstructionsPlaceholder, StringComparison.Ordinal) < 0)
+ {
+ throw new ArgumentException(
+ $"The custom prompt template must contain the '{ResourceInstructionsPlaceholder}' placeholder for resource instructions.",
+ paramName);
+ }
+
+ if (template.IndexOf(ScriptInstructionsPlaceholder, StringComparison.Ordinal) < 0)
+ {
+ throw new ArgumentException(
+ $"The custom prompt template must contain the '{ScriptInstructionsPlaceholder}' placeholder for script instructions.",
+ paramName);
+ }
+ }
+
+ [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);
+
+ [LoggerMessage(LogLevel.Error, "Failed to execute script '{ScriptName}' from skill '{SkillName}'")]
+ private static partial void LogScriptExecutionError(ILogger logger, string skillName, string scriptName, Exception exception);
+}
diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProviderBuilder.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProviderBuilder.cs
new file mode 100644
index 0000000000..17d7f2d6f3
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProviderBuilder.cs
@@ -0,0 +1,192 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.Extensions.Logging;
+using Microsoft.Shared.DiagnosticIds;
+using Microsoft.Shared.Diagnostics;
+
+namespace Microsoft.Agents.AI;
+
+///
+/// Fluent builder for constructing an backed by a composite source.
+///
+///
+///
+/// var provider = new AgentSkillsProviderBuilder()
+/// .UseFileSkills("/path/to/skills")
+/// .Build();
+///
+///
+[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
+public sealed class AgentSkillsProviderBuilder
+{
+ private readonly List> _sourceFactories = [];
+ private AgentSkillsProviderOptions? _options;
+ private ILoggerFactory? _loggerFactory;
+ private AgentFileSkillScriptRunner? _scriptRunner;
+ private Func? _filter;
+
+ ///
+ /// Adds a file-based skill source that discovers skills from a filesystem directory.
+ ///
+ /// Path to search for skills.
+ /// Optional options that control skill discovery behavior.
+ ///
+ /// Optional runner for file-based scripts. When provided, overrides the builder-level runner
+ /// set via .
+ ///
+ /// This builder instance for chaining.
+ public AgentSkillsProviderBuilder UseFileSkill(string skillPath, AgentFileSkillsSourceOptions? options = null, AgentFileSkillScriptRunner? scriptRunner = null)
+ {
+ return this.UseFileSkills([skillPath], options, scriptRunner);
+ }
+
+ ///
+ /// Adds a file-based skill source that discovers skills from multiple filesystem directories.
+ ///
+ /// Paths to search for skills.
+ /// Optional options that control skill discovery behavior.
+ ///
+ /// Optional runner for file-based scripts. When provided, overrides the builder-level runner
+ /// set via .
+ ///
+ /// This builder instance for chaining.
+ public AgentSkillsProviderBuilder UseFileSkills(IEnumerable skillPaths, AgentFileSkillsSourceOptions? options = null, AgentFileSkillScriptRunner? scriptRunner = null)
+ {
+ this._sourceFactories.Add((builderScriptRunner, loggerFactory) =>
+ {
+ var resolvedRunner = scriptRunner
+ ?? builderScriptRunner
+ ?? throw new InvalidOperationException($"File-based skill sources require a script runner. Call {nameof(this.UseFileScriptRunner)} or pass a runner to {nameof(this.UseFileSkill)}/{nameof(this.UseFileSkills)}.");
+ return new AgentFileSkillsSource(skillPaths, resolvedRunner, options, loggerFactory);
+ });
+ return this;
+ }
+
+ ///
+ /// Adds a custom skill source.
+ ///
+ /// The custom skill source.
+ /// This builder instance for chaining.
+ public AgentSkillsProviderBuilder UseSource(AgentSkillsSource source)
+ {
+ _ = Throw.IfNull(source);
+ this._sourceFactories.Add((_, _) => source);
+ return this;
+ }
+
+ ///
+ /// Sets a custom system prompt template.
+ ///
+ /// The prompt template with {skills} placeholder for the skills list,
+ /// {resource_instructions} for optional resource instructions,
+ /// and {script_instructions} for optional script instructions.
+ /// This builder instance for chaining.
+ public AgentSkillsProviderBuilder UsePromptTemplate(string promptTemplate)
+ {
+ this.GetOrCreateOptions().SkillsInstructionPrompt = promptTemplate;
+ return this;
+ }
+
+ ///
+ /// Enables or disables the script approval gate.
+ ///
+ /// Whether script execution requires approval.
+ /// This builder instance for chaining.
+ public AgentSkillsProviderBuilder UseScriptApproval(bool enabled = true)
+ {
+ this.GetOrCreateOptions().ScriptApproval = enabled;
+ return this;
+ }
+
+ ///
+ /// Sets the runner for file-based skill scripts.
+ ///
+ /// The delegate that runs file-based scripts.
+ /// This builder instance for chaining.
+ public AgentSkillsProviderBuilder UseFileScriptRunner(AgentFileSkillScriptRunner runner)
+ {
+ this._scriptRunner = Throw.IfNull(runner);
+ return this;
+ }
+
+ ///
+ /// Sets the logger factory.
+ ///
+ /// The logger factory.
+ /// This builder instance for chaining.
+ public AgentSkillsProviderBuilder UseLoggerFactory(ILoggerFactory loggerFactory)
+ {
+ this._loggerFactory = loggerFactory;
+ return this;
+ }
+
+ ///
+ /// Sets a filter predicate that controls which skills are included.
+ ///
+ ///
+ /// Skills for which the predicate returns are kept;
+ /// others are excluded. Only one filter is supported; calling this method
+ /// again replaces any previously set filter.
+ ///
+ /// A predicate that determines which skills to include.
+ /// This builder instance for chaining.
+ public AgentSkillsProviderBuilder UseFilter(Func predicate)
+ {
+ _ = Throw.IfNull(predicate);
+ this._filter = predicate;
+ return this;
+ }
+
+ ///
+ /// Configures the using the provided delegate.
+ ///
+ /// A delegate to configure the options.
+ /// This builder instance for chaining.
+ public AgentSkillsProviderBuilder UseOptions(Action configure)
+ {
+ _ = Throw.IfNull(configure);
+ configure(this.GetOrCreateOptions());
+ return this;
+ }
+
+ ///
+ /// Builds the .
+ ///
+ /// A configured .
+ public AgentSkillsProvider Build()
+ {
+ var resolvedSources = new List(this._sourceFactories.Count);
+ foreach (var factory in this._sourceFactories)
+ {
+ resolvedSources.Add(factory(this._scriptRunner, this._loggerFactory));
+ }
+
+ AgentSkillsSource source;
+ if (resolvedSources.Count == 1)
+ {
+ source = resolvedSources[0];
+ }
+ else
+ {
+ source = new AggregatingAgentSkillsSource(resolvedSources);
+ }
+
+ // Apply user-specified filter, then dedup.
+ if (this._filter != null)
+ {
+ source = new FilteringAgentSkillsSource(source, this._filter, this._loggerFactory);
+ }
+
+ source = new DeduplicatingAgentSkillsSource(source, this._loggerFactory);
+
+ return new AgentSkillsProvider(source, this._options, this._loggerFactory);
+ }
+
+ private AgentSkillsProviderOptions GetOrCreateOptions()
+ {
+ return this._options ??= new AgentSkillsProviderOptions();
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProviderOptions.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProviderOptions.cs
new file mode 100644
index 0000000000..2f89ebfda6
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProviderOptions.cs
@@ -0,0 +1,37 @@
+// 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 AgentSkillsProviderOptions
+{
+ ///
+ /// Gets or sets a custom system prompt template for advertising skills.
+ /// The template must contain {skills} as the placeholder for the generated skills list,
+ /// {resource_instructions} for resource instructions,
+ /// and {script_instructions} for script instructions.
+ /// When , a default template is used.
+ ///
+ public string? SkillsInstructionPrompt { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether script execution requires approval.
+ /// When , script execution is blocked until approved.
+ /// Defaults to .
+ ///
+ public bool ScriptApproval { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether caching of tools and instructions is disabled.
+ /// When (the default), the provider caches the tools and instructions
+ /// after the first build and returns the cached instance on subsequent calls.
+ /// Set to to rebuild tools and instructions on every invocation.
+ ///
+ public bool DisableCaching { get; set; }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsSource.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsSource.cs
new file mode 100644
index 0000000000..6a72d0c01a
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsSource.cs
@@ -0,0 +1,24 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Shared.DiagnosticIds;
+
+namespace Microsoft.Agents.AI;
+
+///
+/// Abstract base class for skill sources. A skill source provides skills from a specific origin
+/// (filesystem, remote server, database, in-memory, etc.).
+///
+[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
+public abstract class AgentSkillsSource
+{
+ ///
+ /// Gets the skills provided by this source.
+ ///
+ /// Cancellation token.
+ /// A collection of skills from this source.
+ public abstract Task> GetSkillsAsync(CancellationToken cancellationToken = default);
+}
diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AggregatingAgentSkillsSource.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AggregatingAgentSkillsSource.cs
new file mode 100644
index 0000000000..7dc468742f
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI/Skills/AggregatingAgentSkillsSource.cs
@@ -0,0 +1,45 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Shared.DiagnosticIds;
+using Microsoft.Shared.Diagnostics;
+
+namespace Microsoft.Agents.AI;
+
+///
+/// A skill source that aggregates multiple child sources, preserving their registration order.
+///
+///
+/// Skills from each child source are returned in the order the sources were registered,
+/// with each source's skills appended sequentially. No deduplication or filtering is applied.
+///
+[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
+internal sealed class AggregatingAgentSkillsSource : AgentSkillsSource
+{
+ private readonly IEnumerable _sources;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The child sources to aggregate.
+ public AggregatingAgentSkillsSource(IEnumerable sources)
+ {
+ this._sources = Throw.IfNull(sources);
+ }
+
+ ///
+ public override async Task> GetSkillsAsync(CancellationToken cancellationToken = default)
+ {
+ var allSkills = new List();
+ foreach (var source in this._sources)
+ {
+ var skills = await source.GetSkillsAsync(cancellationToken).ConfigureAwait(false);
+ allSkills.AddRange(skills);
+ }
+
+ return allSkills;
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/DeduplicatingAgentSkillsSource.cs b/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/DeduplicatingAgentSkillsSource.cs
new file mode 100644
index 0000000000..bf943daae5
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/DeduplicatingAgentSkillsSource.cs
@@ -0,0 +1,58 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Shared.DiagnosticIds;
+
+namespace Microsoft.Agents.AI;
+
+///
+/// A skill source decorator that removes duplicate skills by name, keeping only the first occurrence.
+///
+[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
+internal sealed partial class DeduplicatingAgentSkillsSource : DelegatingAgentSkillsSource
+{
+ private readonly ILogger _logger;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The inner source to deduplicate.
+ /// Optional logger factory.
+ public DeduplicatingAgentSkillsSource(AgentSkillsSource innerSource, ILoggerFactory? loggerFactory = null)
+ : base(innerSource)
+ {
+ this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger();
+ }
+
+ ///
+ public override async Task> GetSkillsAsync(CancellationToken cancellationToken = default)
+ {
+ var allSkills = await this.InnerSource.GetSkillsAsync(cancellationToken).ConfigureAwait(false);
+
+ var deduplicated = new List();
+ var seen = new HashSet(StringComparer.OrdinalIgnoreCase);
+
+ foreach (var skill in allSkills)
+ {
+ if (seen.Add(skill.Frontmatter.Name))
+ {
+ deduplicated.Add(skill);
+ }
+ else
+ {
+ LogDuplicateSkillName(this._logger, skill.Frontmatter.Name);
+ }
+ }
+
+ return deduplicated;
+ }
+
+ [LoggerMessage(LogLevel.Warning, "Duplicate skill name '{SkillName}': subsequent skill skipped in favor of first occurrence")]
+ private static partial void LogDuplicateSkillName(ILogger logger, string skillName);
+}
diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/DelegatingAgentSkillsSource.cs b/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/DelegatingAgentSkillsSource.cs
new file mode 100644
index 0000000000..920ad0428b
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/DelegatingAgentSkillsSource.cs
@@ -0,0 +1,41 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Shared.DiagnosticIds;
+using Microsoft.Shared.Diagnostics;
+
+namespace Microsoft.Agents.AI;
+
+///
+/// Provides an abstract base class for skill sources that delegate operations to an inner source
+/// while allowing for extensibility and customization.
+///
+///
+/// implements the decorator pattern for ,
+/// enabling the creation of source pipelines where each layer can add functionality (caching, deduplication,
+/// filtering, etc.) while delegating core operations to an underlying source.
+///
+[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
+internal abstract class DelegatingAgentSkillsSource : AgentSkillsSource
+{
+ ///
+ /// Initializes a new instance of the class with the specified inner source.
+ ///
+ /// The underlying skill source that will handle the core operations.
+ protected DelegatingAgentSkillsSource(AgentSkillsSource innerSource)
+ {
+ this.InnerSource = Throw.IfNull(innerSource);
+ }
+
+ ///
+ /// Gets the inner skill source that receives delegated operations.
+ ///
+ protected AgentSkillsSource InnerSource { get; }
+
+ ///
+ public override Task> GetSkillsAsync(CancellationToken cancellationToken = default)
+ => this.InnerSource.GetSkillsAsync(cancellationToken);
+}
diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/FilteringAgentSkillsSource.cs b/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/FilteringAgentSkillsSource.cs
new file mode 100644
index 0000000000..2bd26acce2
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/FilteringAgentSkillsSource.cs
@@ -0,0 +1,70 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Shared.DiagnosticIds;
+using Microsoft.Shared.Diagnostics;
+
+namespace Microsoft.Agents.AI;
+
+///
+/// A skill source decorator that filters skills using a caller-supplied predicate.
+///
+///
+/// Skills for which the predicate returns are included in the result;
+/// skills for which it returns are excluded and logged at debug level.
+///
+[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
+internal sealed partial class FilteringAgentSkillsSource : DelegatingAgentSkillsSource
+{
+ private readonly Func _predicate;
+ private readonly ILogger _logger;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The inner source whose skills will be filtered.
+ ///
+ /// A predicate that determines which skills to include. Skills for which the predicate
+ /// returns are kept; others are excluded.
+ ///
+ /// Optional logger factory.
+ public FilteringAgentSkillsSource(
+ AgentSkillsSource innerSource,
+ Func predicate,
+ ILoggerFactory? loggerFactory = null)
+ : base(innerSource)
+ {
+ this._predicate = Throw.IfNull(predicate);
+ this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger();
+ }
+
+ ///
+ public override async Task> GetSkillsAsync(CancellationToken cancellationToken = default)
+ {
+ var allSkills = await this.InnerSource.GetSkillsAsync(cancellationToken).ConfigureAwait(false);
+
+ var filtered = new List();
+ foreach (var skill in allSkills)
+ {
+ if (this._predicate(skill))
+ {
+ filtered.Add(skill);
+ }
+ else
+ {
+ LogSkillFiltered(this._logger, skill.Frontmatter.Name);
+ }
+ }
+
+ return filtered;
+ }
+
+ [LoggerMessage(LogLevel.Debug, "Skill '{SkillName}' excluded by filter predicate")]
+ private static partial void LogSkillFiltered(ILogger logger, string skillName);
+}
diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkill.cs b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkill.cs
new file mode 100644
index 0000000000..4bb62e99a8
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkill.cs
@@ -0,0 +1,57 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.Shared.DiagnosticIds;
+using Microsoft.Shared.Diagnostics;
+
+namespace Microsoft.Agents.AI;
+
+///
+/// An discovered from a filesystem directory backed by a SKILL.md file.
+///
+[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
+public sealed class AgentFileSkill : AgentSkill
+{
+ private readonly IReadOnlyList _resources;
+ private readonly IReadOnlyList _scripts;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The parsed frontmatter metadata for this skill.
+ /// The full raw SKILL.md file content including YAML frontmatter.
+ /// Absolute path to the directory containing this skill.
+ /// Resources discovered for this skill.
+ /// Scripts discovered for this skill.
+ internal AgentFileSkill(
+ AgentSkillFrontmatter frontmatter,
+ string content,
+ string path,
+ IReadOnlyList? resources = null,
+ IReadOnlyList? scripts = null)
+ {
+ this.Frontmatter = Throw.IfNull(frontmatter);
+ this.Content = Throw.IfNull(content);
+ this.Path = Throw.IfNullOrWhitespace(path);
+ this._resources = resources ?? [];
+ this._scripts = scripts ?? [];
+ }
+
+ ///
+ public override AgentSkillFrontmatter Frontmatter { get; }
+
+ ///
+ public override string Content { get; }
+
+ ///
+ /// Gets the directory path where the skill was discovered.
+ ///
+ public string Path { get; }
+
+ ///
+ public override IReadOnlyList Resources => this._resources;
+
+ ///
+ public override IReadOnlyList Scripts => this._scripts;
+}
diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillResource.cs b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillResource.cs
new file mode 100644
index 0000000000..9ba5b7e24a
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillResource.cs
@@ -0,0 +1,43 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.IO;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Shared.Diagnostics;
+
+namespace Microsoft.Agents.AI;
+
+///
+/// A file-path-backed skill resource. Reads content from a file on disk relative to the skill directory.
+///
+internal sealed class AgentFileSkillResource : AgentSkillResource
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The resource name (relative path within the skill directory).
+ /// The absolute file path to the resource.
+ public AgentFileSkillResource(string name, string fullPath)
+ : base(name)
+ {
+ this.FullPath = Throw.IfNullOrWhitespace(fullPath);
+ }
+
+ ///
+ /// Gets the absolute file path to the resource.
+ ///
+ public string FullPath { get; }
+
+ ///
+ public override async Task ReadAsync(IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default)
+ {
+#if NET8_0_OR_GREATER
+ return await File.ReadAllTextAsync(this.FullPath, Encoding.UTF8, cancellationToken).ConfigureAwait(false);
+#else
+ using var reader = new StreamReader(this.FullPath, Encoding.UTF8);
+ return await reader.ReadToEndAsync().ConfigureAwait(false);
+#endif
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillScript.cs b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillScript.cs
new file mode 100644
index 0000000000..116847126f
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillScript.cs
@@ -0,0 +1,56 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Diagnostics.CodeAnalysis;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.AI;
+using Microsoft.Shared.DiagnosticIds;
+using Microsoft.Shared.Diagnostics;
+
+namespace Microsoft.Agents.AI;
+
+///
+/// A file-path-backed skill script. Represents a script file on disk that requires an external runner to run.
+///
+[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
+public sealed class AgentFileSkillScript : AgentSkillScript
+{
+ private readonly AgentFileSkillScriptRunner? _runner;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The script name.
+ /// The absolute file path to the script.
+ /// Optional external runner for running the script. An is thrown from if no runner is provided.
+ internal AgentFileSkillScript(string name, string fullPath, AgentFileSkillScriptRunner? runner = null)
+ : base(name)
+ {
+ this.FullPath = Throw.IfNullOrWhitespace(fullPath);
+ this._runner = runner;
+ }
+
+ ///
+ /// Gets the absolute file path to the script.
+ ///
+ public string FullPath { get; }
+
+ ///
+ public override async Task RunAsync(AgentSkill skill, AIFunctionArguments arguments, CancellationToken cancellationToken = default)
+ {
+ if (skill is not AgentFileSkill fileSkill)
+ {
+ throw new InvalidOperationException($"File-based script '{this.Name}' requires an {nameof(AgentFileSkill)} but received '{skill.GetType().Name}'.");
+ }
+
+ if (this._runner is null)
+ {
+ throw new InvalidOperationException(
+ $"Script '{this.Name}' cannot be executed because no {nameof(AgentFileSkillScriptRunner)} was provided. " +
+ $"Supply a script runner when constructing {nameof(AgentFileSkillsSource)} to enable script execution.");
+ }
+
+ return await this._runner(fileSkill, this, arguments, cancellationToken).ConfigureAwait(false);
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillScriptRunner.cs b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillScriptRunner.cs
new file mode 100644
index 0000000000..c19d19e056
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillScriptRunner.cs
@@ -0,0 +1,27 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Diagnostics.CodeAnalysis;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.AI;
+using Microsoft.Shared.DiagnosticIds;
+
+namespace Microsoft.Agents.AI;
+
+///
+/// Delegate for running file-based skill scripts.
+///
+///
+/// Implementations determine the execution strategy (e.g., local subprocess, hosted code execution environment).
+///
+/// The skill that owns the script.
+/// The file-based script to run.
+/// Optional arguments for the script, provided by the agent/LLM.
+/// Cancellation token.
+/// The script execution result.
+[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
+public delegate Task AgentFileSkillScriptRunner(
+ AgentFileSkill skill,
+ AgentFileSkillScript script,
+ AIFunctionArguments arguments,
+ CancellationToken cancellationToken);
diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillLoader.cs b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSource.cs
similarity index 51%
rename from dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillLoader.cs
rename to dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSource.cs
index 18fa87999a..c6dc3bc629 100644
--- a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillLoader.cs
+++ b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSource.cs
@@ -2,151 +2,135 @@
using System;
using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
using System.IO;
-using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
+using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Shared.DiagnosticIds;
+using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
///
-/// Discovers, parses, and validates SKILL.md files from filesystem directories.
+/// A skill source that discovers skills from filesystem directories containing SKILL.md files.
///
///
-/// Searches directories recursively (up to levels) for SKILL.md files.
-/// Each file is validated for YAML frontmatter. Resource files are discovered by scanning the skill
+/// Searches directories recursively (up to 2 levels deep) for SKILL.md files.
+/// Each file is validated for YAML frontmatter. Resource and script files are discovered by scanning the skill
/// directory for files with matching extensions. Invalid resources are skipped with logged warnings.
-/// Resource paths are checked against path traversal and symlink escape attacks.
+/// Resource and script paths are checked against path traversal and symlink escape attacks.
///
-internal sealed partial class FileAgentSkillLoader
+[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
+internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
{
private const string SkillFileName = "SKILL.md";
private const int MaxSearchDepth = 2;
- private const int MaxNameLength = 64;
- private const int MaxDescriptionLength = 1024;
+
+ private static readonly string[] s_defaultScriptExtensions = [".py", ".js", ".sh", ".ps1", ".cs", ".csx"];
+ private static readonly string[] s_defaultResourceExtensions = [".md", ".json", ".yaml", ".yml", ".csv", ".xml", ".txt"];
// Matches YAML frontmatter delimited by "---" lines. Group 1 = content between delimiters.
// Multiline makes ^/$ match line boundaries; Singleline makes . match newlines across the block.
// The \uFEFF? prefix allows an optional UTF-8 BOM that some editors prepend.
- // Example: "---\nname: foo\n---\nBody" → Group 1: "name: foo\n"
private static readonly Regex s_frontmatterRegex = new(@"\A\uFEFF?^---\s*$(.+?)^---\s*$", RegexOptions.Multiline | RegexOptions.Singleline | RegexOptions.Compiled, TimeSpan.FromSeconds(5));
- // Matches YAML "key: value" lines. Group 1 = key, Group 2 = quoted value, Group 3 = unquoted value.
+ // Matches top-level YAML "key: value" lines. Group 1 = key (supports hyphens for keys like allowed-tools),
+ // Group 2 = quoted value, Group 3 = unquoted value.
// Accepts single or double quotes; the lazy quantifier trims trailing whitespace on unquoted values.
- // Examples: "name: foo" → (name, _, foo), "name: 'foo bar'" → (name, foo bar, _),
- // "description: \"A skill\"" → (description, A skill, _)
- private static readonly Regex s_yamlKeyValueRegex = new(@"^\s*(\w+)\s*:\s*(?:[""'](.+?)[""']|(.+?))\s*$", RegexOptions.Multiline | RegexOptions.Compiled, TimeSpan.FromSeconds(5));
+ private static readonly Regex s_yamlKeyValueRegex = new(@"^([\w-]+)\s*:\s*(?:[""'](.+?)[""']|(.+?))\s*$", RegexOptions.Multiline | RegexOptions.Compiled, TimeSpan.FromSeconds(5));
- // Validates skill names: lowercase letters, numbers, and hyphens only;
- // must not start or end with a hyphen; must not contain consecutive hyphens.
- // Examples: "my-skill" ✓, "skill123" ✓, "-bad" ✗, "bad-" ✗, "Bad" ✗, "my--skill" ✗
- private static readonly Regex s_validNameRegex = new("^[a-z0-9]([a-z0-9]*-[a-z0-9])*[a-z0-9]*$", RegexOptions.Compiled);
+ // Matches a "metadata:" line followed by indented sub-key/value pairs.
+ // Group 1 captures the entire indented block beneath the metadata key.
+ private static readonly Regex s_yamlMetadataBlockRegex = new(@"^metadata\s*:\s*$\n((?:[ \t]+\S.*\n?)+)", RegexOptions.Multiline | RegexOptions.Compiled, TimeSpan.FromSeconds(5));
- private readonly ILogger _logger;
+ // Matches indented YAML "key: value" lines within a metadata block.
+ // Group 1 = key (supports hyphens), Group 2 = quoted value, Group 3 = unquoted value.
+ private static readonly Regex s_yamlIndentedKeyValueRegex = new(@"^\s+([\w-]+)\s*:\s*(?:[""'](.+?)[""']|(.+?))\s*$", RegexOptions.Multiline | RegexOptions.Compiled, TimeSpan.FromSeconds(5));
+
+ private readonly IEnumerable _skillPaths;
private readonly HashSet _allowedResourceExtensions;
+ private readonly HashSet _allowedScriptExtensions;
+ private readonly AgentFileSkillScriptRunner? _scriptRunner;
+ private readonly ILogger _logger;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Path to search for skills.
+ /// Optional runner for file-based scripts. Required only when skills contain scripts.
+ /// Optional options that control skill discovery behavior.
+ /// Optional logger factory.
+ public AgentFileSkillsSource(
+ string skillPath,
+ AgentFileSkillScriptRunner? scriptRunner = null,
+ AgentFileSkillsSourceOptions? options = null,
+ ILoggerFactory? loggerFactory = null)
+ : this([skillPath], scriptRunner, options, loggerFactory)
+ {
+ }
///
- /// Initializes a new instance of the class.
+ /// Initializes a new instance of the class.
///
- /// The logger instance.
- /// File extensions to recognize as skill resources. When , defaults are used.
- internal FileAgentSkillLoader(ILogger logger, IEnumerable? allowedResourceExtensions = null)
+ /// Paths to search for skills.
+ /// Optional runner for file-based scripts. Required only when skills contain scripts.
+ /// Optional options that control skill discovery behavior.
+ /// Optional logger factory.
+ public AgentFileSkillsSource(
+ IEnumerable skillPaths,
+ AgentFileSkillScriptRunner? scriptRunner = null,
+ AgentFileSkillsSourceOptions? options = null,
+ ILoggerFactory? loggerFactory = null)
{
- this._logger = logger;
+ this._skillPaths = Throw.IfNull(skillPaths);
+
+ var resolvedOptions = options ?? new AgentFileSkillsSourceOptions();
- ValidateExtensions(allowedResourceExtensions);
+ ValidateExtensions(resolvedOptions.AllowedResourceExtensions);
+ ValidateExtensions(resolvedOptions.AllowedScriptExtensions);
this._allowedResourceExtensions = new HashSet(
- allowedResourceExtensions ?? [".md", ".json", ".yaml", ".yml", ".csv", ".xml", ".txt"],
+ resolvedOptions.AllowedResourceExtensions ?? s_defaultResourceExtensions,
+ StringComparer.OrdinalIgnoreCase);
+
+ this._allowedScriptExtensions = new HashSet(
+ resolvedOptions.AllowedScriptExtensions ?? s_defaultScriptExtensions,
StringComparer.OrdinalIgnoreCase);
+
+ this._scriptRunner = scriptRunner;
+ this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger();
}
- ///
- /// 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)
+ ///
+ public override Task> GetSkillsAsync(CancellationToken cancellationToken = default)
{
- var skills = new Dictionary(StringComparer.OrdinalIgnoreCase);
-
- var discoveredPaths = DiscoverSkillDirectories(skillPaths);
+ var discoveredPaths = DiscoverSkillDirectories(this._skillPaths);
LogSkillsDiscovered(this._logger, discoveredPaths.Count);
+ var skills = new List();
+
foreach (string skillPath in discoveredPaths)
{
- FileAgentSkill? skill = this.ParseSkillFile(skillPath);
+ AgentFileSkill? skill = this.ParseSkillDirectory(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;
+ skills.Add(skill);
LogSkillLoaded(this._logger, skill.Frontmatter.Name);
}
LogSkillsLoadedTotal(this._logger, skills.Count);
- return skills;
- }
-
- ///
- /// Reads a resource file from disk with path traversal and symlink guards.
- ///
- /// The skill that owns the resource.
- /// Relative path of the resource within the skill directory.
- /// Cancellation token.
- /// The UTF-8 text content of the resource file.
- ///
- /// The resource is not registered, resolves outside the skill directory, or does not exist.
- ///
- internal async Task ReadSkillResourceAsync(FileAgentSkill skill, string resourceName, CancellationToken cancellationToken = default)
- {
- resourceName = NormalizeResourcePath(resourceName);
-
- if (!skill.ResourceNames.Any(r => r.Equals(resourceName, StringComparison.OrdinalIgnoreCase)))
- {
- throw new InvalidOperationException($"Resource '{resourceName}' not found in skill '{skill.Frontmatter.Name}'.");
- }
-
- string fullPath = Path.GetFullPath(Path.Combine(skill.SourcePath, resourceName));
- string normalizedSourcePath = Path.GetFullPath(skill.SourcePath) + Path.DirectorySeparatorChar;
-
- if (!IsPathWithinDirectory(fullPath, normalizedSourcePath))
- {
- throw new InvalidOperationException($"Resource file '{resourceName}' references a path outside the skill directory.");
- }
-
- if (!File.Exists(fullPath))
- {
- throw new InvalidOperationException($"Resource file '{resourceName}' not found in skill '{skill.Frontmatter.Name}'.");
- }
-
- if (HasSymlinkInPath(fullPath, normalizedSourcePath))
- {
- throw new InvalidOperationException($"Resource file '{resourceName}' is a symlink that resolves outside the skill directory.");
- }
-
- LogResourceReading(this._logger, resourceName, skill.Frontmatter.Name);
-
-#if NET
- return await File.ReadAllTextAsync(fullPath, Encoding.UTF8, cancellationToken).ConfigureAwait(false);
-#else
- return await Task.FromResult(File.ReadAllText(fullPath, Encoding.UTF8)).ConfigureAwait(false);
-#endif
+ return Task.FromResult(skills as IList);
}
private static List DiscoverSkillDirectories(IEnumerable skillPaths)
@@ -185,30 +169,30 @@ private static void SearchDirectoriesForSkills(string directory, List re
}
}
- private FileAgentSkill? ParseSkillFile(string skillDirectoryFullPath)
+ private AgentFileSkill? ParseSkillDirectory(string skillDirectoryFullPath)
{
string skillFilePath = Path.Combine(skillDirectoryFullPath, SkillFileName);
-
string content = File.ReadAllText(skillFilePath, Encoding.UTF8);
- if (!this.TryParseSkillDocument(content, skillFilePath, out SkillFrontmatter frontmatter, out string body))
+ if (!this.TryParseFrontmatter(content, skillFilePath, out AgentSkillFrontmatter? frontmatter))
{
return null;
}
- List resourceNames = this.DiscoverResourceFiles(skillDirectoryFullPath, frontmatter.Name);
+ var resources = this.DiscoverResourceFiles(skillDirectoryFullPath, frontmatter.Name);
+ var scripts = this.DiscoverScriptFiles(skillDirectoryFullPath, frontmatter.Name);
- return new FileAgentSkill(
+ return new AgentFileSkill(
frontmatter: frontmatter,
- body: body,
- sourcePath: skillDirectoryFullPath,
- resourceNames: resourceNames);
+ content: content,
+ path: skillDirectoryFullPath,
+ resources: resources,
+ scripts: scripts);
}
- private bool TryParseSkillDocument(string content, string skillFilePath, out SkillFrontmatter frontmatter, out string body)
+ private bool TryParseFrontmatter(string content, string skillFilePath, [NotNullWhen(true)] out AgentSkillFrontmatter? frontmatter)
{
- frontmatter = null!;
- body = null!;
+ frontmatter = null;
Match match = s_frontmatterRegex.Match(content);
if (!match.Success)
@@ -217,10 +201,13 @@ private bool TryParseSkillDocument(string content, string skillFilePath, out Ski
return false;
}
+ string yamlContent = match.Groups[1].Value.Trim();
+
string? name = null;
string? description = null;
-
- string yamlContent = match.Groups[1].Value.Trim();
+ string? license = null;
+ string? compatibility = null;
+ string? allowedTools = null;
foreach (Match kvMatch in s_yamlKeyValueRegex.Matches(yamlContent))
{
@@ -235,50 +222,62 @@ private bool TryParseSkillDocument(string content, string skillFilePath, out Ski
{
description = value;
}
+ else if (string.Equals(key, "license", StringComparison.OrdinalIgnoreCase))
+ {
+ license = value;
+ }
+ else if (string.Equals(key, "compatibility", StringComparison.OrdinalIgnoreCase))
+ {
+ compatibility = value;
+ }
+ else if (string.Equals(key, "allowed-tools", StringComparison.OrdinalIgnoreCase))
+ {
+ allowedTools = value;
+ }
}
- if (string.IsNullOrWhiteSpace(name))
+ // Parse metadata block (indented key-value pairs under "metadata:").
+ AdditionalPropertiesDictionary? metadata = null;
+ Match metadataMatch = s_yamlMetadataBlockRegex.Match(yamlContent);
+ if (metadataMatch.Success)
{
- LogMissingFrontmatterField(this._logger, skillFilePath, "name");
- return false;
+ metadata = [];
+ foreach (Match kvMatch in s_yamlIndentedKeyValueRegex.Matches(metadataMatch.Groups[1].Value))
+ {
+ metadata[kvMatch.Groups[1].Value] = kvMatch.Groups[2].Success ? kvMatch.Groups[2].Value : kvMatch.Groups[3].Value;
+ }
}
- if (name.Length > MaxNameLength || !s_validNameRegex.IsMatch(name))
+ if (!AgentSkillFrontmatter.ValidateName(name, out string? validationReason) ||
+ !AgentSkillFrontmatter.ValidateDescription(description, out validationReason))
{
- 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 or contain consecutive hyphens.");
+ LogInvalidFieldValue(this._logger, skillFilePath, "frontmatter", validationReason);
return false;
}
+ frontmatter = new AgentSkillFrontmatter(name!, description!, compatibility)
+ {
+ License = license,
+ AllowedTools = allowedTools,
+ Metadata = metadata,
+ };
+
// skillFilePath is e.g. "/skills/my-skill/SKILL.md".
// GetDirectoryName strips the filename → "/skills/my-skill".
// GetFileName then extracts the last segment → "my-skill".
// This gives us the skill's parent directory name to validate against the frontmatter name.
string directoryName = Path.GetFileName(Path.GetDirectoryName(skillFilePath)) ?? string.Empty;
- if (!string.Equals(name, directoryName, StringComparison.Ordinal))
+ if (!string.Equals(frontmatter.Name, directoryName, StringComparison.Ordinal))
{
if (this._logger.IsEnabled(LogLevel.Error))
{
- LogNameDirectoryMismatch(this._logger, SanitizePathForLog(skillFilePath), name, SanitizePathForLog(directoryName));
+ LogNameDirectoryMismatch(this._logger, SanitizePathForLog(skillFilePath), frontmatter.Name, SanitizePathForLog(directoryName));
}
+ frontmatter = null;
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;
}
@@ -287,15 +286,15 @@ private bool TryParseSkillDocument(string content, string skillFilePath, out Ski
///
///
/// Recursively walks and collects files whose extension
- /// matches , excluding SKILL.md itself. Each candidate
+ /// matches the allowed set, excluding SKILL.md itself. Each candidate
/// is validated against path-traversal and symlink-escape checks; unsafe files are skipped with
/// a warning.
///
- private List DiscoverResourceFiles(string skillDirectoryFullPath, string skillName)
+ private List DiscoverResourceFiles(string skillDirectoryFullPath, string skillName)
{
string normalizedSkillDirectoryFullPath = skillDirectoryFullPath + Path.DirectorySeparatorChar;
- var resources = new List();
+ var resources = new List();
#if NET
var enumerationOptions = new EnumerationOptions
@@ -326,21 +325,21 @@ private List DiscoverResourceFiles(string skillDirectoryFullPath, string
{
LogResourceSkippedExtension(this._logger, skillName, SanitizePathForLog(filePath), extension);
}
+
continue;
}
// Normalize the enumerated path to guard against non-canonical forms
- // (redundant separators, 8.3 short names, etc.) that would produce
- // malformed relative resource names.
string resolvedFilePath = Path.GetFullPath(filePath);
// Path containment check
- if (!IsPathWithinDirectory(resolvedFilePath, normalizedSkillDirectoryFullPath))
+ if (!resolvedFilePath.StartsWith(normalizedSkillDirectoryFullPath, StringComparison.OrdinalIgnoreCase))
{
if (this._logger.IsEnabled(LogLevel.Warning))
{
LogResourcePathTraversal(this._logger, skillName, SanitizePathForLog(filePath));
}
+
continue;
}
@@ -351,30 +350,86 @@ private List DiscoverResourceFiles(string skillDirectoryFullPath, string
{
LogResourceSymlinkEscape(this._logger, skillName, SanitizePathForLog(filePath));
}
+
continue;
}
// Compute relative path and normalize to forward slashes
- string relativePath = resolvedFilePath.Substring(normalizedSkillDirectoryFullPath.Length);
- resources.Add(NormalizeResourcePath(relativePath));
+ string relativePath = NormalizePath(resolvedFilePath.Substring(normalizedSkillDirectoryFullPath.Length));
+ resources.Add(new AgentFileSkillResource(relativePath, resolvedFilePath));
}
return resources;
}
///
- /// Checks that is under ,
- /// guarding against path traversal attacks.
+ /// Scans a skill directory for script files matching the configured extensions.
///
- private static bool IsPathWithinDirectory(string fullPath, string normalizedDirectoryPath)
+ ///
+ /// Recursively walks the skill directory and collects files whose extension
+ /// matches the allowed set. Each candidate is validated against path-traversal
+ /// and symlink-escape checks; unsafe files are skipped with a warning.
+ ///
+ private List DiscoverScriptFiles(string skillDirectoryFullPath, string skillName)
{
- return fullPath.StartsWith(normalizedDirectoryPath, StringComparison.OrdinalIgnoreCase);
+ string normalizedSkillDirectoryFullPath = skillDirectoryFullPath + Path.DirectorySeparatorChar;
+ var scripts = new List();
+
+#if NET
+ var enumerationOptions = new EnumerationOptions
+ {
+ RecurseSubdirectories = true,
+ IgnoreInaccessible = true,
+ AttributesToSkip = FileAttributes.ReparsePoint,
+ };
+
+ foreach (string filePath in Directory.EnumerateFiles(skillDirectoryFullPath, "*", enumerationOptions))
+#else
+ foreach (string filePath in Directory.EnumerateFiles(skillDirectoryFullPath, "*", SearchOption.AllDirectories))
+#endif
+ {
+ // Filter by extension
+ string extension = Path.GetExtension(filePath);
+ if (string.IsNullOrEmpty(extension) || !this._allowedScriptExtensions.Contains(extension))
+ {
+ continue;
+ }
+
+ // Normalize the enumerated path to guard against non-canonical forms
+ string resolvedFilePath = Path.GetFullPath(filePath);
+
+ // Path containment check
+ if (!resolvedFilePath.StartsWith(normalizedSkillDirectoryFullPath, StringComparison.OrdinalIgnoreCase))
+ {
+ if (this._logger.IsEnabled(LogLevel.Warning))
+ {
+ LogScriptPathTraversal(this._logger, skillName, SanitizePathForLog(filePath));
+ }
+
+ continue;
+ }
+
+ // Symlink check
+ if (HasSymlinkInPath(resolvedFilePath, normalizedSkillDirectoryFullPath))
+ {
+ if (this._logger.IsEnabled(LogLevel.Warning))
+ {
+ LogScriptSymlinkEscape(this._logger, skillName, SanitizePathForLog(filePath));
+ }
+
+ continue;
+ }
+
+ // Compute relative path and normalize to forward slashes
+ string relativePath = NormalizePath(resolvedFilePath.Substring(normalizedSkillDirectoryFullPath.Length));
+ scripts.Add(new AgentFileSkillScript(relativePath, resolvedFilePath, this._scriptRunner));
+ }
+
+ return scripts;
}
///
- /// Checks whether any segment in (relative to
- /// ) is a symlink (reparse point).
- /// Uses which is available on all target frameworks.
+ /// Checks whether any segment in the path (relative to the directory) is a symlink.
///
private static bool HasSymlinkInPath(string fullPath, string normalizedDirectoryPath)
{
@@ -399,11 +454,10 @@ private static bool HasSymlinkInPath(string fullPath, string normalizedDirectory
}
///
- /// 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.
+ /// Normalizes a relative path by replacing backslashes with forward slashes
+ /// and trimming a leading "./" prefix.
///
- private static string NormalizeResourcePath(string path)
+ private static string NormalizePath(string path)
{
if (path.IndexOf('\\') >= 0)
{
@@ -419,8 +473,7 @@ private static string NormalizeResourcePath(string path)
}
///
- /// Replaces control characters in a file path with '?' to prevent log injection
- /// via crafted filenames (e.g., filenames containing newlines on Linux).
+ /// Replaces control characters in a file path with '?' to prevent log injection.
///
private static string SanitizePathForLog(string path)
{
@@ -449,7 +502,7 @@ private static void ValidateExtensions(IEnumerable? extensions)
if (string.IsNullOrWhiteSpace(ext) || !ext.StartsWith(".", StringComparison.Ordinal))
{
#pragma warning disable CA2208 // Instantiate argument exceptions correctly
- throw new ArgumentException($"Each extension must start with '.'. Invalid value: '{ext}'", nameof(FileAgentSkillsProviderOptions.AllowedResourceExtensions));
+ throw new ArgumentException($"Each extension must start with '.'. Invalid value: '{ext}'", "allowedResourceExtensions");
#pragma warning restore CA2208 // Instantiate argument exceptions correctly
}
}
@@ -467,9 +520,6 @@ private static void ValidateExtensions(IEnumerable? extensions)
[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);
@@ -479,15 +529,15 @@ private static void ValidateExtensions(IEnumerable? extensions)
[LoggerMessage(LogLevel.Warning, "Skipping resource in skill '{SkillName}': '{ResourcePath}' references a path outside the skill directory")]
private static partial void LogResourcePathTraversal(ILogger logger, string skillName, string resourcePath);
- [LoggerMessage(LogLevel.Warning, "Duplicate skill name '{SkillName}': skill from '{NewPath}' skipped in favor of existing skill from '{ExistingPath}'")]
- private static partial void LogDuplicateSkillName(ILogger logger, string skillName, string newPath, string existingPath);
-
[LoggerMessage(LogLevel.Warning, "Skipping resource in skill '{SkillName}': '{ResourcePath}' is a symlink that resolves outside the skill directory")]
private static partial void LogResourceSymlinkEscape(ILogger logger, string skillName, string resourcePath);
- [LoggerMessage(LogLevel.Information, "Reading resource '{FileName}' from skill '{SkillName}'")]
- private static partial void LogResourceReading(ILogger logger, string fileName, string skillName);
-
[LoggerMessage(LogLevel.Debug, "Skipping file '{FilePath}' in skill '{SkillName}': extension '{Extension}' is not in the allowed list")]
private static partial void LogResourceSkippedExtension(ILogger logger, string skillName, string filePath, string extension);
+
+ [LoggerMessage(LogLevel.Warning, "Skipping script in skill '{SkillName}': '{ScriptPath}' references a path outside the skill directory")]
+ private static partial void LogScriptPathTraversal(ILogger logger, string skillName, string scriptPath);
+
+ [LoggerMessage(LogLevel.Warning, "Skipping script in skill '{SkillName}': '{ScriptPath}' is a symlink that resolves outside the skill directory")]
+ private static partial void LogScriptSymlinkEscape(ILogger logger, string skillName, string scriptPath);
}
diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSourceOptions.cs b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSourceOptions.cs
new file mode 100644
index 0000000000..edaec327fa
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSourceOptions.cs
@@ -0,0 +1,33 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.Shared.DiagnosticIds;
+
+namespace Microsoft.Agents.AI;
+
+///
+/// Configuration options for file-based skill sources.
+///
+///
+/// Use this class to configure file-based skill discovery without relying on
+/// positional constructor or method parameters. New options can be added here
+/// without breaking existing callers.
+///
+[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
+public sealed class AgentFileSkillsSourceOptions
+{
+ ///
+ /// Gets or sets the allowed file extensions for skill resources.
+ /// When , defaults to .md , .json , .yaml ,
+ /// .yml , .csv , .xml , .txt .
+ ///
+ public IEnumerable? AllowedResourceExtensions { get; set; }
+
+ ///
+ /// Gets or sets the allowed file extensions for skill scripts.
+ /// When , defaults to .py , .js , .sh ,
+ /// .ps1 , .cs , .csx .
+ ///
+ public IEnumerable? AllowedScriptExtensions { get; set; }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkill.cs b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkill.cs
deleted file mode 100644
index f28bad3ab0..0000000000
--- a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkill.cs
+++ /dev/null
@@ -1,56 +0,0 @@
-// 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/FileAgentSkillsProvider.cs b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProvider.cs
deleted file mode 100644
index 460faced70..0000000000
--- a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProvider.cs
+++ /dev/null
@@ -1,222 +0,0 @@
-// Copyright (c) Microsoft. All rights reserved.
-
-using System;
-using System.Collections.Generic;
-using System.Diagnostics.CodeAnalysis;
-using System.Linq;
-using System.Security;
-using System.Text;
-using System.Threading;
-using System.Threading.Tasks;
-using Microsoft.Extensions.AI;
-using Microsoft.Extensions.Logging;
-using Microsoft.Extensions.Logging.Abstractions;
-using Microsoft.Shared.DiagnosticIds;
-using Microsoft.Shared.Diagnostics;
-
-namespace Microsoft.Agents.AI;
-
-///
-/// An that discovers and exposes Agent Skills from filesystem directories.
-///
-///
-///
-/// This provider implements the progressive disclosure pattern from the
-/// Agent Skills specification :
-///
-///
-/// Advertise — skill names and descriptions are injected into the system prompt (~100 tokens per skill).
-/// Load — the full SKILL.md body is returned via the load_skill tool.
-/// Read resources — supplementary files are read from disk on demand via the read_skill_resource tool.
-///
-///
-/// Skills are discovered by searching the configured directories for SKILL.md files.
-/// Referenced resources are validated at initialization; invalid skills are excluded and logged.
-///
-///
-/// Security: this provider only reads static content. Skill metadata is XML-escaped
-/// before prompt embedding, and resource reads are guarded against path traversal and symlink escape.
-/// Only use skills from trusted sources.
-///
-///
-[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
-public sealed partial class FileAgentSkillsProvider : AIContextProvider
-{
- private const string DefaultSkillsInstructionPrompt =
- """
- You have access to skills containing domain-specific knowledge and capabilities.
- Each skill provides specialized instructions, reference documents, and assets for specific tasks.
-
-
- {0}
-
-
- When a task aligns with a skill's domain:
- 1. Use `load_skill` to retrieve the skill's instructions
- 2. Follow the provided guidance
- 3. Use `read_skill_resource` to read any references or other files mentioned by the skill
-
- Only load what is needed, when it is needed.
- """;
-
- private readonly Dictionary _skills;
- private readonly ILogger _logger;
- private readonly FileAgentSkillLoader _loader;
- private readonly AITool[] _tools;
- private readonly string? _skillsInstructionPrompt;
-
- ///
- /// Initializes a new instance of the class that searches a single directory for skills.
- ///
- /// Path to an individual skill folder (containing a SKILL.md file) or a parent folder with skill subdirectories.
- /// Optional configuration for prompt customization.
- /// Optional logger factory.
- public FileAgentSkillsProvider(string skillPath, FileAgentSkillsProviderOptions? options = null, ILoggerFactory? loggerFactory = null)
- : this([skillPath], options, loggerFactory)
- {
- }
-
- ///
- /// Initializes a new instance of the class that searches multiple directories for skills.
- ///
- /// Paths to search. Each can be an individual skill folder or a parent folder with skill subdirectories.
- /// Optional configuration for prompt customization.
- /// Optional logger factory.
- public FileAgentSkillsProvider(IEnumerable skillPaths, FileAgentSkillsProviderOptions? options = null, ILoggerFactory? loggerFactory = null)
- {
- _ = Throw.IfNull(skillPaths);
-
- this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger();
-
- this._loader = new FileAgentSkillLoader(this._logger, options?.AllowedResourceExtensions);
- this._skills = this._loader.DiscoverAndLoadSkills(skillPaths);
-
- this._skillsInstructionPrompt = BuildSkillsInstructionPrompt(options, this._skills);
-
- this._tools =
- [
- AIFunctionFactory.Create(
- this.LoadSkill,
- name: "load_skill",
- description: "Loads the full instructions for a specific skill."),
- AIFunctionFactory.Create(
- this.ReadSkillResourceAsync,
- name: "read_skill_resource",
- description: "Reads a file associated with a skill, such as references or assets."),
- ];
- }
-
- ///
- protected override ValueTask ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default)
- {
- if (this._skills.Count == 0)
- {
- return base.ProvideAIContextAsync(context, cancellationToken);
- }
-
- return new ValueTask(new AIContext
- {
- Instructions = this._skillsInstructionPrompt,
- Tools = this._tools
- });
- }
-
- private string LoadSkill(string skillName)
- {
- if (string.IsNullOrWhiteSpace(skillName))
- {
- return "Error: Skill name cannot be empty.";
- }
-
- if (!this._skills.TryGetValue(skillName, out FileAgentSkill? skill))
- {
- return $"Error: Skill '{skillName}' not found.";
- }
-
- LogSkillLoading(this._logger, skillName);
-
- return skill.Body;
- }
-
- private async Task ReadSkillResourceAsync(string skillName, string resourceName, CancellationToken cancellationToken = default)
- {
- if (string.IsNullOrWhiteSpace(skillName))
- {
- return "Error: Skill name cannot be empty.";
- }
-
- if (string.IsNullOrWhiteSpace(resourceName))
- {
- return "Error: Resource name cannot be empty.";
- }
-
- if (!this._skills.TryGetValue(skillName, out FileAgentSkill? skill))
- {
- return $"Error: Skill '{skillName}' not found.";
- }
-
- try
- {
- return await this._loader.ReadSkillResourceAsync(skill, resourceName, cancellationToken).ConfigureAwait(false);
- }
- catch (Exception ex)
- {
- LogResourceReadError(this._logger, skillName, resourceName, ex);
- return $"Error: Failed to read resource '{resourceName}' from skill '{skillName}'.";
- }
- }
-
- private static string? BuildSkillsInstructionPrompt(FileAgentSkillsProviderOptions? options, Dictionary skills)
- {
- string promptTemplate = DefaultSkillsInstructionPrompt;
-
- if (options?.SkillsInstructionPrompt is { } optionsInstructions)
- {
- try
- {
- _ = string.Format(optionsInstructions, string.Empty);
- }
- catch (FormatException ex)
- {
- throw new ArgumentException(
- "The provided SkillsInstructionPrompt is not a valid format string.",
- nameof(options),
- ex);
- }
-
- if (optionsInstructions.IndexOf("{0}", StringComparison.Ordinal) < 0)
- {
- throw new ArgumentException(
- "The provided SkillsInstructionPrompt must contain a '{0}' placeholder for the generated skills list.",
- nameof(options));
- }
-
- promptTemplate = optionsInstructions;
- }
-
- if (skills.Count == 0)
- {
- return null;
- }
-
- var sb = new StringBuilder();
-
- // Order by name for deterministic prompt output across process restarts
- // (Dictionary enumeration order is not guaranteed and varies with hash randomization).
- foreach (var skill in skills.Values.OrderBy(s => s.Frontmatter.Name, StringComparer.Ordinal))
- {
- sb.AppendLine(" ");
- sb.AppendLine($" {SecurityElement.Escape(skill.Frontmatter.Name)} ");
- sb.AppendLine($" {SecurityElement.Escape(skill.Frontmatter.Description)} ");
- sb.AppendLine(" ");
- }
-
- return string.Format(promptTemplate, sb.ToString().TrimEnd());
- }
-
- [LoggerMessage(LogLevel.Information, "Loading skill: {SkillName}")]
- private static partial void LogSkillLoading(ILogger logger, string skillName);
-
- [LoggerMessage(LogLevel.Error, "Failed to read resource '{ResourceName}' from skill '{SkillName}'")]
- private static partial void LogResourceReadError(ILogger logger, string skillName, string resourceName, Exception exception);
-}
diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProviderOptions.cs b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProviderOptions.cs
deleted file mode 100644
index 600c5b964c..0000000000
--- a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProviderOptions.cs
+++ /dev/null
@@ -1,32 +0,0 @@
-// Copyright (c) Microsoft. All rights reserved.
-
-using System.Collections.Generic;
-using System.Diagnostics.CodeAnalysis;
-using Microsoft.Shared.DiagnosticIds;
-
-namespace Microsoft.Agents.AI;
-
-///
-/// Configuration options for .
-///
-[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
-public sealed class FileAgentSkillsProviderOptions
-{
- ///
- /// Gets or sets a custom system prompt template for advertising skills.
- /// Use {0} as the placeholder for the generated skills list.
- /// When , a default template is used.
- ///
- public string? SkillsInstructionPrompt { get; set; }
-
- ///
- /// Gets or sets the file extensions recognized as discoverable skill resources.
- /// Each value must start with a '.' character (for example, .md ), and
- /// extension comparisons are performed in a case-insensitive manner.
- /// Files in the skill directory (and its subdirectories) whose extension matches
- /// one of these values will be automatically discovered as resources.
- /// When , a default set of extensions is used
- /// (.md , .json , .yaml , .yml , .csv , .xml , .txt ).
- ///
- public IEnumerable? AllowedResourceExtensions { get; set; }
-}
diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/SkillFrontmatter.cs b/dotnet/src/Microsoft.Agents.AI/Skills/SkillFrontmatter.cs
deleted file mode 100644
index 123a6c43f4..0000000000
--- a/dotnet/src/Microsoft.Agents.AI/Skills/SkillFrontmatter.cs
+++ /dev/null
@@ -1,32 +0,0 @@
-// Copyright (c) Microsoft. All rights reserved.
-
-using Microsoft.Shared.Diagnostics;
-
-namespace Microsoft.Agents.AI;
-
-///
-/// Parsed YAML frontmatter from a SKILL.md file, containing the skill's name and description.
-///
-internal sealed class SkillFrontmatter
-{
- ///
- /// Initializes a new instance of the class.
- ///
- /// Skill name.
- /// Skill description.
- public SkillFrontmatter(string name, string description)
- {
- this.Name = Throw.IfNullOrWhitespace(name);
- this.Description = Throw.IfNullOrWhitespace(description);
- }
-
- ///
- /// Gets the skill name. Lowercase letters, numbers, and hyphens only.
- ///
- public string Name { get; }
-
- ///
- /// Gets the skill description. Used for discovery in the system prompt.
- ///
- public string Description { get; }
-}
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillScriptTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillScriptTests.cs
new file mode 100644
index 0000000000..eb4f706f30
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillScriptTests.cs
@@ -0,0 +1,103 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.AI;
+
+namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
+
+///
+/// Unit tests for .
+///
+public sealed class AgentFileSkillScriptTests
+{
+ [Fact]
+ public async Task RunAsync_SkillIsNotAgentFileSkill_ThrowsInvalidOperationExceptionAsync()
+ {
+ // Arrange
+ static Task RunnerAsync(AgentFileSkill s, AgentFileSkillScript sc, AIFunctionArguments a, CancellationToken ct) => Task.FromResult("result");
+ var script = CreateScript("test-script", "/path/to/script.py", RunnerAsync);
+ var nonFileSkill = new TestAgentSkill("my-skill", "A skill", "Instructions.");
+
+ // Act & Assert
+ await Assert.ThrowsAsync(
+ () => script.RunAsync(nonFileSkill, new AIFunctionArguments(), CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task RunAsync_WithAgentFileSkill_DelegatesToRunnerAsync()
+ {
+ // Arrange
+ var runnerCalled = false;
+ Task runnerAsync(AgentFileSkill skill, AgentFileSkillScript scriptArg, AIFunctionArguments args, CancellationToken ct)
+ {
+ runnerCalled = true;
+ return Task.FromResult("executed");
+ }
+ var script = CreateScript("run-me", "/scripts/run-me.sh", runnerAsync);
+ var fileSkill = new AgentFileSkill(
+ new AgentSkillFrontmatter("my-skill", "A file skill"),
+ "---\nname: my-skill\n---\nContent",
+ "/skills/my-skill");
+
+ // Act
+ var result = await script.RunAsync(fileSkill, new AIFunctionArguments(), CancellationToken.None);
+
+ // Assert
+ Assert.True(runnerCalled);
+ Assert.Equal("executed", result);
+ }
+
+ [Fact]
+ public async Task RunAsync_RunnerReceivesCorrectArgumentsAsync()
+ {
+ // Arrange
+ AgentFileSkill? capturedSkill = null;
+ AgentFileSkillScript? capturedScript = null;
+ Task runnerAsync(AgentFileSkill skill, AgentFileSkillScript scriptArg, AIFunctionArguments args, CancellationToken ct)
+ {
+ capturedSkill = skill;
+ capturedScript = scriptArg;
+ return Task.FromResult(null);
+ }
+ var script = CreateScript("capture", "/scripts/capture.py", runnerAsync);
+ var fileSkill = new AgentFileSkill(
+ new AgentSkillFrontmatter("owner-skill", "Owner"),
+ "Content",
+ "/skills/owner-skill");
+
+ // Act
+ await script.RunAsync(fileSkill, new AIFunctionArguments(), CancellationToken.None);
+
+ // Assert
+ Assert.Same(fileSkill, capturedSkill);
+ Assert.Same(script, capturedScript);
+ }
+
+ [Fact]
+ public void Script_HasCorrectNameAndPath()
+ {
+ // Arrange & Act
+ static Task RunnerAsync(AgentFileSkill s, AgentFileSkillScript sc, AIFunctionArguments a, CancellationToken ct) => Task.FromResult(null);
+ var script = CreateScript("my-script", "/path/to/my-script.py", RunnerAsync);
+
+ // Assert
+ Assert.Equal("my-script", script.Name);
+ Assert.Equal("/path/to/my-script.py", script.FullPath);
+ }
+
+ ///
+ /// Helper to create an via reflection since the constructor is internal.
+ ///
+ private static AgentFileSkillScript CreateScript(string name, string fullPath, AgentFileSkillScriptRunner executor)
+ {
+ var ctor = typeof(AgentFileSkillScript).GetConstructor(
+ System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance,
+ null,
+ [typeof(string), typeof(string), typeof(AgentFileSkillScriptRunner)],
+ null) ?? throw new InvalidOperationException("Could not find internal constructor.");
+
+ return (AgentFileSkillScript)ctor.Invoke([name, fullPath, executor]);
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillsSourceScriptTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillsSourceScriptTests.cs
new file mode 100644
index 0000000000..ef8f7780a6
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillsSourceScriptTests.cs
@@ -0,0 +1,255 @@
+// 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 script discovery and execution in .
+///
+public sealed class AgentFileSkillsSourceScriptTests : IDisposable
+{
+ private static readonly string[] s_rubyExtension = new[] { ".rb" };
+ private static readonly AgentFileSkillScriptRunner s_noOpExecutor = (skill, script, args, ct) => Task.FromResult(null);
+
+ private readonly string _testRoot;
+
+ public AgentFileSkillsSourceScriptTests()
+ {
+ this._testRoot = Path.Combine(Path.GetTempPath(), "skills-source-script-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 GetSkillsAsync_WithScriptFiles_DiscoversScriptsAsync()
+ {
+ // Arrange
+ CreateSkillWithScript(this._testRoot, "my-skill", "A test skill", "Body.", "scripts/convert.py", "print('hello')");
+ var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
+
+ // Act
+ var skills = await source.GetSkillsAsync(CancellationToken.None);
+
+ // Assert
+ Assert.Single(skills);
+ var skill = skills[0];
+ Assert.NotNull(skill.Scripts);
+ Assert.Single(skill.Scripts!);
+ Assert.Equal("scripts/convert.py", skill.Scripts![0].Name);
+ }
+
+ [Fact]
+ public async Task GetSkillsAsync_WithMultipleScriptExtensions_DiscoversAllAsync()
+ {
+ // Arrange
+ string skillDir = CreateSkillDir(this._testRoot, "multi-ext-skill", "Multi-extension skill", "Body.");
+ CreateFile(skillDir, "scripts/run.py", "print('py')");
+ CreateFile(skillDir, "scripts/run.sh", "echo 'sh'");
+ CreateFile(skillDir, "scripts/run.js", "console.log('js')");
+ CreateFile(skillDir, "scripts/run.ps1", "Write-Host 'ps'");
+ CreateFile(skillDir, "scripts/run.cs", "Console.WriteLine();");
+ CreateFile(skillDir, "scripts/run.csx", "Console.WriteLine();");
+ var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
+
+ // Act
+ var skills = await source.GetSkillsAsync(CancellationToken.None);
+
+ // Assert
+ Assert.Single(skills);
+ var scriptNames = skills[0].Scripts!.Select(s => s.Name).OrderBy(n => n, StringComparer.Ordinal).ToList();
+ Assert.Equal(6, scriptNames.Count);
+ Assert.Contains("scripts/run.cs", scriptNames);
+ Assert.Contains("scripts/run.csx", scriptNames);
+ Assert.Contains("scripts/run.js", scriptNames);
+ Assert.Contains("scripts/run.ps1", scriptNames);
+ Assert.Contains("scripts/run.py", scriptNames);
+ Assert.Contains("scripts/run.sh", scriptNames);
+ }
+
+ [Fact]
+ public async Task GetSkillsAsync_NonScriptExtensionsAreNotDiscoveredAsync()
+ {
+ // Arrange
+ string skillDir = CreateSkillDir(this._testRoot, "no-script-skill", "Non-script skill", "Body.");
+ CreateFile(skillDir, "scripts/data.txt", "text data");
+ CreateFile(skillDir, "scripts/config.json", "{}");
+ CreateFile(skillDir, "scripts/notes.md", "# Notes");
+ var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
+
+ // Act
+ var skills = await source.GetSkillsAsync(CancellationToken.None);
+
+ // Assert
+ Assert.Single(skills);
+ Assert.Empty(skills[0].Scripts!);
+ }
+
+ [Fact]
+ public async Task GetSkillsAsync_NoScriptFiles_ReturnsEmptyScriptsAsync()
+ {
+ // Arrange
+ CreateSkillDir(this._testRoot, "no-scripts", "No scripts skill", "Body.");
+ var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
+
+ // Act
+ var skills = await source.GetSkillsAsync(CancellationToken.None);
+
+ // Assert
+ Assert.Single(skills);
+ Assert.NotNull(skills[0].Scripts);
+ Assert.Empty(skills[0].Scripts!);
+ }
+
+ [Fact]
+ public async Task GetSkillsAsync_ScriptsOutsideScriptsDir_AreAlsoDiscoveredAsync()
+ {
+ // Arrange — scripts at any depth in the skill directory are discovered
+ string skillDir = CreateSkillDir(this._testRoot, "root-scripts", "Root scripts skill", "Body.");
+ CreateFile(skillDir, "convert.py", "print('root')");
+ CreateFile(skillDir, "tools/helper.sh", "echo 'helper'");
+ var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
+
+ // Act
+ var skills = await source.GetSkillsAsync(CancellationToken.None);
+
+ // Assert
+ Assert.Single(skills);
+ var scriptNames = skills[0].Scripts!.Select(s => s.Name).OrderBy(n => n, StringComparer.Ordinal).ToList();
+ Assert.Equal(2, scriptNames.Count);
+ Assert.Contains("convert.py", scriptNames);
+ Assert.Contains("tools/helper.sh", scriptNames);
+ }
+
+ [Fact]
+ public async Task GetSkillsAsync_WithRunner_ScriptsCanRunAsync()
+ {
+ // Arrange
+ CreateSkillWithScript(this._testRoot, "exec-skill", "Executor test", "Body.", "scripts/test.py", "print('ok')");
+ var executorCalled = false;
+ var source = new AgentFileSkillsSource(
+ this._testRoot,
+ (skill, script, args, ct) =>
+ {
+ executorCalled = true;
+ Assert.Equal("exec-skill", skill.Frontmatter.Name);
+ Assert.Equal("scripts/test.py", script.Name);
+ Assert.Equal(Path.GetFullPath(Path.Combine(this._testRoot, "exec-skill", "scripts", "test.py")), script.FullPath);
+ return Task.FromResult("executed");
+ });
+
+ // Act
+ var skills = await source.GetSkillsAsync(CancellationToken.None);
+ var scriptResult = await skills[0].Scripts![0].RunAsync(skills[0], new AIFunctionArguments(), CancellationToken.None);
+
+ // Assert
+ Assert.True(executorCalled);
+ Assert.Equal("executed", scriptResult);
+ }
+
+ [Fact]
+ public void Constructor_NullExecutor_DoesNotThrow()
+ {
+ // Arrange & Act & Assert — null runner is allowed when skills have no scripts
+ var source = new AgentFileSkillsSource(this._testRoot, null);
+ Assert.NotNull(source);
+ }
+
+ [Fact]
+ public async Task GetSkillsAsync_ScriptsWithNoRunner_ThrowsOnRunAsync()
+ {
+ // Arrange
+ string skillDir = CreateSkillDir(this._testRoot, "no-runner-skill", "No runner", "Body.");
+ CreateFile(skillDir, "scripts/run.sh", "echo 'hello'");
+ var source = new AgentFileSkillsSource(this._testRoot, scriptRunner: null);
+
+ // Act — discovery succeeds even without a runner
+ var skills = await source.GetSkillsAsync(CancellationToken.None);
+ var script = skills[0].Scripts![0];
+
+ // Assert — running the script throws because no runner was provided
+ await Assert.ThrowsAsync(() => script.RunAsync(skills[0], new AIFunctionArguments(), CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task GetSkillsAsync_CustomScriptExtensions_OnlyDiscoversMatchingAsync()
+ {
+ // Arrange
+ string skillDir = CreateSkillDir(this._testRoot, "custom-ext-skill", "Custom extensions", "Body.");
+ CreateFile(skillDir, "scripts/run.py", "print('py')");
+ CreateFile(skillDir, "scripts/run.rb", "puts 'rb'");
+ var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { AllowedScriptExtensions = s_rubyExtension });
+
+ // Act
+ var skills = await source.GetSkillsAsync(CancellationToken.None);
+
+ // Assert
+ Assert.Single(skills);
+ Assert.Single(skills[0].Scripts!);
+ Assert.Equal("scripts/run.rb", skills[0].Scripts![0].Name);
+ }
+
+ [Fact]
+ public async Task GetSkillsAsync_ExecutorReceivesArgumentsAsync()
+ {
+ // Arrange
+ CreateSkillWithScript(this._testRoot, "args-skill", "Args test", "Body.", "scripts/test.py", "print('ok')");
+ AIFunctionArguments? capturedArgs = null;
+ var source = new AgentFileSkillsSource(
+ this._testRoot,
+ (skill, script, args, ct) =>
+ {
+ capturedArgs = args;
+ return Task.FromResult("done");
+ });
+
+ // Act
+ var skills = await source.GetSkillsAsync(CancellationToken.None);
+ var arguments = new AIFunctionArguments
+ {
+ ["value"] = 26.2,
+ ["factor"] = 1.60934
+ };
+ await skills[0].Scripts![0].RunAsync(skills[0], arguments, CancellationToken.None);
+
+ // Assert
+ Assert.NotNull(capturedArgs);
+ Assert.Equal(26.2, capturedArgs["value"]);
+ Assert.Equal(1.60934, capturedArgs["factor"]);
+ }
+
+ private static string CreateSkillDir(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}");
+ return skillDir;
+ }
+
+ private static void CreateSkillWithScript(string root, string name, string description, string body, string scriptRelativePath, string scriptContent)
+ {
+ string skillDir = CreateSkillDir(root, name, description, body);
+ CreateFile(skillDir, scriptRelativePath, scriptContent);
+ }
+
+ private static void CreateFile(string root, string relativePath, string content)
+ {
+ string fullPath = Path.Combine(root, relativePath.Replace('/', Path.DirectorySeparatorChar));
+ Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!);
+ File.WriteAllText(fullPath, content);
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillFrontmatterValidatorTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillFrontmatterValidatorTests.cs
new file mode 100644
index 0000000000..c0f8412655
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillFrontmatterValidatorTests.cs
@@ -0,0 +1,260 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+
+namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
+
+///
+/// Unit tests for validation.
+///
+public sealed class AgentSkillFrontmatterValidatorTests
+{
+ [Theory]
+ [InlineData("my-skill")]
+ [InlineData("a")]
+ [InlineData("skill123")]
+ [InlineData("a1b2c3")]
+ public void ValidateName_ValidName_ReturnsTrue(string name)
+ {
+ // Act
+ bool result = AgentSkillFrontmatter.ValidateName(name, out string? reason);
+
+ // Assert
+ Assert.True(result);
+ Assert.Null(reason);
+ }
+
+ [Theory]
+ [InlineData("-leading-hyphen")]
+ [InlineData("trailing-hyphen-")]
+ [InlineData("has spaces")]
+ [InlineData("UPPERCASE")]
+ [InlineData("consecutive--hyphens")]
+ [InlineData("special!chars")]
+ public void ValidateName_InvalidName_ReturnsFalse(string name)
+ {
+ // Act
+ bool result = AgentSkillFrontmatter.ValidateName(name, out string? reason);
+
+ // Assert
+ Assert.False(result);
+ Assert.NotNull(reason);
+ Assert.Contains("name", reason, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void ValidateName_NameExceedsMaxLength_ReturnsFalse()
+ {
+ // Arrange
+ string longName = new('a', 65);
+
+ // Act
+ bool result = AgentSkillFrontmatter.ValidateName(longName, out string? reason);
+
+ // Assert
+ Assert.False(result);
+ Assert.NotNull(reason);
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ public void ValidateName_NullOrWhitespace_ReturnsFalse(string? name)
+ {
+ // Act
+ bool result = AgentSkillFrontmatter.ValidateName(name, out string? reason);
+
+ // Assert
+ Assert.False(result);
+ Assert.NotNull(reason);
+ }
+
+ [Fact]
+ public void ValidateDescription_ValidDescription_ReturnsTrue()
+ {
+ // Act
+ bool result = AgentSkillFrontmatter.ValidateDescription("A valid description.", out string? reason);
+
+ // Assert
+ Assert.True(result);
+ Assert.Null(reason);
+ }
+
+ [Fact]
+ public void ValidateDescription_DescriptionExceedsMaxLength_ReturnsFalse()
+ {
+ // Arrange
+ string longDesc = new('x', 1025);
+
+ // Act
+ bool result = AgentSkillFrontmatter.ValidateDescription(longDesc, out string? reason);
+
+ // Assert
+ Assert.False(result);
+ Assert.NotNull(reason);
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ public void ValidateDescription_NullOrWhitespace_ReturnsFalse(string? description)
+ {
+ // Act
+ bool result = AgentSkillFrontmatter.ValidateDescription(description, out string? reason);
+
+ // Assert
+ Assert.False(result);
+ Assert.NotNull(reason);
+ }
+
+ [Fact]
+ public void ValidateCompatibility_Null_ReturnsTrue()
+ {
+ // Act
+ bool result = AgentSkillFrontmatter.ValidateCompatibility(null, out string? reason);
+
+ // Assert
+ Assert.True(result);
+ Assert.Null(reason);
+ }
+
+ [Fact]
+ public void ValidateCompatibility_WithinMaxLength_ReturnsTrue()
+ {
+ // Arrange
+ string compatibility = new('x', 500);
+
+ // Act
+ bool result = AgentSkillFrontmatter.ValidateCompatibility(compatibility, out string? reason);
+
+ // Assert
+ Assert.True(result);
+ Assert.Null(reason);
+ }
+
+ [Fact]
+ public void ValidateCompatibility_ExceedsMaxLength_ReturnsFalse()
+ {
+ // Arrange
+ string compatibility = new('x', 501);
+
+ // Act
+ bool result = AgentSkillFrontmatter.ValidateCompatibility(compatibility, out string? reason);
+
+ // Assert
+ Assert.False(result);
+ Assert.NotNull(reason);
+ }
+
+ [Theory]
+ [InlineData("UPPERCASE")]
+ [InlineData("-leading")]
+ [InlineData("trailing-")]
+ [InlineData("consecutive--hyphens")]
+ public void Constructor_InvalidName_ThrowsArgumentException(string name)
+ {
+ // Act & Assert
+ var ex = Assert.Throws(() => new AgentSkillFrontmatter(name, "A valid description."));
+ Assert.Contains("name", ex.Message, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void Constructor_NameExceedsMaxLength_ThrowsArgumentException()
+ {
+ // Arrange
+ string longName = new('a', 65);
+
+ // Act & Assert
+ Assert.Throws(() => new AgentSkillFrontmatter(longName, "A valid description."));
+ }
+
+ [Fact]
+ public void Constructor_DescriptionExceedsMaxLength_ThrowsArgumentException()
+ {
+ // Arrange
+ string longDesc = new('x', 1025);
+
+ // Act & Assert
+ Assert.Throws(() => new AgentSkillFrontmatter("valid-name", longDesc));
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ public void Constructor_NullOrWhitespaceName_ThrowsArgumentException(string? name)
+ {
+ // Act & Assert
+ Assert.Throws(() => new AgentSkillFrontmatter(name!, "A valid description."));
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ public void Constructor_NullOrWhitespaceDescription_ThrowsArgumentException(string? description)
+ {
+ // Act & Assert
+ Assert.Throws(() => new AgentSkillFrontmatter("valid-name", description!));
+ }
+
+ [Fact]
+ public void Compatibility_ExceedsMaxLength_ThrowsArgumentException()
+ {
+ // Arrange
+ var frontmatter = new AgentSkillFrontmatter("valid-name", "A valid description.");
+ string longCompatibility = new('x', 501);
+
+ // Act & Assert
+ Assert.Throws(() => frontmatter.Compatibility = longCompatibility);
+ }
+
+ [Fact]
+ public void Compatibility_WithinMaxLength_Succeeds()
+ {
+ // Arrange
+ var frontmatter = new AgentSkillFrontmatter("valid-name", "A valid description.");
+ string compatibility = new('x', 500);
+
+ // Act
+ frontmatter.Compatibility = compatibility;
+
+ // Assert
+ Assert.Equal(compatibility, frontmatter.Compatibility);
+ }
+
+ [Fact]
+ public void Compatibility_Null_Succeeds()
+ {
+ // Arrange
+ var frontmatter = new AgentSkillFrontmatter("valid-name", "A valid description.");
+
+ // Act
+ frontmatter.Compatibility = null;
+
+ // Assert
+ Assert.Null(frontmatter.Compatibility);
+ }
+
+ [Fact]
+ public void Constructor_WithCompatibility_SetsValue()
+ {
+ // Arrange & Act
+ var frontmatter = new AgentSkillFrontmatter("valid-name", "A valid description.", "Requires Python 3.10+");
+
+ // Assert
+ Assert.Equal("Requires Python 3.10+", frontmatter.Compatibility);
+ }
+
+ [Fact]
+ public void Constructor_CompatibilityExceedsMaxLength_ThrowsArgumentException()
+ {
+ // Arrange
+ string longCompatibility = new('x', 501);
+
+ // Act & Assert
+ Assert.Throws(() => new AgentSkillFrontmatter("valid-name", "A valid description.", longCompatibility));
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderBuilderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderBuilderTests.cs
new file mode 100644
index 0000000000..85335256a7
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderBuilderTests.cs
@@ -0,0 +1,229 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
+
+///
+/// Unit tests for .
+///
+public sealed class AgentSkillsProviderBuilderTests
+{
+ private readonly TestAIAgent _agent = new();
+
+ private AIContextProvider.InvokingContext CreateInvokingContext()
+ {
+ return new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
+ }
+
+ [Fact]
+ public void Build_NoSourceConfigured_Succeeds()
+ {
+ // Arrange
+ var builder = new AgentSkillsProviderBuilder();
+
+ // Act
+ var provider = builder.Build();
+
+ // Assert
+ Assert.NotNull(provider);
+ }
+
+ [Fact]
+ public void Build_WithCustomSource_Succeeds()
+ {
+ // Arrange
+ var source = new TestAgentSkillsSource(
+ new TestAgentSkill("custom", "Custom skill", "Instructions."));
+ var builder = new AgentSkillsProviderBuilder()
+ .UseSource(source);
+
+ // Act
+ var provider = builder.Build();
+
+ // Assert
+ Assert.NotNull(provider);
+ }
+
+ [Fact]
+ public void UseSource_NullSource_ThrowsArgumentNullException()
+ {
+ // Arrange
+ var builder = new AgentSkillsProviderBuilder();
+
+ // Act & Assert
+ Assert.Throws(() => builder.UseSource(null!));
+ }
+
+ [Fact]
+ public void UseFilter_NullPredicate_ThrowsArgumentNullException()
+ {
+ // Arrange
+ var builder = new AgentSkillsProviderBuilder();
+
+ // Act & Assert
+ Assert.Throws(() => builder.UseFilter(null!));
+ }
+
+ [Fact]
+ public void UseFileScriptRunner_NullRunner_ThrowsArgumentNullException()
+ {
+ // Arrange
+ var builder = new AgentSkillsProviderBuilder();
+
+ // Act & Assert
+ Assert.Throws(() => builder.UseFileScriptRunner(null!));
+ }
+
+ [Fact]
+ public void UseOptions_NullConfigure_ThrowsArgumentNullException()
+ {
+ // Arrange
+ var builder = new AgentSkillsProviderBuilder();
+
+ // Act & Assert
+ Assert.Throws(() => builder.UseOptions(null!));
+ }
+
+ [Fact]
+ public async Task Build_WithFilter_AppliesFilterToSkillsAsync()
+ {
+ // Arrange
+ var source = new TestAgentSkillsSource(
+ new TestAgentSkill("keep-me", "Keep", "Instructions."),
+ new TestAgentSkill("drop-me", "Drop", "Instructions."));
+ var provider = new AgentSkillsProviderBuilder()
+ .UseSource(source)
+ .UseFilter(skill => skill.Frontmatter.Name.StartsWith("keep", StringComparison.OrdinalIgnoreCase))
+ .Build();
+
+ // Act
+ var result = await provider.InvokingAsync(
+ this.CreateInvokingContext(), CancellationToken.None);
+
+ // Assert — the instructions should mention "keep-me" but not "drop-me"
+ Assert.NotNull(result.Instructions);
+ Assert.Contains("keep-me", result.Instructions);
+ Assert.DoesNotContain("drop-me", result.Instructions);
+ }
+
+ [Fact]
+ public async Task Build_WithCacheDisabled_ReloadsOnEachCallAsync()
+ {
+ // Arrange
+ var countingSource = new CountingSource(
+ new TestAgentSkill("skill-a", "A", "Instructions."));
+ var provider = new AgentSkillsProviderBuilder()
+ .UseSource(countingSource)
+ .UseOptions(o => o.DisableCaching = true)
+ .Build();
+
+ // Act
+ await provider.InvokingAsync(this.CreateInvokingContext(), CancellationToken.None);
+ await provider.InvokingAsync(this.CreateInvokingContext(), CancellationToken.None);
+
+ // Assert — inner source should be called each time (dedup still calls through)
+ Assert.True(countingSource.CallCount >= 2);
+ }
+
+ [Fact]
+ public async Task Build_WithCacheEnabled_CachesSkillsAsync()
+ {
+ // Arrange
+ var countingSource = new CountingSource(
+ new TestAgentSkill("skill-a", "A", "Instructions."));
+ var provider = new AgentSkillsProviderBuilder()
+ .UseSource(countingSource)
+ .Build();
+
+ // Act
+ await provider.InvokingAsync(this.CreateInvokingContext(), CancellationToken.None);
+ await provider.InvokingAsync(this.CreateInvokingContext(), CancellationToken.None);
+
+ // Assert — inner source should only be called once due to caching
+ Assert.Equal(1, countingSource.CallCount);
+ }
+
+ [Fact]
+ public void Build_FluentChaining_ReturnsSameBuilder()
+ {
+ // Arrange
+ var builder = new AgentSkillsProviderBuilder();
+ var source = new TestAgentSkillsSource(
+ new TestAgentSkill("test", "Test", "Instructions."));
+
+ // Act — all fluent methods should return the same builder
+ var result = builder
+ .UseSource(source)
+ .UseScriptApproval(false)
+ .UsePromptTemplate("Skills:\n{skills}\n{resource_instructions}\n{script_instructions}");
+
+ // Assert
+ Assert.Same(builder, result);
+ }
+
+ [Fact]
+ public void Build_UseOptions_ConfiguresOptions()
+ {
+ // Arrange
+ var source = new TestAgentSkillsSource(
+ new TestAgentSkill("test", "Test", "Instructions."));
+
+ // Act — UseOptions should not throw and successfully configure
+ var provider = new AgentSkillsProviderBuilder()
+ .UseSource(source)
+ .UseOptions(opts => opts.ScriptApproval = true)
+ .Build();
+
+ // Assert
+ Assert.NotNull(provider);
+ }
+
+ [Fact]
+ public async Task Build_WithMultipleCustomSources_AggregatesAllAsync()
+ {
+ // Arrange
+ var source1 = new TestAgentSkillsSource(
+ new TestAgentSkill("from-one", "Source 1", "Instructions 1."));
+ var source2 = new TestAgentSkillsSource(
+ new TestAgentSkill("from-two", "Source 2", "Instructions 2."));
+ var provider = new AgentSkillsProviderBuilder()
+ .UseSource(source1)
+ .UseSource(source2)
+ .Build();
+
+ // Act
+ var result = await provider.InvokingAsync(
+ this.CreateInvokingContext(), CancellationToken.None);
+
+ // Assert
+ Assert.NotNull(result.Instructions);
+ Assert.Contains("from-one", result.Instructions);
+ Assert.Contains("from-two", result.Instructions);
+ }
+
+ ///
+ /// A test source that counts how many times GetSkillsAsync is called.
+ ///
+ private sealed class CountingSource : AgentSkillsSource
+ {
+ private readonly AgentSkill[] _skills;
+ private int _callCount;
+
+ public CountingSource(params AgentSkill[] skills)
+ {
+ this._skills = skills;
+ }
+
+ public int CallCount => this._callCount;
+
+ public override Task> GetSkillsAsync(CancellationToken cancellationToken = default)
+ {
+ Interlocked.Increment(ref this._callCount);
+ return Task.FromResult>(this._skills);
+ }
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderTests.cs
new file mode 100644
index 0000000000..6dfc45918a
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderTests.cs
@@ -0,0 +1,765 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+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 with .
+///
+public sealed class AgentSkillsProviderTests : IDisposable
+{
+ private static readonly AgentFileSkillScriptRunner s_noOpExecutor = (skill, script, args, ct) => Task.FromResult(null);
+ private readonly string _testRoot;
+ private readonly TestAIAgent _agent = new();
+
+ public AgentSkillsProviderTests()
+ {
+ 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 AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor));
+ 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 AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor));
+ 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 tool (no resources, so no read_skill_resource)
+ Assert.NotNull(result.Tools);
+ var toolNames = result.Tools!.Select(t => t.Name).ToList();
+ Assert.Contains("load_skill", toolNames);
+ Assert.DoesNotContain("read_skill_resource", toolNames);
+ }
+
+ [Fact]
+ public async Task InvokingCoreAsync_NullInputInstructions_SetsInstructionsAsync()
+ {
+ // Arrange
+ this.CreateSkill("null-instr-skill", "Null instruction test", "Body.");
+ var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor));
+ 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 AgentSkillsProviderOptions
+ {
+ SkillsInstructionPrompt = "Custom template: {skills}\n{resource_instructions}\n{script_instructions}"
+ };
+ var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor), 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);
+ Assert.Contains("custom-prompt-skill", result.Instructions);
+ Assert.Contains("Custom prompt", result.Instructions);
+ }
+
+ [Fact]
+ public void Constructor_PromptWithoutSkillsPlaceholder_ThrowsArgumentException()
+ {
+ // Arrange
+ var options = new AgentSkillsProviderOptions
+ {
+ SkillsInstructionPrompt = "No skills placeholder here {resource_instructions} {script_instructions}"
+ };
+
+ // Act & Assert
+ var ex = Assert.Throws(() =>
+ new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor), options));
+ Assert.Contains("{skills}", ex.Message);
+ Assert.Equal("options", ex.ParamName);
+ }
+
+ [Fact]
+ public void Constructor_PromptWithoutRunnerInstructionsPlaceholder_ThrowsArgumentException()
+ {
+ // Arrange
+ var options = new AgentSkillsProviderOptions
+ {
+ SkillsInstructionPrompt = "Has skills {skills} but no runner instructions {resource_instructions}"
+ };
+
+ // Act & Assert
+ var ex = Assert.Throws(() =>
+ new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor), options));
+ Assert.Contains("{script_instructions}", ex.Message);
+ Assert.Equal("options", ex.ParamName);
+ }
+
+ [Fact]
+ public void Constructor_PromptWithBothPlaceholders_Succeeds()
+ {
+ // Arrange
+ var options = new AgentSkillsProviderOptions
+ {
+ SkillsInstructionPrompt = "Skills: {skills}\nResources: {resource_instructions}\nRunner: {script_instructions}"
+ };
+
+ // Act — should not throw
+ var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor), options);
+
+ // Assert
+ Assert.NotNull(provider);
+ }
+
+ [Fact]
+ public void Constructor_PromptWithoutResourceInstructionsPlaceholder_ThrowsArgumentException()
+ {
+ // Arrange
+ var options = new AgentSkillsProviderOptions
+ {
+ SkillsInstructionPrompt = "Has skills {skills} and runner {script_instructions} but no resource instructions"
+ };
+
+ // Act & Assert
+ var ex = Assert.Throws(() =>
+ new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor), options));
+ Assert.Contains("{resource_instructions}", ex.Message);
+ Assert.Equal("options", ex.ParamName);
+ }
+
+ [Fact]
+ public async Task InvokingCoreAsync_SkillNamesAreXmlEscapedAsync()
+ {
+ // Arrange — description with XML-sensitive characters
+ string skillDir = Path.Combine(this._testRoot, "xml-skill");
+ Directory.CreateDirectory(skillDir);
+ File.WriteAllText(
+ Path.Combine(skillDir, "SKILL.md"),
+ "---\nname: xml-skill\ndescription: Uses & \"quotes\"\n---\nBody.");
+ var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor));
+ 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 AgentSkillsProvider(new AgentFileSkillsSource(new[] { dir1, dir2 }, s_noOpExecutor));
+ 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 AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor));
+
+ 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);
+ }
+
+ [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 AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor));
+ 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");
+ }
+
+ [Fact]
+ public async Task ProvideAIContextAsync_ConcurrentCalls_LoadsSkillsOnlyOnceAsync()
+ {
+ // Arrange
+ var source = new CountingAgentSkillsSource(
+ [
+ new TestAgentSkill("concurrent-skill", "Concurrent test", "Body.")
+ ]);
+ var provider = new AgentSkillsProvider(source);
+
+ var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
+
+ // Act — invoke concurrently from multiple threads
+ var tasks = Enumerable.Range(0, 10)
+ .Select(_ => provider.InvokingAsync(invokingContext, CancellationToken.None).AsTask())
+ .ToArray();
+ await Task.WhenAll(tasks);
+
+ // Assert — GetSkillsAsync should have been called exactly once (provider-level caching)
+ Assert.Equal(1, source.GetSkillsCallCount);
+ }
+
+ [Fact]
+ public async Task InvokingCoreAsync_WithScripts_IncludesRunSkillScriptToolAsync()
+ {
+ // Arrange
+ string skillDir = Path.Combine(this._testRoot, "script-skill");
+ Directory.CreateDirectory(Path.Combine(skillDir, "scripts"));
+ File.WriteAllText(
+ Path.Combine(skillDir, "SKILL.md"),
+ "---\nname: script-skill\ndescription: Skill with scripts\n---\nBody.");
+ File.WriteAllText(
+ Path.Combine(skillDir, "scripts", "test.py"),
+ "print('hello')");
+
+ var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
+ var provider = new AgentSkillsProvider(source);
+ 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.Tools);
+ var toolNames = result.Tools!.Select(t => t.Name).ToList();
+ Assert.Contains("run_skill_script", toolNames);
+ Assert.Contains("load_skill", toolNames);
+ }
+
+ [Fact]
+ public async Task InvokingCoreAsync_WithoutScripts_NoRunSkillScriptToolAsync()
+ {
+ // Arrange
+ this.CreateSkill("no-script-skill", "No scripts", "Body.");
+ var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
+ var provider = new AgentSkillsProvider(source);
+ 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.Tools);
+ var toolNames = result.Tools!.Select(t => t.Name).ToList();
+ Assert.DoesNotContain("run_skill_script", toolNames);
+ }
+
+ [Fact]
+ public void Build_WithFileSkillsButNoExecutor_ThrowsInvalidOperationException()
+ {
+ // Arrange
+ var builder = new AgentSkillsProviderBuilder()
+ .UseFileSkill(this._testRoot);
+
+ // Act & Assert
+ Assert.Throws(() => builder.Build());
+ }
+
+ [Fact]
+ public async Task Builder_UseFileSkillWithOptions_DiscoverSkillsAsync()
+ {
+ // Arrange
+ this.CreateSkill("opts-skill", "Options skill", "Options body.");
+ var options = new AgentFileSkillsSourceOptions();
+ var provider = new AgentSkillsProviderBuilder()
+ .UseFileSkill(this._testRoot, options)
+ .UseFileScriptRunner(s_noOpExecutor)
+ .UseOptions(o => o.DisableCaching = true)
+ .Build();
+
+ // Act
+ var inputContext = new AIContext();
+ var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext);
+ var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
+
+ // Assert
+ Assert.NotNull(result.Instructions);
+ Assert.Contains("opts-skill", result.Instructions);
+ }
+
+ [Fact]
+ public async Task Builder_UseFileSkillsWithOptions_DiscoverMultipleSkillsAsync()
+ {
+ // Arrange
+ string dir1 = Path.Combine(this._testRoot, "multi-opts-1");
+ string dir2 = Path.Combine(this._testRoot, "multi-opts-2");
+ CreateSkillIn(dir1, "skill-x", "Skill X", "Body X.");
+ CreateSkillIn(dir2, "skill-y", "Skill Y", "Body Y.");
+
+ var options = new AgentFileSkillsSourceOptions();
+ var provider = new AgentSkillsProviderBuilder()
+ .UseFileSkills(new[] { dir1, dir2 }, options)
+ .UseFileScriptRunner(s_noOpExecutor)
+ .UseOptions(o => o.DisableCaching = true)
+ .Build();
+
+ // Act
+ var inputContext = new AIContext();
+ var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext);
+ var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
+
+ // Assert
+ Assert.NotNull(result.Instructions);
+ Assert.Contains("skill-x", result.Instructions);
+ Assert.Contains("skill-y", result.Instructions);
+ }
+
+ [Fact]
+ public async Task Builder_UseFileSkillWithOptionsResourceFilter_FiltersResourcesAsync()
+ {
+ // Arrange — create a skill with both .md and .json resources
+ string skillDir = Path.Combine(this._testRoot, "res-filter-opts");
+ CreateSkillIn(skillDir, "filter-skill", "Filter test", "Filter body.");
+ File.WriteAllText(Path.Combine(skillDir, "data.json"), "{}", System.Text.Encoding.UTF8);
+ File.WriteAllText(Path.Combine(skillDir, "notes.txt"), "notes", System.Text.Encoding.UTF8);
+
+ // Only allow .json resources
+ var options = new AgentFileSkillsSourceOptions
+ {
+ AllowedResourceExtensions = [".json"],
+ };
+ var source = new AgentFileSkillsSource(skillDir, s_noOpExecutor, options);
+
+ // Act
+ var skills = await source.GetSkillsAsync();
+
+ // Assert
+ Assert.Single(skills);
+ var fileSkill = Assert.IsType(skills[0]);
+ Assert.All(fileSkill.Resources, r => Assert.EndsWith(".json", r.Name));
+ }
+
+ private void CreateSkill(string name, string description, string body)
+ {
+ CreateSkillIn(this._testRoot, name, description, body);
+ }
+
+ [Fact]
+ public async Task LoadSkill_DefaultOptions_ReturnsFullContentAsync()
+ {
+ // Arrange
+ this.CreateSkill("content-skill", "Content test", "Skill body.");
+ var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor));
+ var inputContext = new AIContext();
+ var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext);
+ var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
+ var loadSkillTool = result.Tools!.First(t => t.Name == "load_skill") as AIFunction;
+
+ // Act
+ var content = await loadSkillTool!.InvokeAsync(new AIFunctionArguments(new Dictionary { ["skillName"] = "content-skill" }));
+
+ // Assert — should contain frontmatter and body
+ var text = content!.ToString()!;
+ Assert.Contains("---", text);
+ Assert.Contains("name: content-skill", text);
+ Assert.Contains("Skill body.", text);
+ }
+
+ [Fact]
+ public async Task Builder_UseFileScriptRunnerAfterUseFileSkills_RunnerIsUsedAsync()
+ {
+ // Arrange — create a skill with a script file
+ string skillDir = Path.Combine(this._testRoot, "builder-skill");
+ Directory.CreateDirectory(Path.Combine(skillDir, "scripts"));
+ File.WriteAllText(
+ Path.Combine(skillDir, "SKILL.md"),
+ "---\nname: builder-skill\ndescription: Builder test\n---\nBody.");
+ File.WriteAllText(
+ Path.Combine(skillDir, "scripts", "run.py"),
+ "print('ok')");
+
+ var executorCalled = false;
+
+ // Act — call UseFileScriptRunner AFTER UseFileSkill (the bug scenario)
+ var provider = new AgentSkillsProviderBuilder()
+ .UseFileSkill(this._testRoot)
+ .UseFileScriptRunner((skill, script, args, ct) =>
+ {
+ executorCalled = true;
+ return Task.FromResult("executed");
+ })
+ .Build();
+
+ var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
+ var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
+
+ // Assert — run_skill_script tool should be present and executor should work
+ Assert.NotNull(result.Tools);
+ var toolNames = result.Tools!.Select(t => t.Name).ToList();
+ Assert.Contains("run_skill_script", toolNames);
+
+ var runScriptTool = result.Tools!.First(t => t.Name == "run_skill_script") as AIFunction;
+ await runScriptTool!.InvokeAsync(new AIFunctionArguments(new Dictionary
+ {
+ ["skillName"] = "builder-skill",
+ ["scriptName"] = "scripts/run.py",
+ }));
+
+ Assert.True(executorCalled);
+ }
+
+ 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}");
+ }
+
+ [Fact]
+ public async Task Build_WithCachingDisabled_ReloadsSkillsOnEachCallAsync()
+ {
+ // Arrange
+ var source = new CountingAgentSkillsSource(
+ [
+ new TestAgentSkill("no-cache-skill", "No cache test", "Body.")
+ ]);
+ var provider = new AgentSkillsProviderBuilder()
+ .UseSource(source)
+ .UseOptions(o => o.DisableCaching = true)
+ .Build();
+
+ var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
+
+ // Act
+ await provider.InvokingAsync(invokingContext, CancellationToken.None);
+ await provider.InvokingAsync(invokingContext, CancellationToken.None);
+
+ // Assert — source should be called more than once since caching is disabled
+ Assert.True(source.GetSkillsCallCount > 1);
+ }
+
+ [Fact]
+ public async Task Build_WithCachingEnabled_CachesSkillsAsync()
+ {
+ // Arrange
+ var source = new CountingAgentSkillsSource(
+ [
+ new TestAgentSkill("cached-skill", "Cached test", "Body.")
+ ]);
+ var provider = new AgentSkillsProviderBuilder()
+ .UseSource(source)
+ .Build();
+
+ var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
+
+ // Act
+ await provider.InvokingAsync(invokingContext, CancellationToken.None);
+ await provider.InvokingAsync(invokingContext, CancellationToken.None);
+
+ // Assert — source should be called exactly once (caching is on by default)
+ Assert.Equal(1, source.GetSkillsCallCount);
+ }
+
+ [Fact]
+ public async Task Build_DefaultOptions_CachesSkillsAsync()
+ {
+ // Arrange
+ var source = new CountingAgentSkillsSource(
+ [
+ new TestAgentSkill("default-skill", "Default test", "Body.")
+ ]);
+ var provider = new AgentSkillsProviderBuilder()
+ .UseSource(source)
+ .Build();
+
+ var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
+
+ // Act
+ await provider.InvokingAsync(invokingContext, CancellationToken.None);
+ await provider.InvokingAsync(invokingContext, CancellationToken.None);
+
+ // Assert — default behavior caches
+ Assert.Equal(1, source.GetSkillsCallCount);
+ }
+
+ [Fact]
+ public async Task InvokingCoreAsync_WithScriptsAndScriptApproval_WrapsRunScriptToolAsync()
+ {
+ // Arrange — create a skill with a script and enable ScriptApproval
+ string skillDir = Path.Combine(this._testRoot, "approval-skill");
+ Directory.CreateDirectory(Path.Combine(skillDir, "scripts"));
+ File.WriteAllText(
+ Path.Combine(skillDir, "SKILL.md"),
+ "---\nname: approval-skill\ndescription: Approval test\n---\nBody.");
+ File.WriteAllText(
+ Path.Combine(skillDir, "scripts", "run.py"),
+ "print('hello')");
+
+ var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
+ var options = new AgentSkillsProviderOptions { ScriptApproval = true };
+ var provider = new AgentSkillsProvider(source, options);
+ var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
+
+ // Act
+ var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
+
+ // Assert — run_skill_script tool should be wrapped in ApprovalRequiredAIFunction
+ Assert.NotNull(result.Tools);
+ var scriptTool = result.Tools!.FirstOrDefault(t => t.Name == "run_skill_script");
+ Assert.NotNull(scriptTool);
+ Assert.IsType(scriptTool);
+ }
+
+ [Fact]
+ public async Task InvokingCoreAsync_WithScriptsNoScriptApproval_DoesNotWrapRunScriptToolAsync()
+ {
+ // Arrange — create a skill with a script, default options (no approval)
+ string skillDir = Path.Combine(this._testRoot, "no-approval-skill");
+ Directory.CreateDirectory(Path.Combine(skillDir, "scripts"));
+ File.WriteAllText(
+ Path.Combine(skillDir, "SKILL.md"),
+ "---\nname: no-approval-skill\ndescription: No approval test\n---\nBody.");
+ File.WriteAllText(
+ Path.Combine(skillDir, "scripts", "run.py"),
+ "print('hello')");
+
+ var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
+ var provider = new AgentSkillsProvider(source);
+ var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
+
+ // Act
+ var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
+
+ // Assert — run_skill_script tool should NOT be wrapped
+ Assert.NotNull(result.Tools);
+ var scriptTool = result.Tools!.FirstOrDefault(t => t.Name == "run_skill_script");
+ Assert.NotNull(scriptTool);
+ Assert.IsNotType(scriptTool);
+ }
+
+ [Fact]
+ public async Task InvokingCoreAsync_MultipleInvocations_ToolsAreSharedWhenCachedAsync()
+ {
+ // Arrange — with default caching, tools should be the same reference
+ this.CreateSkill("cached-tools-skill", "Cached tools test", "Body.");
+ var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
+ var provider = new AgentSkillsProvider(source);
+ var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
+
+ // Act
+ var result1 = await provider.InvokingAsync(invokingContext, CancellationToken.None);
+ var result2 = await provider.InvokingAsync(invokingContext, CancellationToken.None);
+
+ // Assert — tool lists should be the same reference (cached)
+ Assert.NotNull(result1.Tools);
+ Assert.NotNull(result2.Tools);
+ Assert.Same(result1.Tools, result2.Tools);
+ }
+
+ [Fact]
+ public async Task InvokingCoreAsync_MultipleInvocations_ToolsAreNotSharedWhenCachingDisabledAsync()
+ {
+ // Arrange — with caching disabled, tools should be rebuilt per invocation
+ this.CreateSkill("fresh-tools-skill", "Fresh tools test", "Body.");
+ var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
+ var options = new AgentSkillsProviderOptions { DisableCaching = true };
+ var provider = new AgentSkillsProvider(source, options);
+ var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
+
+ // Act
+ var result1 = await provider.InvokingAsync(invokingContext, CancellationToken.None);
+ var result2 = await provider.InvokingAsync(invokingContext, CancellationToken.None);
+
+ // Assert — tool lists should not be the same reference
+ Assert.NotNull(result1.Tools);
+ Assert.NotNull(result2.Tools);
+ Assert.NotSame(result1.Tools, result2.Tools);
+ }
+
+ [Fact]
+ public async Task Constructor_SingleDirectory_DiscoverFileSkillsAsync()
+ {
+ // Arrange
+ this.CreateSkill("file-ctor-skill", "File ctor test", "File body.");
+ var provider = new AgentSkillsProvider(this._testRoot, s_noOpExecutor);
+ var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
+
+ // Act
+ var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
+
+ // Assert
+ Assert.NotNull(result.Instructions);
+ Assert.Contains("file-ctor-skill", result.Instructions);
+ Assert.NotNull(result.Tools);
+ Assert.Contains(result.Tools!, t => t.Name == "load_skill");
+ }
+
+ [Fact]
+ public async Task Constructor_MultipleDirectories_DiscoverFileSkillsAsync()
+ {
+ // Arrange
+ string dir1 = Path.Combine(this._testRoot, "dir1");
+ string dir2 = Path.Combine(this._testRoot, "dir2");
+ Directory.CreateDirectory(dir1);
+ Directory.CreateDirectory(dir2);
+ CreateSkillIn(dir1, "skill-a", "Skill A", "Body A.");
+ CreateSkillIn(dir2, "skill-b", "Skill B", "Body B.");
+
+ var provider = new AgentSkillsProvider(new[] { dir1, dir2 }, s_noOpExecutor);
+ var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
+
+ // Act
+ var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
+
+ // Assert
+ Assert.NotNull(result.Instructions);
+ Assert.Contains("skill-a", result.Instructions);
+ Assert.Contains("skill-b", result.Instructions);
+ }
+
+ [Fact]
+ public async Task Constructor_MultipleDirectories_DeduplicatesSkillsByNameAsync()
+ {
+ // Arrange — same skill name in two directories
+ string dir1 = Path.Combine(this._testRoot, "dup1");
+ string dir2 = Path.Combine(this._testRoot, "dup2");
+ Directory.CreateDirectory(dir1);
+ Directory.CreateDirectory(dir2);
+ CreateSkillIn(dir1, "dup-skill", "First", "Body 1.");
+ CreateSkillIn(dir2, "dup-skill", "Second", "Body 2.");
+
+ var provider = new AgentSkillsProvider(new[] { dir1, dir2 }, s_noOpExecutor);
+ var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
+
+ // Act
+ var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
+ var loadSkillTool = result.Tools!.First(t => t.Name == "load_skill") as AIFunction;
+ var content = await loadSkillTool!.InvokeAsync(new AIFunctionArguments(new Dictionary { ["skillName"] = "dup-skill" }));
+
+ // Assert — only first occurrence should survive
+ Assert.NotNull(content);
+ Assert.Contains("Body 1.", content!.ToString()!);
+ }
+
+ ///
+ /// A test skill source that counts how many times is called.
+ ///
+ private sealed class CountingAgentSkillsSource : AgentSkillsSource
+ {
+ private readonly IList _skills;
+ private int _callCount;
+
+ public CountingAgentSkillsSource(IList skills)
+ {
+ this._skills = skills;
+ }
+
+ public int GetSkillsCallCount => this._callCount;
+
+ public override Task> GetSkillsAsync(CancellationToken cancellationToken = default)
+ {
+ Interlocked.Increment(ref this._callCount);
+ return Task.FromResult(this._skills);
+ }
+ }
+
+ private sealed class TestAgentSkill : AgentSkill
+ {
+ private readonly string _content;
+
+ public TestAgentSkill(string name, string description, string content)
+ {
+ this.Frontmatter = new AgentSkillFrontmatter(name, description);
+ this._content = content;
+ }
+
+ public override AgentSkillFrontmatter Frontmatter { get; }
+
+ public override string Content => this._content;
+
+ public override IReadOnlyList? Resources => null;
+
+ public override IReadOnlyList? Scripts => null;
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/DeduplicatingAgentSkillsSourceTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/DeduplicatingAgentSkillsSourceTests.cs
new file mode 100644
index 0000000000..860f402005
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/DeduplicatingAgentSkillsSourceTests.cs
@@ -0,0 +1,99 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
+
+///
+/// Unit tests for .
+///
+public sealed class DeduplicatingAgentSkillsSourceTests
+{
+ [Fact]
+ public async Task GetSkillsAsync_NoDuplicates_ReturnsAllSkillsAsync()
+ {
+ // Arrange
+ var inner = new TestAgentSkillsSource(
+ new TestAgentSkill("skill-a", "A", "Instructions A."),
+ new TestAgentSkill("skill-b", "B", "Instructions B."));
+ var source = new DeduplicatingAgentSkillsSource(inner);
+
+ // Act
+ var result = await source.GetSkillsAsync(CancellationToken.None);
+
+ // Assert
+ Assert.Equal(2, result.Count);
+ }
+
+ [Fact]
+ public async Task GetSkillsAsync_WithDuplicates_KeepsFirstOccurrenceAsync()
+ {
+ // Arrange
+ var skills = new AgentSkill[]
+ {
+ new TestAgentSkill("dupe", "First", "Instructions 1."),
+ new TestAgentSkill("dupe", "Second", "Instructions 2."),
+ new TestAgentSkill("unique", "Unique", "Instructions 3."),
+ };
+ var inner = new TestAgentSkillsSource(skills);
+ var source = new DeduplicatingAgentSkillsSource(inner);
+
+ // Act
+ var result = await source.GetSkillsAsync(CancellationToken.None);
+
+ // Assert
+ Assert.Equal(2, result.Count);
+ Assert.Equal("First", result.First(s => s.Frontmatter.Name == "dupe").Frontmatter.Description);
+ Assert.Contains(result, s => s.Frontmatter.Name == "unique");
+ }
+
+ [Fact]
+ public async Task GetSkillsAsync_CaseInsensitiveDuplication_KeepsFirstAsync()
+ {
+ // Arrange — use a custom source that returns skills with same name but different casing
+ var inner = new FakeDuplicateCaseSource();
+ var source = new DeduplicatingAgentSkillsSource(inner);
+
+ // Act
+ var result = await source.GetSkillsAsync(CancellationToken.None);
+
+ // Assert
+ Assert.Single(result);
+ Assert.Equal("First", result[0].Frontmatter.Description);
+ }
+
+ [Fact]
+ public async Task GetSkillsAsync_EmptySource_ReturnsEmptyAsync()
+ {
+ // Arrange
+ var inner = new TestAgentSkillsSource(System.Array.Empty());
+ var source = new DeduplicatingAgentSkillsSource(inner);
+
+ // Act
+ var result = await source.GetSkillsAsync(CancellationToken.None);
+
+ // Assert
+ Assert.Empty(result);
+ }
+
+ ///
+ /// A fake source that returns skills with names differing only by case.
+ ///
+ private sealed class FakeDuplicateCaseSource : AgentSkillsSource
+ {
+ public override Task> GetSkillsAsync(CancellationToken cancellationToken = default)
+ {
+ // AgentSkillFrontmatter validates names must be lowercase, so we build
+ // two skills with the same lowercase name to test case-insensitive dedup.
+ var skills = new List
+ {
+ new TestAgentSkill("my-skill", "First", "Instructions 1."),
+ new TestAgentSkill("my-skill", "Second", "Instructions 2."),
+ };
+ return Task.FromResult>(skills);
+ }
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs
index 6134b04feb..e9dc2e0358 100644
--- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs
@@ -4,25 +4,25 @@
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.
+/// Unit tests for the skill discovery and parsing logic.
///
public sealed class FileAgentSkillLoaderTests : IDisposable
{
- private static readonly string[] s_traversalResource = new[] { "../secret.txt" };
+ private static readonly string[] s_customExtensions = [".custom"];
+ private static readonly string[] s_validExtensions = [".md", ".json", ".custom"];
+ private static readonly string[] s_mixedValidInvalidExtensions = [".md", "json"];
+ private static readonly AgentFileSkillScriptRunner s_noOpExecutor = (skill, script, args, ct) => Task.FromResult(null);
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()
@@ -34,23 +34,23 @@ public void Dispose()
}
[Fact]
- public void DiscoverAndLoadSkills_ValidSkill_ReturnsSkill()
+ public async Task GetSkillsAsync_ValidSkill_ReturnsSkillAsync()
{
// Arrange
_ = this.CreateSkillDirectory("my-skill", "A test skill", "Use this skill to do things.");
+ var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
// Act
- var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
+ var skills = await source.GetSkillsAsync();
// 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);
+ Assert.Equal("my-skill", skills[0].Frontmatter.Name);
+ Assert.Equal("A test skill", skills[0].Frontmatter.Description);
}
[Fact]
- public void DiscoverAndLoadSkills_QuotedFrontmatterValues_ParsesCorrectly()
+ public async Task GetSkillsAsync_QuotedFrontmatterValues_ParsesCorrectlyAsync()
{
// Arrange
string skillDir = Path.Combine(this._testRoot, "quoted-skill");
@@ -58,33 +58,35 @@ public void DiscoverAndLoadSkills_QuotedFrontmatterValues_ParsesCorrectly()
File.WriteAllText(
Path.Combine(skillDir, "SKILL.md"),
"---\nname: 'quoted-skill'\ndescription: \"A quoted description\"\n---\nBody text.");
+ var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
// Act
- var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
+ var skills = await source.GetSkillsAsync();
// Assert
Assert.Single(skills);
- Assert.Equal("quoted-skill", skills["quoted-skill"].Frontmatter.Name);
- Assert.Equal("A quoted description", skills["quoted-skill"].Frontmatter.Description);
+ Assert.Equal("quoted-skill", skills[0].Frontmatter.Name);
+ Assert.Equal("A quoted description", skills[0].Frontmatter.Description);
}
[Fact]
- public void DiscoverAndLoadSkills_MissingFrontmatter_ExcludesSkill()
+ public async Task GetSkillsAsync_MissingFrontmatter_ExcludesSkillAsync()
{
// Arrange
string skillDir = Path.Combine(this._testRoot, "bad-skill");
Directory.CreateDirectory(skillDir);
File.WriteAllText(Path.Combine(skillDir, "SKILL.md"), "No frontmatter here.");
+ var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
// Act
- var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
+ var skills = await source.GetSkillsAsync();
// Assert
Assert.Empty(skills);
}
[Fact]
- public void DiscoverAndLoadSkills_MissingNameField_ExcludesSkill()
+ public async Task GetSkillsAsync_MissingNameField_ExcludesSkillAsync()
{
// Arrange
string skillDir = Path.Combine(this._testRoot, "no-name");
@@ -92,16 +94,17 @@ public void DiscoverAndLoadSkills_MissingNameField_ExcludesSkill()
File.WriteAllText(
Path.Combine(skillDir, "SKILL.md"),
"---\ndescription: A skill without a name\n---\nBody.");
+ var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
// Act
- var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
+ var skills = await source.GetSkillsAsync();
// Assert
Assert.Empty(skills);
}
[Fact]
- public void DiscoverAndLoadSkills_MissingDescriptionField_ExcludesSkill()
+ public async Task GetSkillsAsync_MissingDescriptionField_ExcludesSkillAsync()
{
// Arrange
string skillDir = Path.Combine(this._testRoot, "no-desc");
@@ -109,9 +112,10 @@ public void DiscoverAndLoadSkills_MissingDescriptionField_ExcludesSkill()
File.WriteAllText(
Path.Combine(skillDir, "SKILL.md"),
"---\nname: no-desc\n---\nBody.");
+ var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
// Act
- var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
+ var skills = await source.GetSkillsAsync();
// Assert
Assert.Empty(skills);
@@ -123,7 +127,7 @@ public void DiscoverAndLoadSkills_MissingDescriptionField_ExcludesSkill()
[InlineData("trailing-hyphen-")]
[InlineData("has spaces")]
[InlineData("consecutive--hyphens")]
- public void DiscoverAndLoadSkills_InvalidName_ExcludesSkill(string invalidName)
+ public async Task GetSkillsAsync_InvalidName_ExcludesSkillAsync(string invalidName)
{
// Arrange
string skillDir = Path.Combine(this._testRoot, invalidName);
@@ -136,16 +140,17 @@ public void DiscoverAndLoadSkills_InvalidName_ExcludesSkill(string invalidName)
File.WriteAllText(
Path.Combine(skillDir, "SKILL.md"),
$"---\nname: {invalidName}\ndescription: A skill\n---\nBody.");
+ var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
// Act
- var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
+ var skills = await source.GetSkillsAsync();
// Assert
Assert.Empty(skills);
}
[Fact]
- public void DiscoverAndLoadSkills_DuplicateNames_KeepsFirstOnly()
+ public async Task GetSkillsAsync_DuplicateNames_KeepsFirstOnlyAsync()
{
// Arrange
string dir1 = Path.Combine(this._testRoot, "dupe");
@@ -162,34 +167,37 @@ public void DiscoverAndLoadSkills_DuplicateNames_KeepsFirstOnly()
File.WriteAllText(
Path.Combine(nestedDir, "SKILL.md"),
"---\nname: dupe\ndescription: Second\n---\nSecond body.");
+ var fileSource = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
+ var source = new DeduplicatingAgentSkillsSource(fileSource);
// Act
- var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
+ var skills = await source.GetSkillsAsync();
// Assert – filesystem enumeration order is not guaranteed, so we only
// verify that exactly one of the two duplicates was kept.
Assert.Single(skills);
- string desc = skills["dupe"].Frontmatter.Description;
+ string desc = skills[0].Frontmatter.Description;
Assert.True(desc == "First" || desc == "Second", $"Unexpected description: {desc}");
}
[Fact]
- public void DiscoverAndLoadSkills_NameMismatchesDirectory_ExcludesSkill()
+ public async Task GetSkillsAsync_NameMismatchesDirectory_ExcludesSkillAsync()
{
// Arrange — directory name differs from the frontmatter name
_ = this.CreateSkillDirectoryWithRawContent(
"wrong-dir-name",
"---\nname: actual-skill-name\ndescription: A skill\n---\nBody.");
+ var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
// Act
- var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
+ var skills = await source.GetSkillsAsync();
// Assert
Assert.Empty(skills);
}
[Fact]
- public void DiscoverAndLoadSkills_FilesWithMatchingExtensions_DiscoveredAsResources()
+ public async Task GetSkillsAsync_FilesWithMatchingExtensions_DiscoveredAsResourcesAsync()
{
// Arrange — create resource files in the skill directory
string skillDir = Path.Combine(this._testRoot, "resource-skill");
@@ -200,20 +208,21 @@ public void DiscoverAndLoadSkills_FilesWithMatchingExtensions_DiscoveredAsResour
File.WriteAllText(
Path.Combine(skillDir, "SKILL.md"),
"---\nname: resource-skill\ndescription: Has resources\n---\nSee docs for details.");
+ var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
// Act
- var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
+ var skills = await source.GetSkillsAsync();
// Assert
Assert.Single(skills);
- var skill = skills["resource-skill"];
- Assert.Equal(2, skill.ResourceNames.Count);
- Assert.Contains(skill.ResourceNames, r => r.Equals("refs/FAQ.md", StringComparison.OrdinalIgnoreCase));
- Assert.Contains(skill.ResourceNames, r => r.Equals("refs/data.json", StringComparison.OrdinalIgnoreCase));
+ var skill = skills[0];
+ Assert.Equal(2, skill.Resources!.Count);
+ Assert.Contains(skill.Resources!, r => r.Name.Equals("refs/FAQ.md", StringComparison.OrdinalIgnoreCase));
+ Assert.Contains(skill.Resources!, r => r.Name.Equals("refs/data.json", StringComparison.OrdinalIgnoreCase));
}
[Fact]
- public void DiscoverAndLoadSkills_FilesWithNonMatchingExtensions_NotDiscovered()
+ public async Task GetSkillsAsync_FilesWithNonMatchingExtensions_NotDiscoveredAsync()
{
// Arrange — create a file with an extension not in the default list
string skillDir = Path.Combine(this._testRoot, "ext-skill");
@@ -223,19 +232,20 @@ public void DiscoverAndLoadSkills_FilesWithNonMatchingExtensions_NotDiscovered()
File.WriteAllText(
Path.Combine(skillDir, "SKILL.md"),
"---\nname: ext-skill\ndescription: Extension test\n---\nBody.");
+ var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
// Act
- var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
+ var skills = await source.GetSkillsAsync();
// Assert
Assert.Single(skills);
- var skill = skills["ext-skill"];
- Assert.Single(skill.ResourceNames);
- Assert.Equal("data.json", skill.ResourceNames[0]);
+ var skill = skills[0];
+ Assert.Single(skill.Resources!);
+ Assert.Equal("data.json", skill.Resources![0].Name);
}
[Fact]
- public void DiscoverAndLoadSkills_SkillMdFile_NotIncludedAsResource()
+ public async Task GetSkillsAsync_SkillMdFile_NotIncludedAsResourceAsync()
{
// Arrange — the SKILL.md file itself should not be in the resource list
string skillDir = Path.Combine(this._testRoot, "selfref-skill");
@@ -244,19 +254,20 @@ public void DiscoverAndLoadSkills_SkillMdFile_NotIncludedAsResource()
File.WriteAllText(
Path.Combine(skillDir, "SKILL.md"),
"---\nname: selfref-skill\ndescription: Self ref test\n---\nBody.");
+ var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
// Act
- var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
+ var skills = await source.GetSkillsAsync();
// Assert
Assert.Single(skills);
- var skill = skills["selfref-skill"];
- Assert.Single(skill.ResourceNames);
- Assert.Equal("notes.md", skill.ResourceNames[0]);
+ var skill = skills[0];
+ Assert.Single(skill.Resources!);
+ Assert.Equal("notes.md", skill.Resources![0].Name);
}
[Fact]
- public void DiscoverAndLoadSkills_NestedResourceFiles_Discovered()
+ public async Task GetSkillsAsync_NestedResourceFiles_DiscoveredAsync()
{
// Arrange — resource files in nested subdirectories
string skillDir = Path.Combine(this._testRoot, "nested-res-skill");
@@ -266,26 +277,22 @@ public void DiscoverAndLoadSkills_NestedResourceFiles_Discovered()
File.WriteAllText(
Path.Combine(skillDir, "SKILL.md"),
"---\nname: nested-res-skill\ndescription: Nested resources\n---\nBody.");
+ var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
// Act
- var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
+ var skills = await source.GetSkillsAsync();
// Assert
Assert.Single(skills);
- var skill = skills["nested-res-skill"];
- Assert.Single(skill.ResourceNames);
- Assert.Contains(skill.ResourceNames, r => r.Equals("level1/level2/deep.md", StringComparison.OrdinalIgnoreCase));
+ var skill = skills[0];
+ Assert.Single(skill.Resources!);
+ Assert.Contains(skill.Resources!, r => r.Name.Equals("level1/level2/deep.md", StringComparison.OrdinalIgnoreCase));
}
- private static readonly string[] s_customExtensions = new[] { ".custom" };
- private static readonly string[] s_validExtensions = new[] { ".md", ".json", ".custom" };
- private static readonly string[] s_mixedValidInvalidExtensions = new[] { ".md", "json" };
-
[Fact]
- public void DiscoverAndLoadSkills_CustomResourceExtensions_UsedForDiscovery()
+ public async Task GetSkillsAsync_CustomResourceExtensions_UsedForDiscoveryAsync()
{
- // Arrange — use a loader with custom extensions
- var customLoader = new FileAgentSkillLoader(NullLogger.Instance, s_customExtensions);
+ // Arrange — use a source with custom extensions
string skillDir = Path.Combine(this._testRoot, "custom-ext-skill");
Directory.CreateDirectory(skillDir);
File.WriteAllText(Path.Combine(skillDir, "data.custom"), "custom data");
@@ -293,15 +300,16 @@ public void DiscoverAndLoadSkills_CustomResourceExtensions_UsedForDiscovery()
File.WriteAllText(
Path.Combine(skillDir, "SKILL.md"),
"---\nname: custom-ext-skill\ndescription: Custom extensions\n---\nBody.");
+ var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { AllowedResourceExtensions = s_customExtensions });
// Act
- var skills = customLoader.DiscoverAndLoadSkills(new[] { this._testRoot });
+ var skills = await source.GetSkillsAsync();
// Assert — only .custom files should be discovered, not .json
Assert.Single(skills);
- var skill = skills["custom-ext-skill"];
- Assert.Single(skill.ResourceNames);
- Assert.Equal("data.custom", skill.ResourceNames[0]);
+ var skill = skills[0];
+ Assert.Single(skill.Resources!);
+ Assert.Equal("data.custom", skill.Resources![0].Name);
}
[Theory]
@@ -311,39 +319,39 @@ public void DiscoverAndLoadSkills_CustomResourceExtensions_UsedForDiscovery()
public void Constructor_InvalidExtension_ThrowsArgumentException(string badExtension)
{
// Arrange & Act & Assert
- Assert.Throws(() => new FileAgentSkillLoader(NullLogger.Instance, new[] { badExtension }));
+ Assert.Throws(() => new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { AllowedResourceExtensions = new string[] { badExtension } }));
}
[Fact]
- public void Constructor_NullExtensions_UsesDefaults()
+ public async Task Constructor_NullExtensions_UsesDefaultsAsync()
{
// Arrange & Act
- var loader = new FileAgentSkillLoader(NullLogger.Instance, null);
string skillDir = this.CreateSkillDirectory("null-ext", "A skill", "Body.");
File.WriteAllText(Path.Combine(skillDir, "notes.md"), "notes");
+ var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
// Assert — default extensions include .md
- var skills = loader.DiscoverAndLoadSkills(new[] { this._testRoot });
- Assert.Single(skills["null-ext"].ResourceNames);
+ var skills = await source.GetSkillsAsync();
+ Assert.Single(skills[0].Resources!);
}
[Fact]
public void Constructor_ValidExtensions_DoesNotThrow()
{
// Arrange & Act & Assert — should not throw
- var loader = new FileAgentSkillLoader(NullLogger.Instance, s_validExtensions);
- Assert.NotNull(loader);
+ var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { AllowedResourceExtensions = s_validExtensions });
+ Assert.NotNull(source);
}
[Fact]
public void Constructor_MixOfValidAndInvalidExtensions_ThrowsArgumentException()
{
// Arrange & Act & Assert — one bad extension in the list should cause failure
- Assert.Throws(() => new FileAgentSkillLoader(NullLogger.Instance, s_mixedValidInvalidExtensions));
+ Assert.Throws(() => new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { AllowedResourceExtensions = s_mixedValidInvalidExtensions }));
}
[Fact]
- public void DiscoverAndLoadSkills_ResourceInSkillRoot_Discovered()
+ public async Task GetSkillsAsync_ResourceInSkillRoot_DiscoveredAsync()
{
// Arrange — resource file directly in the skill directory (not in a subdirectory)
string skillDir = Path.Combine(this._testRoot, "root-resource-skill");
@@ -353,54 +361,62 @@ public void DiscoverAndLoadSkills_ResourceInSkillRoot_Discovered()
File.WriteAllText(
Path.Combine(skillDir, "SKILL.md"),
"---\nname: root-resource-skill\ndescription: Root resources\n---\nBody.");
+ var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
// Act
- var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
+ var skills = await source.GetSkillsAsync();
// Assert — both root-level resource files should be discovered
Assert.Single(skills);
- var skill = skills["root-resource-skill"];
- Assert.Equal(2, skill.ResourceNames.Count);
- Assert.Contains(skill.ResourceNames, r => r.Equals("guide.md", StringComparison.OrdinalIgnoreCase));
- Assert.Contains(skill.ResourceNames, r => r.Equals("config.json", StringComparison.OrdinalIgnoreCase));
+ var skill = skills[0];
+ Assert.Equal(2, skill.Resources!.Count);
+ Assert.Contains(skill.Resources!, r => r.Name.Equals("guide.md", StringComparison.OrdinalIgnoreCase));
+ Assert.Contains(skill.Resources!, r => r.Name.Equals("config.json", StringComparison.OrdinalIgnoreCase));
}
[Fact]
- public void DiscoverAndLoadSkills_NoResourceFiles_ReturnsEmptyResourceNames()
+ public async Task GetSkillsAsync_NoResourceFiles_ReturnsEmptyResourcesAsync()
{
// Arrange — skill with no resource files
_ = this.CreateSkillDirectory("no-resources", "A skill", "No resources here.");
+ var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
// Act
- var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
+ var skills = await source.GetSkillsAsync();
// Assert
Assert.Single(skills);
- Assert.Empty(skills["no-resources"].ResourceNames);
+ Assert.Empty(skills[0].Resources!);
}
[Fact]
- public void DiscoverAndLoadSkills_EmptyPaths_ReturnsEmptyDictionary()
+ public async Task GetSkillsAsync_EmptyPaths_ReturnsEmptyListAsync()
{
+ // Arrange
+ var source = new AgentFileSkillsSource(Enumerable.Empty(), s_noOpExecutor);
+
// Act
- var skills = this._loader.DiscoverAndLoadSkills(Enumerable.Empty());
+ var skills = await source.GetSkillsAsync();
// Assert
Assert.Empty(skills);
}
[Fact]
- public void DiscoverAndLoadSkills_NonExistentPath_ReturnsEmptyDictionary()
+ public async Task GetSkillsAsync_NonExistentPath_ReturnsEmptyListAsync()
{
+ // Arrange
+ var source = new AgentFileSkillsSource(Path.Combine(this._testRoot, "does-not-exist"), s_noOpExecutor);
+
// Act
- var skills = this._loader.DiscoverAndLoadSkills(new[] { Path.Combine(this._testRoot, "does-not-exist") });
+ var skills = await source.GetSkillsAsync();
// Assert
Assert.Empty(skills);
}
[Fact]
- public void DiscoverAndLoadSkills_NestedSkillDirectory_DiscoveredWithinDepthLimit()
+ public async Task GetSkillsAsync_NestedSkillDirectory_DiscoveredWithinDepthLimitAsync()
{
// Arrange — nested 1 level deep (MaxSearchDepth = 2, so depth 0 = testRoot, depth 1 = level1)
string nestedDir = Path.Combine(this._testRoot, "level1", "nested-skill");
@@ -408,13 +424,14 @@ public void DiscoverAndLoadSkills_NestedSkillDirectory_DiscoveredWithinDepthLimi
File.WriteAllText(
Path.Combine(nestedDir, "SKILL.md"),
"---\nname: nested-skill\ndescription: Nested\n---\nNested body.");
+ var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
// Act
- var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
+ var skills = await source.GetSkillsAsync();
// Assert
Assert.Single(skills);
- Assert.True(skills.ContainsKey("nested-skill"));
+ Assert.Equal("nested-skill", skills[0].Frontmatter.Name);
}
[Fact]
@@ -425,54 +442,19 @@ public async Task ReadSkillResourceAsync_ValidResource_ReturnsContentAsync()
string refsDir = Path.Combine(skillDir, "refs");
Directory.CreateDirectory(refsDir);
File.WriteAllText(Path.Combine(refsDir, "doc.md"), "Document content here.");
- var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
- var skill = skills["read-skill"];
+ var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
+ var skills = await source.GetSkillsAsync();
+ var resource = skills[0].Resources!.First(r => r.Name == "refs/doc.md");
// Act
- string content = await this._loader.ReadSkillResourceAsync(skill, "refs/doc.md");
+ var content = await resource.ReadAsync();
// 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
- string skillDir = this.CreateSkillDirectory("traverse-read", "A skill", "See docs.");
- string refsDir = Path.Combine(skillDir, "refs");
- Directory.CreateDirectory(refsDir);
- File.WriteAllText(Path.Combine(refsDir, "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()
+ public async Task GetSkillsAsync_NameExceedsMaxLength_ExcludesSkillAsync()
{
// Arrange — name longer than 64 characters
string longName = new('a', 65);
@@ -481,16 +463,17 @@ public void DiscoverAndLoadSkills_NameExceedsMaxLength_ExcludesSkill()
File.WriteAllText(
Path.Combine(skillDir, "SKILL.md"),
$"---\nname: {longName}\ndescription: A skill\n---\nBody.");
+ var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
// Act
- var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
+ var skills = await source.GetSkillsAsync();
// Assert
Assert.Empty(skills);
}
[Fact]
- public void DiscoverAndLoadSkills_DescriptionExceedsMaxLength_ExcludesSkill()
+ public async Task GetSkillsAsync_DescriptionExceedsMaxLength_ExcludesSkillAsync()
{
// Arrange — description longer than 1024 characters
string longDesc = new('x', 1025);
@@ -499,71 +482,18 @@ public void DiscoverAndLoadSkills_DescriptionExceedsMaxLength_ExcludesSkill()
File.WriteAllText(
Path.Combine(skillDir, "SKILL.md"),
$"---\nname: long-desc\ndescription: {longDesc}\n---\nBody.");
+ var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
// Act
- var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
+ var skills = await source.GetSkillsAsync();
// Assert
Assert.Empty(skills);
}
- [Fact]
- public async Task ReadSkillResourceAsync_DotSlashPrefix_MatchesNormalizedResourceAsync()
- {
- // Arrange — skill loaded with bare path, caller uses ./ prefix
- string skillDir = this.CreateSkillDirectory("dotslash-read", "A skill", "See docs.");
- string refsDir = Path.Combine(skillDir, "refs");
- Directory.CreateDirectory(refsDir);
- File.WriteAllText(Path.Combine(refsDir, "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
- string skillDir = this.CreateSkillDirectory("backslash-read", "A skill", "See docs.");
- string refsDir = Path.Combine(skillDir, "refs");
- Directory.CreateDirectory(refsDir);
- File.WriteAllText(Path.Combine(refsDir, "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
- string skillDir = this.CreateSkillDirectory("mixed-sep-read", "A skill", "See docs.");
- string refsDir = Path.Combine(skillDir, "refs");
- Directory.CreateDirectory(refsDir);
- File.WriteAllText(Path.Combine(refsDir, "doc.md"), "Mixed separator content.");
- var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
- var skill = skills["mixed-sep-read"];
-
- // Act — caller passes .\refs\doc.md which should match refs/doc.md
- string content = await this._loader.ReadSkillResourceAsync(skill, ".\\refs\\doc.md");
-
- // Assert
- Assert.Equal("Mixed separator content.", content);
- }
-
#if NET
[Fact]
- public void DiscoverAndLoadSkills_SymlinkInPath_SkipsSymlinkedResources()
+ public async Task GetSkillsAsync_SymlinkInPath_SkipsSymlinkedResourcesAsync()
{
// Arrange — a "refs" subdirectory is a symlink pointing outside the skill directory
string skillDir = Path.Combine(this._testRoot, "symlink-escape-skill");
@@ -588,71 +518,179 @@ public void DiscoverAndLoadSkills_SymlinkInPath_SkipsSymlinkedResources()
File.WriteAllText(
Path.Combine(skillDir, "SKILL.md"),
"---\nname: symlink-escape-skill\ndescription: Symlinked directory escape\n---\nBody.");
+ var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
// Act
- var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
+ var skills = await source.GetSkillsAsync();
// Assert — skill should still load, but symlinked resources should be excluded
- Assert.True(skills.ContainsKey("symlink-escape-skill"));
- var skill = skills["symlink-escape-skill"];
- Assert.Single(skill.ResourceNames);
- Assert.Equal("legit.md", skill.ResourceNames[0]);
+ var skill = skills.FirstOrDefault(s => s.Frontmatter.Name == "symlink-escape-skill");
+ Assert.NotNull(skill);
+ Assert.Single(skill.Resources!);
+ Assert.Equal("legit.md", skill.Resources![0].Name);
}
+#endif
- private static readonly string[] s_symlinkResource = ["refs/data.md"];
+ [Fact]
+ public async Task GetSkillsAsync_FileWithUtf8Bom_ParsesSuccessfullyAsync()
+ {
+ // 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.");
+ var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
+
+ // Act
+ var skills = await source.GetSkillsAsync();
+
+ // Assert
+ Assert.Single(skills);
+ Assert.Equal("bom-skill", skills[0].Frontmatter.Name);
+ Assert.Equal("Skill with BOM", skills[0].Frontmatter.Description);
+ }
[Fact]
- public async Task ReadSkillResourceAsync_SymlinkInPath_ThrowsInvalidOperationExceptionAsync()
+ public async Task GetSkillsAsync_LicenseField_ParsedCorrectlyAsync()
{
- // 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);
+ // Arrange
+ _ = this.CreateSkillDirectoryWithRawContent(
+ "licensed-skill",
+ "---\nname: licensed-skill\ndescription: A skill with license\nlicense: MIT\n---\nBody.");
+ var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
- string outsideDir = Path.Combine(this._testRoot, "outside-read");
- Directory.CreateDirectory(outsideDir);
- File.WriteAllText(Path.Combine(outsideDir, "data.md"), "external data");
+ // Act
+ var skills = await source.GetSkillsAsync();
- try
- {
- Directory.CreateSymbolicLink(refsDir, outsideDir);
- }
- catch (IOException)
- {
- // Symlink creation requires elevation on some platforms; skip gracefully.
- return;
- }
+ // Assert
+ Assert.Single(skills);
+ Assert.Equal("MIT", skills[0].Frontmatter.License);
+ }
+
+ [Fact]
+ public async Task GetSkillsAsync_CompatibilityField_ParsedCorrectlyAsync()
+ {
+ // Arrange
+ _ = this.CreateSkillDirectoryWithRawContent(
+ "compat-skill",
+ "---\nname: compat-skill\ndescription: A skill with compatibility\ncompatibility: Requires Node.js 18+\n---\nBody.");
+ var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
- // 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
+ var skills = await source.GetSkillsAsync();
- // Act & Assert
- await Assert.ThrowsAsync(
- () => this._loader.ReadSkillResourceAsync(skill, "refs/data.md"));
+ // Assert
+ Assert.Single(skills);
+ Assert.Equal("Requires Node.js 18+", skills[0].Frontmatter.Compatibility);
}
-#endif
[Fact]
- public void DiscoverAndLoadSkills_FileWithUtf8Bom_ParsesSuccessfully()
+ public async Task GetSkillsAsync_AllowedToolsField_ParsedCorrectlyAsync()
{
- // Arrange — prepend a UTF-8 BOM (\uFEFF) before the frontmatter
+ // Arrange
_ = this.CreateSkillDirectoryWithRawContent(
- "bom-skill",
- "\uFEFF---\nname: bom-skill\ndescription: Skill with BOM\n---\nBody content.");
+ "tools-skill",
+ "---\nname: tools-skill\ndescription: A skill with tools\nallowed-tools: grep glob bash\n---\nBody.");
+ var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
+
+ // Act
+ var skills = await source.GetSkillsAsync();
+
+ // Assert
+ Assert.Single(skills);
+ Assert.Equal("grep glob bash", skills[0].Frontmatter.AllowedTools);
+ }
+
+ [Fact]
+ public async Task GetSkillsAsync_MetadataField_ParsedCorrectlyAsync()
+ {
+ // Arrange
+ _ = this.CreateSkillDirectoryWithRawContent(
+ "meta-skill",
+ "---\nname: meta-skill\ndescription: A skill with metadata\nmetadata:\n author: test-user\n version: 1.0\n---\nBody.");
+ var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
+
+ // Act
+ var skills = await source.GetSkillsAsync();
+
+ // Assert
+ Assert.Single(skills);
+ Assert.NotNull(skills[0].Frontmatter.Metadata);
+ Assert.Equal("test-user", skills[0].Frontmatter.Metadata!["author"]?.ToString());
+ Assert.Equal("1.0", skills[0].Frontmatter.Metadata!["version"]?.ToString());
+ }
+
+ [Fact]
+ public async Task GetSkillsAsync_MetadataWithQuotedValues_ParsedCorrectlyAsync()
+ {
+ // Arrange
+ _ = this.CreateSkillDirectoryWithRawContent(
+ "quoted-meta",
+ "---\nname: quoted-meta\ndescription: Metadata with quotes\nmetadata:\n key1: 'single quoted'\n key2: \"double quoted\"\n---\nBody.");
+ var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
+
+ // Act
+ var skills = await source.GetSkillsAsync();
+
+ // Assert
+ Assert.Single(skills);
+ Assert.NotNull(skills[0].Frontmatter.Metadata);
+ Assert.Equal("single quoted", skills[0].Frontmatter.Metadata!["key1"]?.ToString());
+ Assert.Equal("double quoted", skills[0].Frontmatter.Metadata!["key2"]?.ToString());
+ }
+
+ [Fact]
+ public async Task GetSkillsAsync_AllOptionalFields_ParsedCorrectlyAsync()
+ {
+ // Arrange
+ string content = string.Join(
+ "\n",
+ "---",
+ "name: full-skill",
+ "description: A skill with all fields",
+ "license: Apache-2.0",
+ "compatibility: Requires Python 3.10+",
+ "allowed-tools: grep glob view",
+ "metadata:",
+ " org: contoso",
+ " tier: premium",
+ "---",
+ "Full body content.");
+ _ = this.CreateSkillDirectoryWithRawContent("full-skill", content);
+ var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
+
+ // Act
+ var skills = await source.GetSkillsAsync();
+
+ // Assert
+ Assert.Single(skills);
+ var fm = skills[0].Frontmatter;
+ Assert.Equal("full-skill", fm.Name);
+ Assert.Equal("A skill with all fields", fm.Description);
+ Assert.Equal("Apache-2.0", fm.License);
+ Assert.Equal("Requires Python 3.10+", fm.Compatibility);
+ Assert.Equal("grep glob view", fm.AllowedTools);
+ Assert.NotNull(fm.Metadata);
+ Assert.Equal("contoso", fm.Metadata!["org"]?.ToString());
+ Assert.Equal("premium", fm.Metadata!["tier"]?.ToString());
+ }
+
+ [Fact]
+ public async Task GetSkillsAsync_NoOptionalFields_DefaultsToNullAsync()
+ {
+ // Arrange
+ _ = this.CreateSkillDirectory("basic-skill", "A basic skill", "Body.");
+ var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
// Act
- var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
+ var skills = await source.GetSkillsAsync();
// 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);
+ var fm = skills[0].Frontmatter;
+ Assert.Null(fm.License);
+ Assert.Null(fm.Compatibility);
+ Assert.Null(fm.AllowedTools);
+ Assert.Null(fm.Metadata);
}
private string CreateSkillDirectory(string name, string description, string body)
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillsProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillsProviderTests.cs
deleted file mode 100644
index 5da49525d4..0000000000
--- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillsProviderTests.cs
+++ /dev/null
@@ -1,266 +0,0 @@
-// 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);
- Assert.Contains("custom-prompt-skill", result.Instructions);
- Assert.Contains("Custom prompt", 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 void Constructor_PromptWithoutPlaceholder_ThrowsArgumentException()
- {
- // Arrange -- valid format string but missing the required placeholder
- var options = new FileAgentSkillsProviderOptions
- {
- SkillsInstructionPrompt = "No placeholder here"
- };
-
- var ex = Assert.Throws(() => new FileAgentSkillsProvider(this._testRoot, options));
- Assert.Contains("{0}", ex.Message);
- Assert.Equal("options", ex.ParamName);
- }
-
- [Fact]
- public async Task Constructor_PromptWithPlaceholder_AppliesCustomTemplateAsync()
- {
- // Arrange — valid custom template with {0} placeholder
- this.CreateSkill("custom-tpl-skill", "Custom template skill", "Body.");
- var options = new FileAgentSkillsProviderOptions
- {
- SkillsInstructionPrompt = "== Skills ==\n{0}\n== End =="
- };
- var provider = new FileAgentSkillsProvider(this._testRoot, options);
- var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
-
- // Act
- var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
-
- // Assert — the custom template wraps the skill list
- Assert.NotNull(result.Instructions);
- Assert.StartsWith("== Skills ==", result.Instructions);
- Assert.Contains("custom-tpl-skill", result.Instructions);
- Assert.Contains("== End ==", 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);
- }
-
- [Fact]
- public async Task InvokingCoreAsync_SkillsListIsSortedByNameAsync()
- {
- // Arrange — create skills in reverse alphabetical order
- this.CreateSkill("zulu-skill", "Zulu skill", "Body Z.");
- this.CreateSkill("alpha-skill", "Alpha skill", "Body A.");
- this.CreateSkill("mike-skill", "Mike skill", "Body M.");
- var provider = new FileAgentSkillsProvider(this._testRoot);
- var inputContext = new AIContext();
- var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext);
-
- // Act
- var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
-
- // Assert — skills should appear in alphabetical order in the prompt
- Assert.NotNull(result.Instructions);
- int alphaIndex = result.Instructions!.IndexOf("alpha-skill", StringComparison.Ordinal);
- int mikeIndex = result.Instructions.IndexOf("mike-skill", StringComparison.Ordinal);
- int zuluIndex = result.Instructions.IndexOf("zulu-skill", StringComparison.Ordinal);
- Assert.True(alphaIndex < mikeIndex, "alpha-skill should appear before mike-skill");
- Assert.True(mikeIndex < zuluIndex, "mike-skill should appear before zulu-skill");
- }
-
- private void CreateSkill(string name, string description, string body)
- {
- CreateSkillIn(this._testRoot, name, description, body);
- }
-
- private static void CreateSkillIn(string root, string name, string description, string body)
- {
- string skillDir = Path.Combine(root, name);
- Directory.CreateDirectory(skillDir);
- File.WriteAllText(
- Path.Combine(skillDir, "SKILL.md"),
- $"---\nname: {name}\ndescription: {description}\n---\n{body}");
- }
-}
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FilteringAgentSkillsSourceTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FilteringAgentSkillsSourceTests.cs
new file mode 100644
index 0000000000..de145004e0
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FilteringAgentSkillsSourceTests.cs
@@ -0,0 +1,120 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
+
+///
+/// Unit tests for .
+///
+public sealed class FilteringAgentSkillsSourceTests
+{
+ [Fact]
+ public async Task GetSkillsAsync_PredicateIncludesAll_ReturnsAllSkillsAsync()
+ {
+ // Arrange
+ var inner = new TestAgentSkillsSource(
+ new TestAgentSkill("skill-a", "A", "Instructions A."),
+ new TestAgentSkill("skill-b", "B", "Instructions B."));
+ var source = new FilteringAgentSkillsSource(inner, _ => true);
+
+ // Act
+ var result = await source.GetSkillsAsync(CancellationToken.None);
+
+ // Assert
+ Assert.Equal(2, result.Count);
+ }
+
+ [Fact]
+ public async Task GetSkillsAsync_PredicateExcludesAll_ReturnsEmptyAsync()
+ {
+ // Arrange
+ var inner = new TestAgentSkillsSource(
+ new TestAgentSkill("skill-a", "A", "Instructions A."),
+ new TestAgentSkill("skill-b", "B", "Instructions B."));
+ var source = new FilteringAgentSkillsSource(inner, _ => false);
+
+ // Act
+ var result = await source.GetSkillsAsync(CancellationToken.None);
+
+ // Assert
+ Assert.Empty(result);
+ }
+
+ [Fact]
+ public async Task GetSkillsAsync_PartialFilter_ReturnsMatchingSkillsOnlyAsync()
+ {
+ // Arrange
+ var inner = new TestAgentSkillsSource(
+ new TestAgentSkill("keep-me", "Keep", "Instructions."),
+ new TestAgentSkill("drop-me", "Drop", "Instructions."),
+ new TestAgentSkill("keep-also", "KeepAlso", "Instructions."));
+ var source = new FilteringAgentSkillsSource(
+ inner,
+ skill => skill.Frontmatter.Name.StartsWith("keep", StringComparison.OrdinalIgnoreCase));
+
+ // Act
+ var result = await source.GetSkillsAsync(CancellationToken.None);
+
+ // Assert
+ Assert.Equal(2, result.Count);
+ Assert.All(result, s => Assert.StartsWith("keep", s.Frontmatter.Name));
+ }
+
+ [Fact]
+ public async Task GetSkillsAsync_EmptySource_ReturnsEmptyAsync()
+ {
+ // Arrange
+ var inner = new TestAgentSkillsSource(Array.Empty());
+ var source = new FilteringAgentSkillsSource(inner, _ => true);
+
+ // Act
+ var result = await source.GetSkillsAsync(CancellationToken.None);
+
+ // Assert
+ Assert.Empty(result);
+ }
+
+ [Fact]
+ public void Constructor_NullPredicate_Throws()
+ {
+ // Arrange
+ var inner = new TestAgentSkillsSource(Array.Empty());
+
+ // Act & Assert
+ Assert.Throws(() => new FilteringAgentSkillsSource(inner, null!));
+ }
+
+ [Fact]
+ public void Constructor_NullInnerSource_Throws()
+ {
+ // Act & Assert
+ Assert.Throws(() => new FilteringAgentSkillsSource(null!, _ => true));
+ }
+
+ [Fact]
+ public async Task GetSkillsAsync_PreservesOrderAsync()
+ {
+ // Arrange
+ var inner = new TestAgentSkillsSource(
+ new TestAgentSkill("alpha", "Alpha", "Instructions."),
+ new TestAgentSkill("beta", "Beta", "Instructions."),
+ new TestAgentSkill("gamma", "Gamma", "Instructions."),
+ new TestAgentSkill("delta", "Delta", "Instructions."));
+
+ // Keep only alpha and gamma
+ var source = new FilteringAgentSkillsSource(
+ inner,
+ skill => skill.Frontmatter.Name is "alpha" or "gamma");
+
+ // Act
+ var result = await source.GetSkillsAsync(CancellationToken.None);
+
+ // Assert
+ Assert.Equal(2, result.Count);
+ Assert.Equal("alpha", result[0].Frontmatter.Name);
+ Assert.Equal("gamma", result[1].Frontmatter.Name);
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/TestSkillTypes.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/TestSkillTypes.cs
new file mode 100644
index 0000000000..8c97a31ae4
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/TestSkillTypes.cs
@@ -0,0 +1,72 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
+
+///
+/// A simple in-memory implementation for unit tests.
+///
+internal sealed class TestAgentSkill : AgentSkill
+{
+ private readonly AgentSkillFrontmatter _frontmatter;
+ private readonly string _content;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Kebab-case skill name.
+ /// Skill description.
+ /// Full skill content (body text).
+ public TestAgentSkill(string name, string description, string content)
+ {
+ this._frontmatter = new AgentSkillFrontmatter(name, description);
+ this._content = content;
+ }
+
+ ///
+ public override AgentSkillFrontmatter Frontmatter => this._frontmatter;
+
+ ///
+ public override string Content => this._content;
+
+ ///
+ public override IReadOnlyList? Resources => null;
+
+ ///
+ public override IReadOnlyList? Scripts => null;
+}
+
+///
+/// A simple in-memory implementation for unit tests.
+///
+internal sealed class TestAgentSkillsSource : AgentSkillsSource
+{
+ private readonly IList _skills;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The skills to return.
+ public TestAgentSkillsSource(IList skills)
+ {
+ this._skills = skills;
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The skills to return.
+ public TestAgentSkillsSource(params AgentSkill[] skills)
+ {
+ this._skills = skills;
+ }
+
+ ///
+ public override Task> GetSkillsAsync(CancellationToken cancellationToken = default)
+ {
+ return Task.FromResult(this._skills);
+ }
+}