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