From 0e78d14e43e3b1d8061a1e9e09ea91edfb3a4db2 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Date: Mon, 23 Mar 2026 22:08:15 +0000 Subject: [PATCH 01/14] initial commit --- dotnet/agent-framework-dotnet.slnx | 2 +- .../Agent_Step01_BasicSkills/Program.cs | 50 -- .../Agent_Step01_BasicSkills/README.md | 63 -- .../skills/expense-report/SKILL.md | 40 - .../assets/expense-report-template.md | 5 - .../expense-report/references/POLICY_FAQ.md | 55 -- .../Agent_Step01_FileBasedSkills.csproj} | 4 + .../Agent_Step01_FileBasedSkills/Program.cs | 49 ++ .../Agent_Step01_FileBasedSkills/README.md | 51 ++ .../skills/unit-converter/SKILL.md | 11 + .../references/conversion-table.md | 10 + .../skills/unit-converter/scripts/convert.py | 29 + .../samples/02-agents/AgentSkills/README.md | 2 +- .../AgentSkills/SubprocessScriptExecutor.cs | 119 +++ .../Microsoft.Agents.AI/Skills/AgentSkill.cs | 50 ++ .../Skills/AgentSkillFrontmatter.cs | 200 +++++ .../Skills/AgentSkillResource.cs | 46 ++ .../Skills/AgentSkillScript.cs | 47 ++ .../Skills/AgentSkillsProvider.cs | 277 +++++++ .../Skills/AgentSkillsProviderBuilder.cs | 235 ++++++ .../Skills/AgentSkillsProviderOptions.cs | 28 + .../Skills/AgentSkillsSource.cs | 24 + .../Skills/AggregateAgentSkillsSource.cs | 46 ++ .../Decorators/CachingAgentSkillsSource.cs | 37 + .../DeduplicatingAgentSkillsSource.cs | 58 ++ .../Decorators/DelegatingAgentSkillsSource.cs | 41 + .../Decorators/FilteringAgentSkillsSource.cs | 71 ++ .../Skills/File/AgentFileSkill.cs | 61 ++ .../Skills/File/AgentFileSkillResource.cs | 47 ++ .../Skills/File/AgentFileSkillScript.cs | 58 ++ .../File/AgentFileSkillScriptExecutor.cs | 27 + .../AgentFileSkillsSource.cs} | 371 +++++---- .../File/AgentFileSkillsSourceOptions.cs | 33 + .../Skills/FileAgentSkill.cs | 56 -- .../Skills/FileAgentSkillsProvider.cs | 222 ------ .../Skills/FileAgentSkillsProviderOptions.cs | 32 - .../Skills/SkillFrontmatter.cs | 32 - .../AgentSkills/AgentFileSkillScriptTests.cs | 103 +++ .../AgentFileSkillsSourceScriptTests.cs | 237 ++++++ .../AgentSkillFrontmatterValidatorTests.cs | 260 +++++++ .../AgentSkillsProviderBuilderTests.cs | 228 ++++++ .../AgentSkills/AgentSkillsProviderTests.cs | 701 ++++++++++++++++++ .../AggregateAgentSkillsSourceTests.cs | 134 ++++ .../CachingAgentSkillsSourceTests.cs | 153 ++++ .../DeduplicatingAgentSkillsSourceTests.cs | 105 +++ .../AgentSkills/FileAgentSkillLoaderTests.cs | 484 ++++++------ .../FileAgentSkillsProviderTests.cs | 266 ------- .../FilteringAgentSkillsSourceTests.cs | 120 +++ .../AgentSkills/TestSkillTypes.cs | 72 ++ 49 files changed, 4246 insertions(+), 1206 deletions(-) delete mode 100644 dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/Program.cs delete mode 100644 dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/README.md delete mode 100644 dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/SKILL.md delete mode 100644 dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/assets/expense-report-template.md delete mode 100644 dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/references/POLICY_FAQ.md rename dotnet/samples/02-agents/AgentSkills/{Agent_Step01_BasicSkills/Agent_Step01_BasicSkills.csproj => Agent_Step01_FileBasedSkills/Agent_Step01_FileBasedSkills.csproj} (85%) create mode 100644 dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/Program.cs create mode 100644 dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/README.md create mode 100644 dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/skills/unit-converter/SKILL.md create mode 100644 dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/skills/unit-converter/references/conversion-table.md create mode 100644 dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/skills/unit-converter/scripts/convert.py create mode 100644 dotnet/samples/02-agents/AgentSkills/SubprocessScriptExecutor.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/AgentSkill.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillFrontmatter.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillResource.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillScript.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProvider.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProviderBuilder.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProviderOptions.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsSource.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/AggregateAgentSkillsSource.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/Decorators/CachingAgentSkillsSource.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/Decorators/DeduplicatingAgentSkillsSource.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/Decorators/DelegatingAgentSkillsSource.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/Decorators/FilteringAgentSkillsSource.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkill.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillResource.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillScript.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillScriptExecutor.cs rename dotnet/src/Microsoft.Agents.AI/Skills/{FileAgentSkillLoader.cs => File/AgentFileSkillsSource.cs} (51%) create mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSourceOptions.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkill.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProvider.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProviderOptions.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/SkillFrontmatter.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillScriptTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillsSourceScriptTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillFrontmatterValidatorTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderBuilderTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AggregateAgentSkillsSourceTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/CachingAgentSkillsSourceTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/DeduplicatingAgentSkillsSourceTests.cs delete mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillsProviderTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FilteringAgentSkillsSourceTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/TestSkillTypes.cs diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index af7ca9f0be..681fecceec 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -101,7 +101,7 @@ - + diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/Program.cs b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/Program.cs deleted file mode 100644 index 9b0a4b4f99..0000000000 --- a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/Program.cs +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -// This sample demonstrates how to use Agent Skills with a ChatClientAgent. -// Agent Skills are modular packages of instructions and resources that extend an agent's capabilities. -// Skills follow the progressive disclosure pattern: advertise -> load -> read resources. -// -// This sample includes the expense-report skill: -// - Policy-based expense filing with references and assets - -using Azure.AI.OpenAI; -using Azure.Identity; -using Microsoft.Agents.AI; -using OpenAI.Responses; - -// --- Configuration --- -string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") - ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; - -// --- Skills Provider --- -// Discovers skills from the 'skills' directory and makes them available to the agent -var skillsProvider = new FileAgentSkillsProvider(skillPath: Path.Combine(AppContext.BaseDirectory, "skills")); - -// --- Agent Setup --- -AIAgent agent = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()) - .GetResponsesClient() - .AsAIAgent(new ChatClientAgentOptions - { - Name = "SkillsAgent", - ChatOptions = new() - { - Instructions = "You are a helpful assistant.", - }, - AIContextProviders = [skillsProvider], - }, - model: deploymentName); - -// --- Example 1: Expense policy question (loads FAQ resource) --- -Console.WriteLine("Example 1: Checking expense policy FAQ"); -Console.WriteLine("---------------------------------------"); -AgentResponse response1 = await agent.RunAsync("Are tips reimbursable? I left a 25% tip on a taxi ride and want to know if that's covered."); -Console.WriteLine($"Agent: {response1.Text}\n"); - -// --- Example 2: Filing an expense report (multi-turn with template asset) --- -Console.WriteLine("Example 2: Filing an expense report"); -Console.WriteLine("---------------------------------------"); -AgentSession session = await agent.CreateSessionAsync(); -AgentResponse response2 = await agent.RunAsync("I had 3 client dinners and a $1,200 flight last week. Return a draft expense report and ask about any missing details.", - session); -Console.WriteLine($"Agent: {response2.Text}\n"); diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/README.md b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/README.md deleted file mode 100644 index 78099fa8a5..0000000000 --- a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/README.md +++ /dev/null @@ -1,63 +0,0 @@ -# Agent Skills Sample - -This sample demonstrates how to use **Agent Skills** with a `ChatClientAgent` in the Microsoft Agent Framework. - -## What are Agent Skills? - -Agent Skills are modular packages of instructions and resources that enable AI agents to perform specialized tasks. They follow the [Agent Skills specification](https://agentskills.io/) and implement the progressive disclosure pattern: - -1. **Advertise**: Skills are advertised with name + description (~100 tokens per skill) -2. **Load**: Full instructions are loaded on-demand via `load_skill` tool -3. **Resources**: References and other files loaded via `read_skill_resource` tool - -## Skills Included - -### expense-report -Policy-based expense filing with spending limits, receipt requirements, and approval workflows. -- `references/POLICY_FAQ.md` — Detailed expense policy Q&A -- `assets/expense-report-template.md` — Submission template - -## Project Structure - -``` -Agent_Step01_BasicSkills/ -├── Program.cs -├── Agent_Step01_BasicSkills.csproj -└── skills/ - └── expense-report/ - ├── SKILL.md - ├── references/ - │ └── POLICY_FAQ.md - └── assets/ - └── expense-report-template.md -``` - -## Running the Sample - -### Prerequisites -- .NET 10.0 SDK -- Azure OpenAI endpoint with a deployed model - -### Setup -1. Set environment variables: - ```bash - export AZURE_OPENAI_ENDPOINT="https://your-endpoint.openai.azure.com/" - export AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini" - ``` - -2. Run the sample: - ```bash - dotnet run - ``` - -### Examples - -The sample runs two examples: - -1. **Expense policy FAQ** — Asks about tip reimbursement; the agent loads the expense-report skill and reads the FAQ resource -2. **Filing an expense report** — Multi-turn conversation to draft an expense report using the template asset - -## Learn More - -- [Agent Skills Specification](https://agentskills.io/) -- [Microsoft Agent Framework Documentation](../../../../../docs/) diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/SKILL.md b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/SKILL.md deleted file mode 100644 index fc6c83cf30..0000000000 --- a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/SKILL.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -name: expense-report -description: File and validate employee expense reports according to Contoso company policy. Use when asked about expense submissions, reimbursement rules, receipt requirements, spending limits, or expense categories. -metadata: - author: contoso-finance - version: "2.1" ---- - -# Expense Report - -## Categories and Limits - -| Category | Limit | Receipt | Approval | -|---|---|---|---| -| Meals — solo | $50/day | >$25 | No | -| Meals — team/client | $75/person | Always | Manager if >$200 total | -| Lodging | $250/night | Always | Manager if >3 nights | -| Ground transport | $100/day | >$15 | No | -| Airfare | Economy | Always | Manager; VP if >$1,500 | -| Conference/training | $2,000/event | Always | Manager + L&D | -| Office supplies | $100 | Yes | No | -| Software/subscriptions | $50/month | Yes | Manager if >$200/year | - -## Filing Process - -1. Collect receipts — must show vendor, date, amount, payment method. -2. Categorize per table above. -3. Use template: [assets/expense-report-template.md](assets/expense-report-template.md). -4. For client/team meals: list attendee names and business purpose. -5. Submit — auto-approved if <$500; manager if $500–$2,000; VP if >$2,000. -6. Reimbursement: 10 business days via direct deposit. - -## Policy Rules - -- Submit within 30 days of transaction. -- Alcohol is never reimbursable. -- Foreign currency: convert to USD at transaction-date rate; note original currency and amount. -- Mixed personal/business travel: only business portion reimbursable; provide comparison quotes. -- Lost receipts (>$25): file Lost Receipt Affidavit from Finance. Max 2 per quarter. -- For policy questions not covered above, consult the FAQ: [references/POLICY_FAQ.md](references/POLICY_FAQ.md). Answers should be based on what this document and the FAQ state. diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/assets/expense-report-template.md b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/assets/expense-report-template.md deleted file mode 100644 index 3f7c7dc36c..0000000000 --- a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/assets/expense-report-template.md +++ /dev/null @@ -1,5 +0,0 @@ -# Expense Report Template - -| Date | Category | Vendor | Description | Amount (USD) | Original Currency | Original Amount | Attendees | Business Purpose | Receipt Attached | -|------|----------|--------|-------------|--------------|-------------------|-----------------|-----------|------------------|------------------| -| | | | | | | | | | Yes or No | diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/references/POLICY_FAQ.md b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/references/POLICY_FAQ.md deleted file mode 100644 index 8e971192f8..0000000000 --- a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/references/POLICY_FAQ.md +++ /dev/null @@ -1,55 +0,0 @@ -# Expense Policy — Frequently Asked Questions - -## Meals - -**Q: Can I expense coffee or snacks during the workday?** -A: Daily coffee/snacks under $10 are not reimbursable (considered personal). Coffee purchased during a client meeting or team working session is reimbursable as a team meal. - -**Q: What if a team dinner exceeds the per-person limit?** -A: The $75/person limit applies as a guideline. Overages up to 20% are accepted with a written justification (e.g., "client dinner at venue chosen by client"). Overages beyond 20% require pre-approval from your VP. - -**Q: Do I need to list every attendee?** -A: Yes. For client meals, list the client's name and company. For team meals, list all employee names. For groups over 10, you may attach a separate attendee list. - -## Travel - -**Q: Can I book a premium economy or business class flight?** -A: Economy class is the standard. Premium economy is allowed for flights over 6 hours. Business class requires VP pre-approval and is generally reserved for flights over 10 hours or medical accommodation. - -**Q: What about ride-sharing (Uber/Lyft) vs. rental cars?** -A: Use ride-sharing for trips under 30 miles round-trip. Rent a car for multi-day travel or when ride-sharing would exceed $100/day. Always choose the compact/standard category unless traveling with 3+ people. - -**Q: Are tips reimbursable?** -A: Tips up to 20% are reimbursable for meals, taxi/ride-share, and hotel housekeeping. Tips above 20% require justification. - -## Lodging - -**Q: What if the $250/night limit isn't enough for the city I'm visiting?** -A: For high-cost cities (New York, San Francisco, London, Tokyo, Sydney), the limit is automatically increased to $350/night. No additional approval is needed. For other locations where rates are unusually high (e.g., during a major conference), request a per-trip exception from your manager before booking. - -**Q: Can I stay with friends/family instead and get a per-diem?** -A: No. Contoso reimburses actual lodging costs only, not per-diems. - -## Subscriptions and Software - -**Q: Can I expense a personal productivity tool?** -A: Software must be directly related to your job function. Tools like IDE licenses, design software, or project management apps are reimbursable. General productivity apps (note-taking, personal calendar) are not, unless your manager confirms a business need in writing. - -**Q: What about annual subscriptions?** -A: Annual subscriptions over $200 require manager approval before purchase. Submit the approval email with your expense report. - -## Receipts and Documentation - -**Q: My receipt is faded/damaged. What do I do?** -A: Try to obtain a duplicate from the vendor. If not possible, submit a Lost Receipt Affidavit (available from the Finance SharePoint site). You're limited to 2 affidavits per quarter. - -**Q: Do I need a receipt for parking meters or tolls?** -A: For amounts under $15, no receipt is required — just note the date, location, and amount. For $15 and above, a receipt or bank/credit card statement excerpt is required. - -## Approval and Reimbursement - -**Q: My manager is on leave. Who approves my report?** -A: Expense reports can be approved by your skip-level manager or any manager designated as an alternate approver in the expense system. - -**Q: Can I submit expenses from a previous quarter?** -A: The standard 30-day window applies. Expenses older than 30 days require a written explanation and VP approval. Expenses older than 90 days are not reimbursable except in extraordinary circumstances (extended leave, medical emergency) with CFO approval. diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/Agent_Step01_BasicSkills.csproj b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/Agent_Step01_FileBasedSkills.csproj similarity index 85% rename from dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/Agent_Step01_BasicSkills.csproj rename to dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/Agent_Step01_FileBasedSkills.csproj index 2a503bbfb2..4d89721fde 100644 --- a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/Agent_Step01_BasicSkills.csproj +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/Agent_Step01_FileBasedSkills.csproj @@ -14,6 +14,10 @@ + + + + diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/Program.cs b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/Program.cs new file mode 100644 index 0000000000..ebddb34a8a --- /dev/null +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/Program.cs @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample demonstrates how to use file-based Agent Skills with a ChatClientAgent. +// Skills are discovered from SKILL.md files on disk and follow the progressive disclosure pattern: +// 1. Advertise — skill names and descriptions in the system prompt +// 2. Load — full instructions loaded on demand via load_skill tool +// 3. Read resources — reference files read via read_skill_resource tool +// 4. Run scripts — scripts executed via run_skill_script tool with a subprocess executor +// +// This sample uses a unit-converter skill that converts between miles, kilometers, pounds, and kilograms. + +using Azure.AI.OpenAI; +using Azure.Identity; +using Microsoft.Agents.AI; +using OpenAI.Responses; + +// --- Configuration --- +string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; + +// --- Skills Provider --- +// Discovers skills from the 'skills' directory containing SKILL.md files. +// The script executor runs file-based scripts (e.g. Python) as local subprocesses. +var skillsProvider = new AgentSkillsProviderBuilder() + .UseFileSkill(Path.Combine(AppContext.BaseDirectory, "skills")) + .UseFileScriptExecutor(SubprocessScriptExecutor.ExecuteAsync) + .Build(); +// --- Agent Setup --- +AIAgent agent = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()) + .GetResponsesClient() + .AsAIAgent(new ChatClientAgentOptions + { + Name = "UnitConverterAgent", + ChatOptions = new() + { + Instructions = "You are a helpful assistant that can convert units.", + }, + AIContextProviders = [skillsProvider], + }, + model: deploymentName); + +// --- Example: Unit conversion --- +Console.WriteLine("Converting units with file-based skills"); +Console.WriteLine(new string('-', 60)); + +AgentResponse response = await agent.RunAsync( + "How many kilometers is a marathon (26.2 miles)? And how many pounds is 75 kilograms?"); + +Console.WriteLine($"Agent: {response.Text}"); diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/README.md b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/README.md new file mode 100644 index 0000000000..0f0dbb960a --- /dev/null +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/README.md @@ -0,0 +1,51 @@ +# File-Based Agent Skills Sample + +This sample demonstrates how to use **file-based Agent Skills** with a `ChatClientAgent`. + +## What it demonstrates + +- Discovering skills from `SKILL.md` files on disk via `AgentFileSkillsSource` +- The progressive disclosure pattern: advertise → load → read resources → run scripts +- Using the `AgentSkillsProviderBuilder` with `UseFileSkill` and `UseFileScriptExecutor` +- Running file-based scripts (Python) via a subprocess-based executor + +## Skills Included + +### unit-converter + +Converts between common units (miles↔km, pounds↔kg) using a multiplication factor. + +- `references/conversion-table.md` — Conversion factor table +- `scripts/convert.py` — Python script that performs the conversion + +## Running the Sample + +### Prerequisites + +- .NET 10.0 SDK +- Azure OpenAI endpoint with a deployed model +- Python 3 installed and available as `python3` on your PATH + +### Setup + +```bash +export AZURE_OPENAI_ENDPOINT="https://your-endpoint.openai.azure.com/" +export AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini" +``` + +### Run + +```bash +dotnet run +``` + +### Expected Output + +``` +Converting units with file-based skills +------------------------------------------------------------ +Agent: Here are your conversions: + +1. **26.2 miles → 42.16 km** (a marathon distance) +2. **75 kg → 165.35 lbs** +``` diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/skills/unit-converter/SKILL.md b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/skills/unit-converter/SKILL.md new file mode 100644 index 0000000000..6a8e692ff2 --- /dev/null +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/skills/unit-converter/SKILL.md @@ -0,0 +1,11 @@ +--- +name: unit-converter +description: Convert between common units using a multiplication factor. Use when asked to convert miles, kilometers, pounds, or kilograms. +--- + +## Usage + +When the user requests a unit conversion: +1. First, review `references/conversion-table.md` to find the correct factor +2. Run the `scripts/convert.py` script with `--value --factor ` (e.g. `--value 26.2 --factor 1.60934`) +3. Present the converted value clearly with both units diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/skills/unit-converter/references/conversion-table.md b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/skills/unit-converter/references/conversion-table.md new file mode 100644 index 0000000000..7a0160b854 --- /dev/null +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/skills/unit-converter/references/conversion-table.md @@ -0,0 +1,10 @@ +# Conversion Tables + +Formula: **result = value × factor** + +| From | To | Factor | +|-------------|-------------|----------| +| miles | kilometers | 1.60934 | +| kilometers | miles | 0.621371 | +| pounds | kilograms | 0.453592 | +| kilograms | pounds | 2.20462 | diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/skills/unit-converter/scripts/convert.py b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/skills/unit-converter/scripts/convert.py new file mode 100644 index 0000000000..228c8809ff --- /dev/null +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/skills/unit-converter/scripts/convert.py @@ -0,0 +1,29 @@ +# Unit conversion script +# Converts a value using a multiplication factor: result = value × factor +# +# Usage: +# python scripts/convert.py --value 26.2 --factor 1.60934 +# python scripts/convert.py --value 75 --factor 2.20462 + +import argparse +import json + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Convert a value using a multiplication factor.", + epilog="Examples:\n" + " python scripts/convert.py --value 26.2 --factor 1.60934\n" + " python scripts/convert.py --value 75 --factor 2.20462", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument("--value", type=float, required=True, help="The numeric value to convert.") + parser.add_argument("--factor", type=float, required=True, help="The conversion factor from the table.") + args = parser.parse_args() + + result = round(args.value * args.factor, 4) + print(json.dumps({"value": args.value, "factor": args.factor, "result": result})) + + +if __name__ == "__main__": + main() diff --git a/dotnet/samples/02-agents/AgentSkills/README.md b/dotnet/samples/02-agents/AgentSkills/README.md index 8488ec9eed..75b850f077 100644 --- a/dotnet/samples/02-agents/AgentSkills/README.md +++ b/dotnet/samples/02-agents/AgentSkills/README.md @@ -4,4 +4,4 @@ Samples demonstrating Agent Skills capabilities. | Sample | Description | |--------|-------------| -| [Agent_Step01_BasicSkills](Agent_Step01_BasicSkills/) | Using Agent Skills with a ChatClientAgent, including progressive disclosure and skill resources | +| [Agent_Step01_FileBasedSkills](Agent_Step01_FileBasedSkills/) | Define skills as `SKILL.md` files on disk with reference documents. Uses a unit-converter skill. | diff --git a/dotnet/samples/02-agents/AgentSkills/SubprocessScriptExecutor.cs b/dotnet/samples/02-agents/AgentSkills/SubprocessScriptExecutor.cs new file mode 100644 index 0000000000..9f7c58bf99 --- /dev/null +++ b/dotnet/samples/02-agents/AgentSkills/SubprocessScriptExecutor.cs @@ -0,0 +1,119 @@ +// Copyright (c) Microsoft. All rights reserved. + +// Sample subprocess-based skill script executor. +// Executes file-based skill scripts as local subprocesses. +// This is provided for demonstration purposes only. + +using System.Diagnostics; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +/// +/// Executes file-based skill scripts as local subprocesses. +/// +/// +/// This executor uses the script's absolute path, converts the arguments +/// to CLI flags, and returns captured output. It is intended for +/// demonstration purposes only. +/// +internal static class SubprocessScriptExecutor +{ + /// + /// Runs a skill script as a local subprocess. + /// + public static Task ExecuteAsync( + AgentSkill skill, + AgentFileSkillScript script, + AIFunctionArguments arguments, + CancellationToken cancellationToken) + { + if (!File.Exists(script.FullPath)) + { + return Task.FromResult($"Error: Script file not found: {script.FullPath}"); + } + + string extension = Path.GetExtension(script.FullPath); + string? interpreter = extension switch + { + ".py" => "python3", + ".js" => "node", + ".sh" => "bash", + ".ps1" => "pwsh", + _ => null, + }; + + var startInfo = new ProcessStartInfo + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + WorkingDirectory = Path.GetDirectoryName(script.FullPath) ?? ".", + }; + + if (interpreter is not null) + { + startInfo.FileName = interpreter; + startInfo.ArgumentList.Add(script.FullPath); + } + else + { + startInfo.FileName = script.FullPath; + } + + if (arguments is not null) + { + foreach (var (key, value) in arguments) + { + if (value is bool boolValue) + { + if (boolValue) + { + startInfo.ArgumentList.Add(NormalizeKey(key)); + } + } + else if (value is not null) + { + startInfo.ArgumentList.Add(NormalizeKey(key)); + startInfo.ArgumentList.Add(value.ToString()!); + } + } + } + + try + { + using var process = Process.Start(startInfo); + if (process is null) + { + return Task.FromResult($"Error: Failed to start process for script '{script.Name}'."); + } + + string output = process.StandardOutput.ReadToEnd(); + string error = process.StandardError.ReadToEnd(); + + process.WaitForExit(TimeSpan.FromSeconds(30)); + + if (!string.IsNullOrEmpty(error)) + { + output += $"\nStderr:\n{error}"; + } + + if (process.ExitCode != 0) + { + output += $"\nScript exited with code {process.ExitCode}"; + } + + return Task.FromResult(string.IsNullOrEmpty(output) ? "(no output)" : output.Trim()); + } + catch (Exception ex) + { + return Task.FromResult($"Error: Failed to execute script '{script.Name}': {ex.Message}"); + } + } + + /// + /// Normalizes a parameter key to a consistent --flag format. + /// Models may return keys with or without leading dashes (e.g., "value" vs "--value"). + /// + private static string NormalizeKey(string key) => "--" + key.TrimStart('-'); +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkill.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkill.cs new file mode 100644 index 0000000000..15fd6550da --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkill.cs @@ -0,0 +1,50 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI; + +/// +/// Abstract base class for all agent skills. +/// +/// +/// +/// A skill represents a domain-specific capability with instructions, resources, and scripts. +/// Concrete implementations include (filesystem-backed). +/// +/// +/// Skill metadata follows the Agent Skills specification. +/// +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public abstract class AgentSkill +{ + /// + /// Gets the frontmatter metadata for this skill. + /// + /// + /// Contains the L1 discovery metadata (name, description, license, compatibility, etc.) + /// as defined by the Agent Skills specification. + /// + public abstract AgentSkillFrontmatter Frontmatter { get; } + + /// + /// Gets the full skill content. + /// + /// + /// For file-based skills this is the raw SKILL.md file content. + /// + public abstract string Content { get; } + + /// + /// Gets the resources associated with this skill, or if none. + /// + public abstract IReadOnlyList? Resources { get; } + + /// + /// Gets the scripts associated with this skill, or if none. + /// + public abstract IReadOnlyList? Scripts { get; } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillFrontmatter.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillFrontmatter.cs new file mode 100644 index 0000000000..992f396a36 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillFrontmatter.cs @@ -0,0 +1,200 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Text.RegularExpressions; +using Microsoft.Extensions.AI; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI; + +/// +/// Represents the frontmatter metadata for an agent skill. +/// +/// +/// +/// Frontmatter is the L1 (discovery) layer of the +/// Agent Skills specification. +/// It contains the minimal metadata needed to advertise a skill in the system prompt +/// without loading the full skill content. +/// +/// +/// Instances can be parsed from the YAML frontmatter of a SKILL.md file (see ) +/// or constructed programmatically. +/// +/// +/// The constructor validates the name and description against specification rules +/// and throws if either value is invalid. +/// +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class AgentSkillFrontmatter +{ + /// + /// Maximum allowed length for the skill name. + /// + internal const int MaxNameLength = 64; + + /// + /// Maximum allowed length for the skill description. + /// + internal const int MaxDescriptionLength = 1024; + + /// + /// Maximum allowed length for the compatibility field. + /// + internal const int MaxCompatibilityLength = 500; + + // Validates skill names: lowercase letters, numbers, and hyphens only; + // must not start or end with a hyphen; must not contain consecutive hyphens. + private static readonly Regex s_validNameRegex = new("^[a-z0-9]([a-z0-9]*-[a-z0-9])*[a-z0-9]*$", RegexOptions.Compiled); + + private string? _compatibility; + + /// + /// Initializes a new instance of the class. + /// + /// Skill name in kebab-case. + /// Skill description for discovery. + /// Optional compatibility information (max 500 chars). + /// + /// Thrown when , , or violates the + /// Agent Skills specification rules. + /// + public AgentSkillFrontmatter(string name, string description, string? compatibility = null) + { + if (!ValidateName(name, out string? reason) || + !ValidateDescription(description, out reason) || + !ValidateCompatibility(compatibility, out reason)) + { + throw new ArgumentException(reason); + } + + this.Name = name; + this.Description = description; + this._compatibility = compatibility; + } + + /// + /// Gets the skill name. Lowercase letters, numbers, and hyphens only; no leading, trailing, or consecutive hyphens. + /// + public string Name { get; } + + /// + /// Gets the skill description. Used for discovery in the system prompt. + /// + public string Description { get; } + + /// + /// Gets or sets an optional license name or reference. + /// + public string? License { get; set; } + + /// + /// Gets or sets optional compatibility information (max 500 chars). + /// + /// + /// Thrown when the value exceeds characters. + /// + public string? Compatibility + { + get => this._compatibility; + set + { + if (!ValidateCompatibility(value, out string? reason)) + { + throw new ArgumentException(reason); + } + + this._compatibility = value; + } + } + + /// + /// Gets or sets optional space-delimited list of pre-approved tools. + /// + public string? AllowedTools { get; set; } + + /// + /// Gets or sets the arbitrary key-value metadata for this skill. + /// + public AdditionalPropertiesDictionary? Metadata { get; set; } + + /// + /// Validates a skill name against specification rules. + /// + /// The skill name to validate (may be ). + /// When validation fails, contains a human-readable description of the failure. + /// if the name is valid; otherwise, . + public static bool ValidateName( + string? name, + [NotNullWhen(false)] out string? reason) + { + if (string.IsNullOrWhiteSpace(name)) + { + reason = "Skill name is required."; + return false; + } + + if (name.Length > MaxNameLength) + { + reason = $"Skill name must be {MaxNameLength} characters or fewer."; + return false; + } + + if (!s_validNameRegex.IsMatch(name)) + { + reason = "Skill name must use only lowercase letters, numbers, and hyphens, and must not start or end with a hyphen or contain consecutive hyphens."; + return false; + } + + reason = null; + return true; + } + + /// + /// Validates a skill description against specification rules. + /// + /// The skill description to validate (may be ). + /// When validation fails, contains a human-readable description of the failure. + /// if the description is valid; otherwise, . + public static bool ValidateDescription( + string? description, + [NotNullWhen(false)] out string? reason) + { + if (string.IsNullOrWhiteSpace(description)) + { + reason = "Skill description is required."; + return false; + } + + if (description.Length > MaxDescriptionLength) + { + reason = $"Skill description must be {MaxDescriptionLength} characters or fewer."; + return false; + } + + reason = null; + return true; + } + + /// + /// Validates an optional skill compatibility value against specification rules. + /// + /// The optional compatibility value to validate (may be ). + /// When validation fails, contains a human-readable description of the failure. + /// if the value is valid; otherwise, . + public static bool ValidateCompatibility( + string? compatibility, + [NotNullWhen(false)] out string? reason) + { + if (compatibility?.Length > MaxCompatibilityLength) + { + reason = $"Skill compatibility must be {MaxCompatibilityLength} characters or fewer."; + return false; + } + + reason = null; + return true; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillResource.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillResource.cs new file mode 100644 index 0000000000..ef477c23c7 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillResource.cs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// Abstract base class for skill resources. A resource provides supplementary content (references, assets) to a skill. +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public abstract class AgentSkillResource +{ + /// + /// Initializes a new instance of the class. + /// + /// The resource name (e.g., relative path or identifier). + /// An optional description of the resource. + protected AgentSkillResource(string name, string? description = null) + { + this.Name = Throw.IfNullOrWhitespace(name); + this.Description = description; + } + + /// + /// Gets the resource name. + /// + public string Name { get; } + + /// + /// Gets the optional resource description. + /// + public string? Description { get; } + + /// + /// Reads the resource content asynchronously. + /// + /// Arguments for the resource read operation. + /// Cancellation token. + /// The resource content. + public abstract Task ReadAsync(AIFunctionArguments arguments, CancellationToken cancellationToken = default); +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillScript.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillScript.cs new file mode 100644 index 0000000000..00c7836c7a --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillScript.cs @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// Abstract base class for skill scripts. A script represents an executable action associated with a skill. +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public abstract class AgentSkillScript +{ + /// + /// Initializes a new instance of the class. + /// + /// The script name. + /// An optional description of the script. + protected AgentSkillScript(string name, string? description = null) + { + this.Name = Throw.IfNullOrWhitespace(name); + this.Description = description; + } + + /// + /// Gets the script name. + /// + public string Name { get; } + + /// + /// Gets the optional script description. + /// + public string? Description { get; } + + /// + /// Executes the script with the given arguments. + /// + /// The skill that owns this script. + /// Arguments for script execution. + /// Cancellation token. + /// The script execution result. + public abstract Task ExecuteAsync(AgentSkill skill, AIFunctionArguments arguments, CancellationToken cancellationToken = default); +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProvider.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProvider.cs new file mode 100644 index 0000000000..2722c91c00 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProvider.cs @@ -0,0 +1,277 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Security; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// An that exposes agent skills from one or more instances. +/// +/// +/// +/// This provider implements the progressive disclosure pattern from the +/// Agent Skills specification: +/// +/// +/// Advertise — skill names and descriptions are injected into the system prompt. +/// Load — the full skill body is returned via the load_skill tool. +/// Read resources — supplementary content is read on demand via the read_skill_resource tool. +/// Run scripts — scripts are executed via the run_skill_script tool (when scripts exist). +/// +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed partial class AgentSkillsProvider : AIContextProvider +{ + /// + /// Placeholder token for the generated skills list in the prompt template. + /// + private const string SkillsPlaceholder = "{skills}"; + + /// + /// Placeholder token for the runner/script instructions in the prompt template. + /// + private const string RunnerInstructionsPlaceholder = "{runner_instructions}"; + + private const string DefaultSkillsInstructionPrompt = + """ + You have access to skills containing domain-specific knowledge and capabilities. + Each skill provides specialized instructions, reference documents, and assets for specific tasks. + + + {skills} + + + When a task aligns with a skill's domain, follow these steps in exact order: + - Use `load_skill` to retrieve the skill's instructions. + - Follow the provided guidance. + - Use `read_skill_resource` to read any referenced resources, using the name exactly as listed + (e.g. `"style-guide"` not `"style-guide.md"`, `"references/FAQ.md"` not `"FAQ.md"`). + {runner_instructions} + Only load what is needed, when it is needed. + """; + + private readonly AgentSkillsSource _source; + private readonly AgentSkillsProviderOptions? _options; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The skill source providing skills. + /// Optional configuration. + /// Optional logger factory. + public AgentSkillsProvider(AgentSkillsSource source, AgentSkillsProviderOptions? options = null, ILoggerFactory? loggerFactory = null) + { + this._source = Throw.IfNull(source); + this._options = options; + this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger(); + + if (options?.SkillsInstructionPrompt is string prompt) + { + ValidatePromptTemplate(prompt, nameof(options)); + } + } + + /// + protected override async ValueTask ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default) + { + var skills = await this._source.GetSkillsAsync(cancellationToken).ConfigureAwait(false); + if (skills is not { Count: > 0 }) + { + return await base.ProvideAIContextAsync(context, cancellationToken).ConfigureAwait(false); + } + + bool hasScripts = skills.Any(s => s.Scripts is { Count: > 0 }); + + return new AIContext + { + Instructions = this.BuildSkillsInstructions(skills, includeScriptInstructions: hasScripts), + Tools = this.BuildTools(skills, hasScripts), + }; + } + + private IList BuildTools(IList skills, bool hasScripts) + { + IList tools = + [ + AIFunctionFactory.Create( + (string skillName) => this.LoadSkill(skills, skillName), + name: "load_skill", + description: "Loads the full content of a specific skill"), + AIFunctionFactory.Create( + (string skillName, string resourceName, IServiceProvider? serviceProvider, CancellationToken cancellationToken = default) => + this.ReadSkillResourceAsync(skills, skillName, resourceName, serviceProvider, cancellationToken), + name: "read_skill_resource", + description: "Reads a resource associated with a skill, such as references, assets, or dynamic data."), + ]; + + if (!hasScripts) + { + return tools; + } + + AIFunction scriptFunction = AIFunctionFactory.Create( + (string skillName, string scriptName, IDictionary? arguments = null, IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default) => + this.RunSkillScriptAsync(skills, skillName, scriptName, arguments, serviceProvider, cancellationToken), + name: "run_skill_script", + description: "Runs a script associated with a skill."); + + if (this._options?.ScriptApproval == true) + { + return [.. tools, new ApprovalRequiredAIFunction(scriptFunction)]; + } + + return [.. tools, scriptFunction]; + } + + private string? BuildSkillsInstructions(IList skills, bool includeScriptInstructions) + { + string promptTemplate = this._options?.SkillsInstructionPrompt ?? DefaultSkillsInstructionPrompt; + + var sb = new StringBuilder(); + foreach (var skill in skills.OrderBy(s => s.Frontmatter.Name, StringComparer.Ordinal)) + { + sb.AppendLine(" "); + sb.AppendLine($" {SecurityElement.Escape(skill.Frontmatter.Name)}"); + sb.AppendLine($" {SecurityElement.Escape(skill.Frontmatter.Description)}"); + sb.AppendLine(" "); + } + + string scriptInstruction = includeScriptInstructions + ? "- Use `run_skill_script` to run referenced scripts, using the name exactly as listed." + : string.Empty; + + return new StringBuilder(promptTemplate) + .Replace(SkillsPlaceholder, sb.ToString().TrimEnd()) + .Replace(RunnerInstructionsPlaceholder, scriptInstruction) + .ToString(); + } + + private string LoadSkill(IList skills, string skillName) + { + if (string.IsNullOrWhiteSpace(skillName)) + { + return "Error: Skill name cannot be empty."; + } + + var skill = skills?.FirstOrDefault(skill => skill.Frontmatter.Name == skillName); + if (skill == null) + { + return $"Error: Skill '{skillName}' not found."; + } + + LogSkillLoading(this._logger, skillName); + + return skill.Content; + } + + private async Task ReadSkillResourceAsync(IList skills, string skillName, string resourceName, IServiceProvider? serviceProvider, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(skillName)) + { + return "Error: Skill name cannot be empty."; + } + + if (string.IsNullOrWhiteSpace(resourceName)) + { + return "Error: Resource name cannot be empty."; + } + + var skill = skills?.FirstOrDefault(skill => skill.Frontmatter.Name == skillName); + if (skill == null) + { + return $"Error: Skill '{skillName}' not found."; + } + + var resource = skill.Resources?.FirstOrDefault(resource => resource.Name == resourceName); + if (resource is null) + { + return $"Error: Resource '{resourceName}' not found in skill '{skillName}'."; + } + + try + { + return await resource.ReadAsync(new AIFunctionArguments() { Services = serviceProvider }, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + LogResourceReadError(this._logger, skillName, resourceName, ex); + return $"Error: Failed to read resource '{resourceName}' from skill '{skillName}'."; + } + } + + private async Task RunSkillScriptAsync(IList skills, string skillName, string scriptName, IDictionary? arguments = null, IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(skillName)) + { + return "Error: Skill name cannot be empty."; + } + + if (string.IsNullOrWhiteSpace(scriptName)) + { + return "Error: Script name cannot be empty."; + } + + var skill = skills?.FirstOrDefault(skill => skill.Frontmatter.Name == skillName); if (skill == null) + { + return $"Error: Skill '{skillName}' not found."; + } + + var script = skill.Scripts?.FirstOrDefault(resource => resource.Name == scriptName); + if (script is null) + { + return $"Error: Script '{scriptName}' not found in skill '{skillName}'."; + } + + try + { + return await script.ExecuteAsync(skill, new AIFunctionArguments(arguments) { Services = serviceProvider }, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + LogScriptExecutionError(this._logger, skillName, scriptName, ex); + return $"Error: Failed to execute script '{scriptName}' from skill '{skillName}'."; + } + } + + /// + /// Validates that a custom prompt template contains the required placeholder tokens. + /// + private static void ValidatePromptTemplate(string template, string paramName) + { + if (template.IndexOf(SkillsPlaceholder, StringComparison.Ordinal) < 0) + { + throw new ArgumentException( + $"The custom prompt template must contain the '{SkillsPlaceholder}' placeholder for the generated skills list.", + paramName); + } + + if (template.IndexOf(RunnerInstructionsPlaceholder, StringComparison.Ordinal) < 0) + { + throw new ArgumentException( + $"The custom prompt template must contain the '{RunnerInstructionsPlaceholder}' placeholder for script runner instructions.", + paramName); + } + } + + [LoggerMessage(LogLevel.Information, "Loading skill: {SkillName}")] + private static partial void LogSkillLoading(ILogger logger, string skillName); + + [LoggerMessage(LogLevel.Error, "Failed to read resource '{ResourceName}' from skill '{SkillName}'")] + private static partial void LogResourceReadError(ILogger logger, string skillName, string resourceName, Exception exception); + + [LoggerMessage(LogLevel.Error, "Failed to execute script '{ScriptName}' from skill '{SkillName}'")] + private static partial void LogScriptExecutionError(ILogger logger, string skillName, string scriptName, Exception exception); +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProviderBuilder.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProviderBuilder.cs new file mode 100644 index 0000000000..2e38781677 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProviderBuilder.cs @@ -0,0 +1,235 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.Logging; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// Fluent builder for constructing an backed by one or more skill sources. +/// +/// +/// +/// var provider = new AgentSkillsProviderBuilder() +/// .UseFileSkills("/path/to/skills") +/// .Build(); +/// +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class AgentSkillsProviderBuilder +{ + private readonly List> _sourceFactories = []; + private AgentSkillsProviderOptions? _options; + private ILoggerFactory? _loggerFactory; + private AgentFileSkillScriptExecutor? _scriptExecutor; + private Func? _filter; + private bool _cacheSkills = true; + + /// + /// Adds a file-based skill source that discovers skills from a filesystem directory. + /// + /// + /// The script executor is resolved using the following fallback order: + /// + /// The passed to this method, if provided. + /// The builder-level executor set via . + /// + /// If neither is available, throws . + /// + /// Path to search for skills. + /// Optional options that control skill discovery behavior. + /// + /// Optional executor for file-based scripts. When provided, overrides the builder-level executor + /// set via for this source. + /// + /// This builder instance for chaining. + public AgentSkillsProviderBuilder UseFileSkill(string skillPath, AgentFileSkillsSourceOptions? options = null, AgentFileSkillScriptExecutor? scriptExecutor = null) + { + return this.UseFileSkills([skillPath], options, scriptExecutor); + } + + /// + /// Adds a file-based skill source that discovers skills from multiple filesystem directories. + /// + /// + /// The script executor is resolved using the following fallback order: + /// + /// The passed to this method, if provided. + /// The builder-level executor set via . + /// + /// If neither is available, throws . + /// + /// Paths to search for skills. + /// Optional options that control skill discovery behavior. + /// + /// Optional executor for file-based scripts. When provided, overrides the builder-level executor + /// set via for this source. + /// + /// This builder instance for chaining. + public AgentSkillsProviderBuilder UseFileSkills(IEnumerable skillPaths, AgentFileSkillsSourceOptions? options = null, AgentFileSkillScriptExecutor? scriptExecutor = null) + { + this._sourceFactories.Add((builderScriptExecutor, loggerFactory) => + { + var resolvedExecutor = scriptExecutor + ?? builderScriptExecutor + ?? throw new InvalidOperationException($"File-based skill sources require a script executor. Call {nameof(this.UseFileScriptExecutor)} or pass an executor to {nameof(this.UseFileSkill)}/{nameof(this.UseFileSkills)}."); + return new AgentFileSkillsSource(skillPaths, resolvedExecutor, options, loggerFactory); + }); + return this; + } + + /// + /// Adds a custom skill source. + /// + /// The custom skill source. + /// This builder instance for chaining. + public AgentSkillsProviderBuilder UseSource(AgentSkillsSource source) + { + _ = Throw.IfNull(source); + this._sourceFactories.Add((_, _) => source); + return this; + } + + /// + /// Sets a custom system prompt template. + /// + /// The prompt template with {skills} placeholder for the skills list + /// and {runner_instructions} for optional script runner instructions. + /// This builder instance for chaining. + public AgentSkillsProviderBuilder UsePromptTemplate(string promptTemplate) + { + this.EnsureOptions().SkillsInstructionPrompt = promptTemplate; + return this; + } + + /// + /// Enables or disables the script approval gate. + /// + /// Whether script execution requires approval. + /// This builder instance for chaining. + public AgentSkillsProviderBuilder UseScriptApproval(bool enabled = true) + { + this.EnsureOptions().ScriptApproval = enabled; + return this; + } + + /// + /// Sets the executor for file-based skill scripts. + /// + /// The delegate that executes file-based scripts. + /// This builder instance for chaining. + public AgentSkillsProviderBuilder UseFileScriptExecutor(AgentFileSkillScriptExecutor executor) + { + this._scriptExecutor = Throw.IfNull(executor); + return this; + } + + /// + /// Sets the logger factory. + /// + /// The logger factory. + /// This builder instance for chaining. + public AgentSkillsProviderBuilder UseLoggerFactory(ILoggerFactory loggerFactory) + { + this._loggerFactory = loggerFactory; + return this; + } + + /// + /// Sets a filter predicate that controls which skills are included. + /// + /// + /// Skills for which the predicate returns are kept; + /// others are excluded. Only one filter is supported; calling this method + /// again replaces any previously set filter. + /// + /// A predicate that determines which skills to include. + /// This builder instance for chaining. + public AgentSkillsProviderBuilder UseFilter(Func predicate) + { + _ = Throw.IfNull(predicate); + this._filter = predicate; + return this; + } + + /// + /// Enables or disables skill caching after the first load. + /// + /// to cache skills (default); to reload from sources on every call. + /// This builder instance for chaining. + public AgentSkillsProviderBuilder UseCache(bool enabled = true) + { + this._cacheSkills = enabled; + return this; + } + + /// + /// Configures the using the provided delegate. + /// + /// A delegate to configure the options. + /// This builder instance for chaining. + public AgentSkillsProviderBuilder UseOptions(Action configure) + { + _ = Throw.IfNull(configure); + configure(this.EnsureOptions()); + return this; + } + + /// + /// Builds the . + /// + /// A configured . + public AgentSkillsProvider Build() + { + if (this._sourceFactories.Count == 0) + { + throw new InvalidOperationException("At least one skill source must be configured."); + } + + var resolvedSources = new List(this._sourceFactories.Count); + foreach (var factory in this._sourceFactories) + { + resolvedSources.Add(factory(this._scriptExecutor, this._loggerFactory)); + } + + AgentSkillsSource source; + if (resolvedSources.Count == 1) + { + source = resolvedSources[0]; + } + else + { + source = new AggregateAgentSkillsSource(resolvedSources); + } + + // Apply user-specified filter, then dedup, then optionally cache. + if (this._filter != null) + { + source = new FilteringAgentSkillsSource(source, this._filter, this._loggerFactory); + } + + // Wrap with dedup (first) then caching so duplicates are resolved before the result is cached. + source = new DeduplicatingAgentSkillsSource(source, this._loggerFactory); + + if (this._cacheSkills) + { + source = new CachingAgentSkillsSource(source); + } + + return new AgentSkillsProvider(source, this._options, this._loggerFactory); + } + + private AgentSkillsProviderOptions EnsureOptions() + { + if (this._options == null) + { + this._options = new AgentSkillsProviderOptions(); + } + + return this._options; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProviderOptions.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProviderOptions.cs new file mode 100644 index 0000000000..90cd3fa39e --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProviderOptions.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI; + +/// +/// Configuration options for . +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class AgentSkillsProviderOptions +{ + /// + /// Gets or sets a custom system prompt template for advertising skills. + /// The template must contain {skills} as the placeholder for the generated skills list + /// and {runner_instructions} for script runner instructions. + /// When , a default template is used. + /// + public string? SkillsInstructionPrompt { get; set; } + + /// + /// Gets or sets a value indicating whether script execution requires approval. + /// When , script execution is blocked until approved. + /// Defaults to . + /// + public bool ScriptApproval { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsSource.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsSource.cs new file mode 100644 index 0000000000..6a72d0c01a --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsSource.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI; + +/// +/// Abstract base class for skill sources. A skill source provides skills from a specific origin +/// (filesystem, remote server, database, in-memory, etc.). +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public abstract class AgentSkillsSource +{ + /// + /// Gets the skills provided by this source. + /// + /// Cancellation token. + /// A collection of skills from this source. + public abstract Task> GetSkillsAsync(CancellationToken cancellationToken = default); +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AggregateAgentSkillsSource.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AggregateAgentSkillsSource.cs new file mode 100644 index 0000000000..7f36e6c2d1 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/AggregateAgentSkillsSource.cs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// A skill source that aggregates multiple child sources, preserving their registration order. +/// +/// +/// Skills from each child source are returned in the order the sources were registered, +/// with each source's skills appended sequentially. No deduplication or filtering is applied. +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class AggregateAgentSkillsSource : AgentSkillsSource +{ + private readonly IReadOnlyList _sources; + + /// + /// Initializes a new instance of the class. + /// + /// The child sources to aggregate. + public AggregateAgentSkillsSource(IEnumerable sources) + { + _ = Throw.IfNull(sources); + this._sources = new List(sources); + } + + /// + public override async Task> GetSkillsAsync(CancellationToken cancellationToken = default) + { + var allSkills = new List(); + foreach (var source in this._sources) + { + var skills = await source.GetSkillsAsync(cancellationToken).ConfigureAwait(false); + allSkills.AddRange(skills); + } + + return allSkills; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/CachingAgentSkillsSource.cs b/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/CachingAgentSkillsSource.cs new file mode 100644 index 0000000000..475a9a5d87 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/CachingAgentSkillsSource.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI; + +/// +/// A skill source decorator that caches the result of the first call. +/// +/// +/// Thread-safe: concurrent first callers may redundantly load from the inner source, but the result +/// is idempotent and subsequent calls always return the cached value. +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class CachingAgentSkillsSource : DelegatingAgentSkillsSource +{ + private IList? _cachedSkills; + + /// + /// Initializes a new instance of the class. + /// + /// The inner source to cache. + public CachingAgentSkillsSource(AgentSkillsSource innerSource) + : base(innerSource) + { + } + + /// + public override async Task> GetSkillsAsync(CancellationToken cancellationToken = default) + { + return this._cachedSkills ??= await this.InnerSource.GetSkillsAsync(cancellationToken).ConfigureAwait(false); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/DeduplicatingAgentSkillsSource.cs b/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/DeduplicatingAgentSkillsSource.cs new file mode 100644 index 0000000000..caaab40158 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/DeduplicatingAgentSkillsSource.cs @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI; + +/// +/// A skill source decorator that removes duplicate skills by name (case-insensitive), keeping only the first occurrence. +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed partial class DeduplicatingAgentSkillsSource : DelegatingAgentSkillsSource +{ + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The inner source to deduplicate. + /// Optional logger factory. + public DeduplicatingAgentSkillsSource(AgentSkillsSource innerSource, ILoggerFactory? loggerFactory = null) + : base(innerSource) + { + this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger(); + } + + /// + public override async Task> GetSkillsAsync(CancellationToken cancellationToken = default) + { + var allSkills = await this.InnerSource.GetSkillsAsync(cancellationToken).ConfigureAwait(false); + + var deduplicated = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var skill in allSkills) + { + if (seen.Add(skill.Frontmatter.Name)) + { + deduplicated.Add(skill); + } + else + { + LogDuplicateSkillName(this._logger, skill.Frontmatter.Name); + } + } + + return deduplicated; + } + + [LoggerMessage(LogLevel.Warning, "Duplicate skill name '{SkillName}': subsequent skill skipped in favor of first occurrence")] + private static partial void LogDuplicateSkillName(ILogger logger, string skillName); +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/DelegatingAgentSkillsSource.cs b/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/DelegatingAgentSkillsSource.cs new file mode 100644 index 0000000000..900d33be46 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/DelegatingAgentSkillsSource.cs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// Provides an abstract base class for skill sources that delegate operations to an inner source +/// while allowing for extensibility and customization. +/// +/// +/// implements the decorator pattern for , +/// enabling the creation of source pipelines where each layer can add functionality (caching, deduplication, +/// filtering, etc.) while delegating core operations to an underlying source. +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public abstract class DelegatingAgentSkillsSource : AgentSkillsSource +{ + /// + /// Initializes a new instance of the class with the specified inner source. + /// + /// The underlying skill source that will handle the core operations. + protected DelegatingAgentSkillsSource(AgentSkillsSource innerSource) + { + this.InnerSource = Throw.IfNull(innerSource); + } + + /// + /// Gets the inner skill source that receives delegated operations. + /// + protected AgentSkillsSource InnerSource { get; } + + /// + public override Task> GetSkillsAsync(CancellationToken cancellationToken = default) + => this.InnerSource.GetSkillsAsync(cancellationToken); +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/FilteringAgentSkillsSource.cs b/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/FilteringAgentSkillsSource.cs new file mode 100644 index 0000000000..09d6ab0d78 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/FilteringAgentSkillsSource.cs @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// A skill source decorator that filters skills using a caller-supplied predicate. +/// +/// +/// Skills for which the predicate returns are included in the result; +/// skills for which it returns are excluded and logged at +/// level. +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed partial class FilteringAgentSkillsSource : DelegatingAgentSkillsSource +{ + private readonly Func _predicate; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The inner source whose skills will be filtered. + /// + /// A predicate that determines which skills to include. Skills for which the predicate + /// returns are kept; others are excluded. + /// + /// Optional logger factory. + public FilteringAgentSkillsSource( + AgentSkillsSource innerSource, + Func predicate, + ILoggerFactory? loggerFactory = null) + : base(innerSource) + { + this._predicate = Throw.IfNull(predicate); + this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger(); + } + + /// + public override async Task> GetSkillsAsync(CancellationToken cancellationToken = default) + { + var allSkills = await this.InnerSource.GetSkillsAsync(cancellationToken).ConfigureAwait(false); + + var filtered = new List(); + foreach (var skill in allSkills) + { + if (this._predicate(skill)) + { + filtered.Add(skill); + } + else + { + LogSkillFiltered(this._logger, skill.Frontmatter.Name); + } + } + + return filtered; + } + + [LoggerMessage(LogLevel.Debug, "Skill '{SkillName}' excluded by filter predicate")] + private static partial void LogSkillFiltered(ILogger logger, string skillName); +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkill.cs b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkill.cs new file mode 100644 index 0000000000..0f68c0f912 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkill.cs @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// An discovered from a filesystem directory backed by a SKILL.md file. +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class AgentFileSkill : AgentSkill +{ + private readonly IReadOnlyList _resources; + private readonly IReadOnlyList _scripts; + + /// + /// Initializes a new instance of the class. + /// + /// The parsed frontmatter metadata for this skill. + /// The full raw SKILL.md file content including YAML frontmatter. + /// Absolute path to the directory containing this skill. + /// Resources discovered for this skill. + /// Scripts discovered for this skill. + internal AgentFileSkill( + AgentSkillFrontmatter frontmatter, + string content, + string path, + IReadOnlyList? resources = null, + IReadOnlyList? scripts = null) + { + this.Frontmatter = Throw.IfNull(frontmatter); + this.Content = Throw.IfNull(content); + this.Path = Throw.IfNullOrWhitespace(path); + this._resources = resources ?? []; + this._scripts = scripts ?? []; + } + + /// + public override AgentSkillFrontmatter Frontmatter { get; } + + /// + public override string Content { get; } + + /// + /// Gets the directory path where the skill was discovered. + /// + public string Path { get; } + + /// + /// Gets the resources discovered for this skill. Returns an empty list when the skill has no resources. + /// + public override IReadOnlyList Resources => this._resources; + + /// + /// Gets the scripts discovered for this skill. Returns an empty list when the skill has no scripts. + /// + public override IReadOnlyList Scripts => this._scripts; +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillResource.cs b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillResource.cs new file mode 100644 index 0000000000..d4dac48ad7 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillResource.cs @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// A file-path-backed skill resource. Reads content from a file on disk relative to the skill directory. +/// +internal sealed class AgentFileSkillResource : AgentSkillResource +{ + /// + /// Initializes a new instance of the class. + /// + /// The resource name (relative path within the skill directory). + /// The absolute file path to the resource. + public AgentFileSkillResource(string name, string fullPath) + : base(name) + { + this.FullPath = Throw.IfNullOrWhitespace(fullPath); + } + + /// + /// Gets the absolute file path to the resource. + /// + public string FullPath { get; } + + /// +#pragma warning disable CA1725 // Parameter names should match base declaration +#pragma warning disable RCS1168 // Parameter name differs from base name + public override async Task ReadAsync(AIFunctionArguments _, CancellationToken cancellationToken = default) +#pragma warning restore RCS1168 // Parameter name differs from base name +#pragma warning restore CA1725 // Parameter names should match base declaration + { +#if NET8_0_OR_GREATER + return await File.ReadAllTextAsync(this.FullPath, Encoding.UTF8, cancellationToken).ConfigureAwait(false); +#else + using var reader = new StreamReader(this.FullPath, Encoding.UTF8); + return await reader.ReadToEndAsync().ConfigureAwait(false); +#endif + } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillScript.cs b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillScript.cs new file mode 100644 index 0000000000..fd1c8aa2bd --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillScript.cs @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// A file-path-backed skill script. Represents a script file on disk that requires an external executor to run. +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class AgentFileSkillScript : AgentSkillScript +{ + private readonly AgentFileSkillScriptExecutor _executor; + + /// + /// Initializes a new instance of the class. + /// + /// The script name. + /// The absolute file path to the script. + /// External executor for running the script. + internal AgentFileSkillScript(string name, string fullPath, AgentFileSkillScriptExecutor executor) + : base(name) + { + this.FullPath = Throw.IfNullOrWhitespace(fullPath); + this._executor = Throw.IfNull(executor); + } + + /// + /// Gets the absolute file path to the script. + /// + public string FullPath { get; } + + /// + /// Executes the file-based script using the configured external executor. + /// + /// The skill that owns this script. Must be an . + /// Arguments for script execution. + /// Cancellation token. + /// The script execution result. + /// + /// Thrown when is not an . + /// + public override async Task ExecuteAsync(AgentSkill skill, AIFunctionArguments arguments, CancellationToken cancellationToken = default) + { + if (skill is not AgentFileSkill fileSkill) + { + throw new InvalidOperationException($"File-based script '{this.Name}' requires an {nameof(AgentFileSkill)} but received '{skill.GetType().Name}'."); + } + + return await this._executor(fileSkill, this, arguments, cancellationToken).ConfigureAwait(false); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillScriptExecutor.cs b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillScriptExecutor.cs new file mode 100644 index 0000000000..81c39e0f4b --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillScriptExecutor.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI; + +/// +/// Delegate for executing file-based skill scripts. +/// +/// +/// Implementations determine the execution strategy (e.g., local subprocess, hosted code execution environment). +/// +/// The skill that owns the script. +/// The file-based script to execute. +/// Optional arguments for the script, provided by the agent/LLM. +/// Cancellation token. +/// The script execution result. +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public delegate Task AgentFileSkillScriptExecutor( + AgentFileSkill skill, + AgentFileSkillScript script, + AIFunctionArguments arguments, + CancellationToken cancellationToken); diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillLoader.cs b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSource.cs similarity index 51% rename from dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillLoader.cs rename to dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSource.cs index 18fa87999a..43db8c50c8 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillLoader.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSource.cs @@ -2,151 +2,136 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.IO; -using System.Linq; using System.Text; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI; /// -/// Discovers, parses, and validates SKILL.md files from filesystem directories. +/// A skill source that discovers skills from filesystem directories containing SKILL.md files. /// /// -/// Searches directories recursively (up to levels) for SKILL.md files. -/// Each file is validated for YAML frontmatter. Resource files are discovered by scanning the skill +/// Searches directories recursively (up to 2 levels deep) for SKILL.md files. +/// Each file is validated for YAML frontmatter. Resource and script files are discovered by scanning the skill /// directory for files with matching extensions. Invalid resources are skipped with logged warnings. -/// Resource paths are checked against path traversal and symlink escape attacks. +/// Resource and script paths are checked against path traversal and symlink escape attacks. /// -internal sealed partial class FileAgentSkillLoader +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed partial class AgentFileSkillsSource : AgentSkillsSource { private const string SkillFileName = "SKILL.md"; private const int MaxSearchDepth = 2; - private const int MaxNameLength = 64; - private const int MaxDescriptionLength = 1024; + + private static readonly string[] s_defaultScriptExtensions = [".py", ".js", ".sh", ".ps1", ".cs", ".csx"]; + private static readonly string[] s_defaultResourceExtensions = [".md", ".json", ".yaml", ".yml", ".csv", ".xml", ".txt"]; // Matches YAML frontmatter delimited by "---" lines. Group 1 = content between delimiters. // Multiline makes ^/$ match line boundaries; Singleline makes . match newlines across the block. // The \uFEFF? prefix allows an optional UTF-8 BOM that some editors prepend. - // Example: "---\nname: foo\n---\nBody" → Group 1: "name: foo\n" private static readonly Regex s_frontmatterRegex = new(@"\A\uFEFF?^---\s*$(.+?)^---\s*$", RegexOptions.Multiline | RegexOptions.Singleline | RegexOptions.Compiled, TimeSpan.FromSeconds(5)); - // Matches YAML "key: value" lines. Group 1 = key, Group 2 = quoted value, Group 3 = unquoted value. + // Matches top-level YAML "key: value" lines. Group 1 = key (supports hyphens for keys like allowed-tools), + // Group 2 = quoted value, Group 3 = unquoted value. // Accepts single or double quotes; the lazy quantifier trims trailing whitespace on unquoted values. - // Examples: "name: foo" → (name, _, foo), "name: 'foo bar'" → (name, foo bar, _), - // "description: \"A skill\"" → (description, A skill, _) - private static readonly Regex s_yamlKeyValueRegex = new(@"^\s*(\w+)\s*:\s*(?:[""'](.+?)[""']|(.+?))\s*$", RegexOptions.Multiline | RegexOptions.Compiled, TimeSpan.FromSeconds(5)); + private static readonly Regex s_yamlKeyValueRegex = new(@"^([\w-]+)\s*:\s*(?:[""'](.+?)[""']|(.+?))\s*$", RegexOptions.Multiline | RegexOptions.Compiled, TimeSpan.FromSeconds(5)); - // Validates skill names: lowercase letters, numbers, and hyphens only; - // must not start or end with a hyphen; must not contain consecutive hyphens. - // Examples: "my-skill" ✓, "skill123" ✓, "-bad" ✗, "bad-" ✗, "Bad" ✗, "my--skill" ✗ - private static readonly Regex s_validNameRegex = new("^[a-z0-9]([a-z0-9]*-[a-z0-9])*[a-z0-9]*$", RegexOptions.Compiled); + // Matches a "metadata:" line followed by indented sub-key/value pairs. + // Group 1 captures the entire indented block beneath the metadata key. + private static readonly Regex s_yamlMetadataBlockRegex = new(@"^metadata\s*:\s*$\n((?:[ \t]+\S.*\n?)+)", RegexOptions.Multiline | RegexOptions.Compiled, TimeSpan.FromSeconds(5)); - private readonly ILogger _logger; + // Matches indented YAML "key: value" lines within a metadata block. + // Group 1 = key (supports hyphens), Group 2 = quoted value, Group 3 = unquoted value. + private static readonly Regex s_yamlIndentedKeyValueRegex = new(@"^\s+([\w-]+)\s*:\s*(?:[""'](.+?)[""']|(.+?))\s*$", RegexOptions.Multiline | RegexOptions.Compiled, TimeSpan.FromSeconds(5)); + + private readonly IEnumerable _skillPaths; private readonly HashSet _allowedResourceExtensions; + private readonly HashSet _allowedScriptExtensions; + private readonly AgentFileSkillScriptExecutor _scriptExecutor; + private readonly ILogger _logger; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// - /// The logger instance. - /// File extensions to recognize as skill resources. When , defaults are used. - internal FileAgentSkillLoader(ILogger logger, IEnumerable? allowedResourceExtensions = null) + /// Path to search for skills. + /// Executor for file-based scripts. + /// Optional options that control skill discovery behavior. + /// Optional logger factory. + public AgentFileSkillsSource( + string skillPath, + AgentFileSkillScriptExecutor scriptExecutor, + AgentFileSkillsSourceOptions? options = null, + ILoggerFactory? loggerFactory = null) + : this([skillPath], scriptExecutor, options, loggerFactory) { - this._logger = logger; - - ValidateExtensions(allowedResourceExtensions); - - this._allowedResourceExtensions = new HashSet( - allowedResourceExtensions ?? [".md", ".json", ".yaml", ".yml", ".csv", ".xml", ".txt"], - StringComparer.OrdinalIgnoreCase); } /// - /// Discovers skill directories and loads valid skills from them. + /// Initializes a new instance of the class. /// - /// 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) + /// Paths to search for skills. + /// Executor for file-based scripts. + /// Optional options that control skill discovery behavior. + /// Optional logger factory. + public AgentFileSkillsSource( + IEnumerable skillPaths, + AgentFileSkillScriptExecutor scriptExecutor, + AgentFileSkillsSourceOptions? options = null, + ILoggerFactory? loggerFactory = null) { - 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; - } + this._skillPaths = Throw.IfNull(skillPaths); - if (skills.TryGetValue(skill.Frontmatter.Name, out FileAgentSkill? existing)) - { - LogDuplicateSkillName(this._logger, skill.Frontmatter.Name, skillPath, existing.SourcePath); + var resolvedOptions = options ?? new AgentFileSkillsSourceOptions(); - // Skip duplicate skill names, keeping the first one found. - continue; - } + ValidateExtensions(resolvedOptions.AllowedResourceExtensions); + ValidateExtensions(resolvedOptions.AllowedScriptExtensions); - skills[skill.Frontmatter.Name] = skill; + this._allowedResourceExtensions = new HashSet( + resolvedOptions.AllowedResourceExtensions ?? s_defaultResourceExtensions, + StringComparer.OrdinalIgnoreCase); - LogSkillLoaded(this._logger, skill.Frontmatter.Name); - } + this._allowedScriptExtensions = new HashSet( + resolvedOptions.AllowedScriptExtensions ?? s_defaultScriptExtensions, + StringComparer.OrdinalIgnoreCase); - LogSkillsLoadedTotal(this._logger, skills.Count); + this._scriptExecutor = Throw.IfNull(scriptExecutor); - return skills; + this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger(); } - /// - /// 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) + /// + public override Task> GetSkillsAsync(CancellationToken cancellationToken = default) { - resourceName = NormalizeResourcePath(resourceName); + var discoveredPaths = DiscoverSkillDirectories(this._skillPaths); - if (!skill.ResourceNames.Any(r => r.Equals(resourceName, StringComparison.OrdinalIgnoreCase))) - { - throw new InvalidOperationException($"Resource '{resourceName}' not found in skill '{skill.Frontmatter.Name}'."); - } + LogSkillsDiscovered(this._logger, discoveredPaths.Count); - string fullPath = Path.GetFullPath(Path.Combine(skill.SourcePath, resourceName)); - string normalizedSourcePath = Path.GetFullPath(skill.SourcePath) + Path.DirectorySeparatorChar; + var skills = new List(); - if (!IsPathWithinDirectory(fullPath, normalizedSourcePath)) + foreach (string skillPath in discoveredPaths) { - throw new InvalidOperationException($"Resource file '{resourceName}' references a path outside the skill directory."); - } + AgentFileSkill? skill = this.ParseSkillDirectory(skillPath); + if (skill is null) + { + continue; + } - if (!File.Exists(fullPath)) - { - throw new InvalidOperationException($"Resource file '{resourceName}' not found in skill '{skill.Frontmatter.Name}'."); - } + skills.Add(skill); - if (HasSymlinkInPath(fullPath, normalizedSourcePath)) - { - throw new InvalidOperationException($"Resource file '{resourceName}' is a symlink that resolves outside the skill directory."); + LogSkillLoaded(this._logger, skill.Frontmatter.Name); } - LogResourceReading(this._logger, resourceName, skill.Frontmatter.Name); + LogSkillsLoadedTotal(this._logger, skills.Count); -#if NET - return await File.ReadAllTextAsync(fullPath, Encoding.UTF8, cancellationToken).ConfigureAwait(false); -#else - return await Task.FromResult(File.ReadAllText(fullPath, Encoding.UTF8)).ConfigureAwait(false); -#endif + return Task.FromResult(skills as IList); } private static List DiscoverSkillDirectories(IEnumerable skillPaths) @@ -185,30 +170,30 @@ private static void SearchDirectoriesForSkills(string directory, List re } } - private FileAgentSkill? ParseSkillFile(string skillDirectoryFullPath) + private AgentFileSkill? ParseSkillDirectory(string skillDirectoryFullPath) { string skillFilePath = Path.Combine(skillDirectoryFullPath, SkillFileName); - string content = File.ReadAllText(skillFilePath, Encoding.UTF8); - if (!this.TryParseSkillDocument(content, skillFilePath, out SkillFrontmatter frontmatter, out string body)) + if (!this.TryParseFrontmatter(content, skillFilePath, out AgentSkillFrontmatter? frontmatter)) { return null; } - List resourceNames = this.DiscoverResourceFiles(skillDirectoryFullPath, frontmatter.Name); + var resources = this.DiscoverResourceFiles(skillDirectoryFullPath, frontmatter.Name); + var scripts = this.DiscoverScriptFiles(skillDirectoryFullPath, frontmatter.Name); - return new FileAgentSkill( + return new AgentFileSkill( frontmatter: frontmatter, - body: body, - sourcePath: skillDirectoryFullPath, - resourceNames: resourceNames); + content: content, + path: skillDirectoryFullPath, + resources: resources, + scripts: scripts); } - private bool TryParseSkillDocument(string content, string skillFilePath, out SkillFrontmatter frontmatter, out string body) + private bool TryParseFrontmatter(string content, string skillFilePath, [NotNullWhen(true)] out AgentSkillFrontmatter? frontmatter) { - frontmatter = null!; - body = null!; + frontmatter = null; Match match = s_frontmatterRegex.Match(content); if (!match.Success) @@ -217,10 +202,13 @@ private bool TryParseSkillDocument(string content, string skillFilePath, out Ski return false; } + string yamlContent = match.Groups[1].Value.Trim(); + string? name = null; string? description = null; - - string yamlContent = match.Groups[1].Value.Trim(); + string? license = null; + string? compatibility = null; + string? allowedTools = null; foreach (Match kvMatch in s_yamlKeyValueRegex.Matches(yamlContent)) { @@ -235,50 +223,62 @@ private bool TryParseSkillDocument(string content, string skillFilePath, out Ski { description = value; } + else if (string.Equals(key, "license", StringComparison.OrdinalIgnoreCase)) + { + license = value; + } + else if (string.Equals(key, "compatibility", StringComparison.OrdinalIgnoreCase)) + { + compatibility = value; + } + else if (string.Equals(key, "allowed-tools", StringComparison.OrdinalIgnoreCase)) + { + allowedTools = value; + } } - if (string.IsNullOrWhiteSpace(name)) + // Parse metadata block (indented key-value pairs under "metadata:"). + AdditionalPropertiesDictionary? metadata = null; + Match metadataMatch = s_yamlMetadataBlockRegex.Match(yamlContent); + if (metadataMatch.Success) { - LogMissingFrontmatterField(this._logger, skillFilePath, "name"); - return false; + metadata = []; + foreach (Match kvMatch in s_yamlIndentedKeyValueRegex.Matches(metadataMatch.Groups[1].Value)) + { + metadata[kvMatch.Groups[1].Value] = kvMatch.Groups[2].Success ? kvMatch.Groups[2].Value : kvMatch.Groups[3].Value; + } } - if (name.Length > MaxNameLength || !s_validNameRegex.IsMatch(name)) + if (!AgentSkillFrontmatter.ValidateName(name, out string? validationReason) || + !AgentSkillFrontmatter.ValidateDescription(description, out validationReason)) { - LogInvalidFieldValue(this._logger, skillFilePath, "name", $"Must be {MaxNameLength} characters or fewer, using only lowercase letters, numbers, and hyphens, and must not start or end with a hyphen or contain consecutive hyphens."); + LogInvalidFieldValue(this._logger, skillFilePath, "frontmatter", validationReason); return false; } + frontmatter = new AgentSkillFrontmatter(name!, description!, compatibility) + { + License = license, + AllowedTools = allowedTools, + Metadata = metadata, + }; + // skillFilePath is e.g. "/skills/my-skill/SKILL.md". // GetDirectoryName strips the filename → "/skills/my-skill". // GetFileName then extracts the last segment → "my-skill". // This gives us the skill's parent directory name to validate against the frontmatter name. string directoryName = Path.GetFileName(Path.GetDirectoryName(skillFilePath)) ?? string.Empty; - if (!string.Equals(name, directoryName, StringComparison.Ordinal)) + if (!string.Equals(frontmatter.Name, directoryName, StringComparison.Ordinal)) { if (this._logger.IsEnabled(LogLevel.Error)) { - LogNameDirectoryMismatch(this._logger, SanitizePathForLog(skillFilePath), name, SanitizePathForLog(directoryName)); + LogNameDirectoryMismatch(this._logger, SanitizePathForLog(skillFilePath), frontmatter.Name, SanitizePathForLog(directoryName)); } + frontmatter = null; return false; } - if (string.IsNullOrWhiteSpace(description)) - { - LogMissingFrontmatterField(this._logger, skillFilePath, "description"); - return false; - } - - if (description.Length > MaxDescriptionLength) - { - LogInvalidFieldValue(this._logger, skillFilePath, "description", $"Must be {MaxDescriptionLength} characters or fewer."); - return false; - } - - frontmatter = new SkillFrontmatter(name, description); - body = content.Substring(match.Index + match.Length).TrimStart(); - return true; } @@ -286,16 +286,16 @@ private bool TryParseSkillDocument(string content, string skillFilePath, out Ski /// Scans a skill directory for resource files matching the configured extensions. /// /// - /// Recursively walks and collects files whose extension - /// matches , excluding SKILL.md itself. Each candidate - /// is validated against path-traversal and symlink-escape checks; unsafe files are skipped with - /// a warning. + /// Recursively walks and collects + /// files whose extension matches the allowed set, excluding SKILL.md itself. + /// Each candidate is validated against path-traversal and symlink-escape checks; unsafe files + /// are skipped with a warning. /// - private List DiscoverResourceFiles(string skillDirectoryFullPath, string skillName) + private List DiscoverResourceFiles(string skillDirectoryFullPath, string skillName) { string normalizedSkillDirectoryFullPath = skillDirectoryFullPath + Path.DirectorySeparatorChar; - var resources = new List(); + var resources = new List(); #if NET var enumerationOptions = new EnumerationOptions @@ -326,21 +326,21 @@ private List DiscoverResourceFiles(string skillDirectoryFullPath, string { LogResourceSkippedExtension(this._logger, skillName, SanitizePathForLog(filePath), extension); } + continue; } // Normalize the enumerated path to guard against non-canonical forms - // (redundant separators, 8.3 short names, etc.) that would produce - // malformed relative resource names. string resolvedFilePath = Path.GetFullPath(filePath); // Path containment check - if (!IsPathWithinDirectory(resolvedFilePath, normalizedSkillDirectoryFullPath)) + if (!resolvedFilePath.StartsWith(normalizedSkillDirectoryFullPath, StringComparison.OrdinalIgnoreCase)) { if (this._logger.IsEnabled(LogLevel.Warning)) { LogResourcePathTraversal(this._logger, skillName, SanitizePathForLog(filePath)); } + continue; } @@ -351,30 +351,86 @@ private List DiscoverResourceFiles(string skillDirectoryFullPath, string { LogResourceSymlinkEscape(this._logger, skillName, SanitizePathForLog(filePath)); } + continue; } // Compute relative path and normalize to forward slashes - string relativePath = resolvedFilePath.Substring(normalizedSkillDirectoryFullPath.Length); - resources.Add(NormalizeResourcePath(relativePath)); + string relativePath = NormalizePath(resolvedFilePath.Substring(normalizedSkillDirectoryFullPath.Length)); + resources.Add(new AgentFileSkillResource(relativePath, resolvedFilePath)); } return resources; } /// - /// Checks that is under , - /// guarding against path traversal attacks. + /// Scans a skill directory for script files matching the configured extensions. /// - private static bool IsPathWithinDirectory(string fullPath, string normalizedDirectoryPath) + /// + /// Recursively walks the skill directory and collects files whose extension + /// matches the allowed set. Each candidate is validated against path-traversal + /// and symlink-escape checks; unsafe files are skipped with a warning. + /// + private List DiscoverScriptFiles(string skillDirectoryFullPath, string skillName) { - return fullPath.StartsWith(normalizedDirectoryPath, StringComparison.OrdinalIgnoreCase); + string normalizedSkillDirectoryFullPath = skillDirectoryFullPath + Path.DirectorySeparatorChar; + var scripts = new List(); + +#if NET + var enumerationOptions = new EnumerationOptions + { + RecurseSubdirectories = true, + IgnoreInaccessible = true, + AttributesToSkip = FileAttributes.ReparsePoint, + }; + + foreach (string filePath in Directory.EnumerateFiles(skillDirectoryFullPath, "*", enumerationOptions)) +#else + foreach (string filePath in Directory.EnumerateFiles(skillDirectoryFullPath, "*", SearchOption.AllDirectories)) +#endif + { + // Filter by extension + string extension = Path.GetExtension(filePath); + if (string.IsNullOrEmpty(extension) || !this._allowedScriptExtensions.Contains(extension)) + { + continue; + } + + // Normalize the enumerated path to guard against non-canonical forms + string resolvedFilePath = Path.GetFullPath(filePath); + + // Path containment check + if (!resolvedFilePath.StartsWith(normalizedSkillDirectoryFullPath, StringComparison.OrdinalIgnoreCase)) + { + if (this._logger.IsEnabled(LogLevel.Warning)) + { + LogScriptPathTraversal(this._logger, skillName, SanitizePathForLog(filePath)); + } + + continue; + } + + // Symlink check + if (HasSymlinkInPath(resolvedFilePath, normalizedSkillDirectoryFullPath)) + { + if (this._logger.IsEnabled(LogLevel.Warning)) + { + LogScriptSymlinkEscape(this._logger, skillName, SanitizePathForLog(filePath)); + } + + continue; + } + + // Compute relative path and normalize to forward slashes + string relativePath = NormalizePath(resolvedFilePath.Substring(normalizedSkillDirectoryFullPath.Length)); + scripts.Add(new AgentFileSkillScript(relativePath, resolvedFilePath, this._scriptExecutor)); + } + + return scripts; } /// - /// Checks whether any segment in (relative to - /// ) is a symlink (reparse point). - /// Uses which is available on all target frameworks. + /// Checks whether any segment in the path (relative to the directory) is a symlink. /// private static bool HasSymlinkInPath(string fullPath, string normalizedDirectoryPath) { @@ -399,11 +455,10 @@ private static bool HasSymlinkInPath(string fullPath, string normalizedDirectory } /// - /// Normalizes a relative resource path by trimming a leading ./ prefix and replacing - /// backslashes with forward slashes so that ./refs/doc.md and refs/doc.md are - /// treated as the same resource. + /// Normalizes a relative path by replacing backslashes with forward slashes + /// and trimming a leading "./" prefix. /// - private static string NormalizeResourcePath(string path) + private static string NormalizePath(string path) { if (path.IndexOf('\\') >= 0) { @@ -419,8 +474,7 @@ private static string NormalizeResourcePath(string path) } /// - /// Replaces control characters in a file path with '?' to prevent log injection - /// via crafted filenames (e.g., filenames containing newlines on Linux). + /// Replaces control characters in a file path with '?' to prevent log injection. /// private static string SanitizePathForLog(string path) { @@ -449,7 +503,7 @@ private static void ValidateExtensions(IEnumerable? extensions) if (string.IsNullOrWhiteSpace(ext) || !ext.StartsWith(".", StringComparison.Ordinal)) { #pragma warning disable CA2208 // Instantiate argument exceptions correctly - throw new ArgumentException($"Each extension must start with '.'. Invalid value: '{ext}'", nameof(FileAgentSkillsProviderOptions.AllowedResourceExtensions)); + throw new ArgumentException($"Each extension must start with '.'. Invalid value: '{ext}'", "allowedResourceExtensions"); #pragma warning restore CA2208 // Instantiate argument exceptions correctly } } @@ -467,9 +521,6 @@ private static void ValidateExtensions(IEnumerable? extensions) [LoggerMessage(LogLevel.Error, "SKILL.md at '{SkillFilePath}' does not contain valid YAML frontmatter delimited by '---'")] private static partial void LogInvalidFrontmatter(ILogger logger, string skillFilePath); - [LoggerMessage(LogLevel.Error, "SKILL.md at '{SkillFilePath}' is missing a '{FieldName}' field in frontmatter")] - private static partial void LogMissingFrontmatterField(ILogger logger, string skillFilePath, string fieldName); - [LoggerMessage(LogLevel.Error, "SKILL.md at '{SkillFilePath}' has an invalid '{FieldName}' value: {Reason}")] private static partial void LogInvalidFieldValue(ILogger logger, string skillFilePath, string fieldName, string reason); @@ -479,15 +530,15 @@ private static void ValidateExtensions(IEnumerable? extensions) [LoggerMessage(LogLevel.Warning, "Skipping resource in skill '{SkillName}': '{ResourcePath}' references a path outside the skill directory")] private static partial void LogResourcePathTraversal(ILogger logger, string skillName, string resourcePath); - [LoggerMessage(LogLevel.Warning, "Duplicate skill name '{SkillName}': skill from '{NewPath}' skipped in favor of existing skill from '{ExistingPath}'")] - private static partial void LogDuplicateSkillName(ILogger logger, string skillName, string newPath, string existingPath); - [LoggerMessage(LogLevel.Warning, "Skipping resource in skill '{SkillName}': '{ResourcePath}' is a symlink that resolves outside the skill directory")] private static partial void LogResourceSymlinkEscape(ILogger logger, string skillName, string resourcePath); - [LoggerMessage(LogLevel.Information, "Reading resource '{FileName}' from skill '{SkillName}'")] - private static partial void LogResourceReading(ILogger logger, string fileName, string skillName); - [LoggerMessage(LogLevel.Debug, "Skipping file '{FilePath}' in skill '{SkillName}': extension '{Extension}' is not in the allowed list")] private static partial void LogResourceSkippedExtension(ILogger logger, string skillName, string filePath, string extension); + + [LoggerMessage(LogLevel.Warning, "Skipping script in skill '{SkillName}': '{ScriptPath}' references a path outside the skill directory")] + private static partial void LogScriptPathTraversal(ILogger logger, string skillName, string scriptPath); + + [LoggerMessage(LogLevel.Warning, "Skipping script in skill '{SkillName}': '{ScriptPath}' is a symlink that resolves outside the skill directory")] + private static partial void LogScriptSymlinkEscape(ILogger logger, string skillName, string scriptPath); } diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSourceOptions.cs b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSourceOptions.cs new file mode 100644 index 0000000000..b90eb12ee7 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSourceOptions.cs @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI; + +/// +/// Configuration options for . +/// +/// +/// Use this class to configure file-based skill discovery without relying on +/// positional constructor or method parameters. New options can be added here +/// without breaking existing callers. +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class AgentFileSkillsSourceOptions +{ + /// + /// Gets or sets the allowed file extensions for skill resources. + /// When , defaults to .md, .json, .yaml, + /// .yml, .csv, .xml, .txt. + /// + public IEnumerable? AllowedResourceExtensions { get; set; } + + /// + /// Gets or sets the allowed file extensions for skill scripts. + /// When , defaults to .py, .js, .sh, + /// .ps1, .cs, .csx. + /// + public IEnumerable? AllowedScriptExtensions { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkill.cs b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkill.cs deleted file mode 100644 index f28bad3ab0..0000000000 --- a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkill.cs +++ /dev/null @@ -1,56 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Collections.Generic; -using Microsoft.Shared.Diagnostics; - -namespace Microsoft.Agents.AI; - -/// -/// Represents a loaded Agent Skill discovered from a filesystem directory. -/// -/// -/// Each skill is backed by a SKILL.md file containing YAML frontmatter (name and description) -/// and a markdown body with instructions. Resource files referenced in the body are validated at -/// discovery time and read from disk on demand. -/// -internal sealed class FileAgentSkill -{ - /// - /// Initializes a new instance of the class. - /// - /// Parsed YAML frontmatter (name and description). - /// The SKILL.md content after the closing --- delimiter. - /// Absolute path to the directory containing this skill. - /// Relative paths of resource files referenced in the skill body. - public FileAgentSkill( - SkillFrontmatter frontmatter, - string body, - string sourcePath, - IReadOnlyList? resourceNames = null) - { - this.Frontmatter = Throw.IfNull(frontmatter); - this.Body = Throw.IfNull(body); - this.SourcePath = Throw.IfNullOrWhitespace(sourcePath); - this.ResourceNames = resourceNames ?? []; - } - - /// - /// Gets the parsed YAML frontmatter (name and description). - /// - public SkillFrontmatter Frontmatter { get; } - - /// - /// Gets the SKILL.md body content (without the YAML frontmatter). - /// - public string Body { get; } - - /// - /// Gets the directory path where the skill was discovered. - /// - public string SourcePath { get; } - - /// - /// Gets the relative paths of resource files referenced in the skill body (e.g., "references/FAQ.md"). - /// - public IReadOnlyList ResourceNames { get; } -} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProvider.cs b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProvider.cs deleted file mode 100644 index 460faced70..0000000000 --- a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProvider.cs +++ /dev/null @@ -1,222 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Linq; -using System.Security; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Shared.DiagnosticIds; -using Microsoft.Shared.Diagnostics; - -namespace Microsoft.Agents.AI; - -/// -/// An that discovers and exposes Agent Skills from filesystem directories. -/// -/// -/// -/// This provider implements the progressive disclosure pattern from the -/// Agent Skills specification: -/// -/// -/// Advertise — skill names and descriptions are injected into the system prompt (~100 tokens per skill). -/// Load — the full SKILL.md body is returned via the load_skill tool. -/// Read resources — supplementary files are read from disk on demand via the read_skill_resource tool. -/// -/// -/// Skills are discovered by searching the configured directories for SKILL.md files. -/// Referenced resources are validated at initialization; invalid skills are excluded and logged. -/// -/// -/// Security: this provider only reads static content. Skill metadata is XML-escaped -/// before prompt embedding, and resource reads are guarded against path traversal and symlink escape. -/// Only use skills from trusted sources. -/// -/// -[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] -public sealed partial class FileAgentSkillsProvider : AIContextProvider -{ - private const string DefaultSkillsInstructionPrompt = - """ - You have access to skills containing domain-specific knowledge and capabilities. - Each skill provides specialized instructions, reference documents, and assets for specific tasks. - - - {0} - - - When a task aligns with a skill's domain: - 1. Use `load_skill` to retrieve the skill's instructions - 2. Follow the provided guidance - 3. Use `read_skill_resource` to read any references or other files mentioned by the skill - - Only load what is needed, when it is needed. - """; - - private readonly Dictionary _skills; - private readonly ILogger _logger; - private readonly FileAgentSkillLoader _loader; - private readonly AITool[] _tools; - private readonly string? _skillsInstructionPrompt; - - /// - /// Initializes a new instance of the class that searches a single directory for skills. - /// - /// Path to an individual skill folder (containing a SKILL.md file) or a parent folder with skill subdirectories. - /// Optional configuration for prompt customization. - /// Optional logger factory. - public FileAgentSkillsProvider(string skillPath, FileAgentSkillsProviderOptions? options = null, ILoggerFactory? loggerFactory = null) - : this([skillPath], options, loggerFactory) - { - } - - /// - /// Initializes a new instance of the class that searches multiple directories for skills. - /// - /// Paths to search. Each can be an individual skill folder or a parent folder with skill subdirectories. - /// Optional configuration for prompt customization. - /// Optional logger factory. - public FileAgentSkillsProvider(IEnumerable skillPaths, FileAgentSkillsProviderOptions? options = null, ILoggerFactory? loggerFactory = null) - { - _ = Throw.IfNull(skillPaths); - - this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger(); - - this._loader = new FileAgentSkillLoader(this._logger, options?.AllowedResourceExtensions); - this._skills = this._loader.DiscoverAndLoadSkills(skillPaths); - - this._skillsInstructionPrompt = BuildSkillsInstructionPrompt(options, this._skills); - - this._tools = - [ - AIFunctionFactory.Create( - this.LoadSkill, - name: "load_skill", - description: "Loads the full instructions for a specific skill."), - AIFunctionFactory.Create( - this.ReadSkillResourceAsync, - name: "read_skill_resource", - description: "Reads a file associated with a skill, such as references or assets."), - ]; - } - - /// - protected override ValueTask ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default) - { - if (this._skills.Count == 0) - { - return base.ProvideAIContextAsync(context, cancellationToken); - } - - return new ValueTask(new AIContext - { - Instructions = this._skillsInstructionPrompt, - Tools = this._tools - }); - } - - private string LoadSkill(string skillName) - { - if (string.IsNullOrWhiteSpace(skillName)) - { - return "Error: Skill name cannot be empty."; - } - - if (!this._skills.TryGetValue(skillName, out FileAgentSkill? skill)) - { - return $"Error: Skill '{skillName}' not found."; - } - - LogSkillLoading(this._logger, skillName); - - return skill.Body; - } - - private async Task ReadSkillResourceAsync(string skillName, string resourceName, CancellationToken cancellationToken = default) - { - if (string.IsNullOrWhiteSpace(skillName)) - { - return "Error: Skill name cannot be empty."; - } - - if (string.IsNullOrWhiteSpace(resourceName)) - { - return "Error: Resource name cannot be empty."; - } - - if (!this._skills.TryGetValue(skillName, out FileAgentSkill? skill)) - { - return $"Error: Skill '{skillName}' not found."; - } - - try - { - return await this._loader.ReadSkillResourceAsync(skill, resourceName, cancellationToken).ConfigureAwait(false); - } - catch (Exception ex) - { - LogResourceReadError(this._logger, skillName, resourceName, ex); - return $"Error: Failed to read resource '{resourceName}' from skill '{skillName}'."; - } - } - - private static string? BuildSkillsInstructionPrompt(FileAgentSkillsProviderOptions? options, Dictionary skills) - { - string promptTemplate = DefaultSkillsInstructionPrompt; - - if (options?.SkillsInstructionPrompt is { } optionsInstructions) - { - try - { - _ = string.Format(optionsInstructions, string.Empty); - } - catch (FormatException ex) - { - throw new ArgumentException( - "The provided SkillsInstructionPrompt is not a valid format string.", - nameof(options), - ex); - } - - if (optionsInstructions.IndexOf("{0}", StringComparison.Ordinal) < 0) - { - throw new ArgumentException( - "The provided SkillsInstructionPrompt must contain a '{0}' placeholder for the generated skills list.", - nameof(options)); - } - - promptTemplate = optionsInstructions; - } - - if (skills.Count == 0) - { - return null; - } - - var sb = new StringBuilder(); - - // Order by name for deterministic prompt output across process restarts - // (Dictionary enumeration order is not guaranteed and varies with hash randomization). - foreach (var skill in skills.Values.OrderBy(s => s.Frontmatter.Name, StringComparer.Ordinal)) - { - sb.AppendLine(" "); - sb.AppendLine($" {SecurityElement.Escape(skill.Frontmatter.Name)}"); - sb.AppendLine($" {SecurityElement.Escape(skill.Frontmatter.Description)}"); - sb.AppendLine(" "); - } - - return string.Format(promptTemplate, sb.ToString().TrimEnd()); - } - - [LoggerMessage(LogLevel.Information, "Loading skill: {SkillName}")] - private static partial void LogSkillLoading(ILogger logger, string skillName); - - [LoggerMessage(LogLevel.Error, "Failed to read resource '{ResourceName}' from skill '{SkillName}'")] - private static partial void LogResourceReadError(ILogger logger, string skillName, string resourceName, Exception exception); -} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProviderOptions.cs b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProviderOptions.cs deleted file mode 100644 index 600c5b964c..0000000000 --- a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProviderOptions.cs +++ /dev/null @@ -1,32 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using Microsoft.Shared.DiagnosticIds; - -namespace Microsoft.Agents.AI; - -/// -/// Configuration options for . -/// -[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] -public sealed class FileAgentSkillsProviderOptions -{ - /// - /// Gets or sets a custom system prompt template for advertising skills. - /// Use {0} as the placeholder for the generated skills list. - /// When , a default template is used. - /// - public string? SkillsInstructionPrompt { get; set; } - - /// - /// Gets or sets the file extensions recognized as discoverable skill resources. - /// Each value must start with a '.' character (for example, .md), and - /// extension comparisons are performed in a case-insensitive manner. - /// Files in the skill directory (and its subdirectories) whose extension matches - /// one of these values will be automatically discovered as resources. - /// When , a default set of extensions is used - /// (.md, .json, .yaml, .yml, .csv, .xml, .txt). - /// - public IEnumerable? AllowedResourceExtensions { get; set; } -} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/SkillFrontmatter.cs b/dotnet/src/Microsoft.Agents.AI/Skills/SkillFrontmatter.cs deleted file mode 100644 index 123a6c43f4..0000000000 --- a/dotnet/src/Microsoft.Agents.AI/Skills/SkillFrontmatter.cs +++ /dev/null @@ -1,32 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Microsoft.Shared.Diagnostics; - -namespace Microsoft.Agents.AI; - -/// -/// Parsed YAML frontmatter from a SKILL.md file, containing the skill's name and description. -/// -internal sealed class SkillFrontmatter -{ - /// - /// Initializes a new instance of the class. - /// - /// Skill name. - /// Skill description. - public SkillFrontmatter(string name, string description) - { - this.Name = Throw.IfNullOrWhitespace(name); - this.Description = Throw.IfNullOrWhitespace(description); - } - - /// - /// Gets the skill name. Lowercase letters, numbers, and hyphens only. - /// - public string Name { get; } - - /// - /// Gets the skill description. Used for discovery in the system prompt. - /// - public string Description { get; } -} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillScriptTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillScriptTests.cs new file mode 100644 index 0000000000..d2744a1e3a --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillScriptTests.cs @@ -0,0 +1,103 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.UnitTests.AgentSkills; + +/// +/// Unit tests for . +/// +public sealed class AgentFileSkillScriptTests +{ + [Fact] + public async Task ExecuteAsync_SkillIsNotAgentFileSkill_ThrowsInvalidOperationExceptionAsync() + { + // Arrange + static Task ExecutorAsync(AgentFileSkill s, AgentFileSkillScript sc, AIFunctionArguments a, CancellationToken ct) => Task.FromResult("result"); + var script = CreateScript("test-script", "/path/to/script.py", ExecutorAsync); + var nonFileSkill = new TestAgentSkill("my-skill", "A skill", "Instructions."); + + // Act & Assert + await Assert.ThrowsAsync( + () => script.ExecuteAsync(nonFileSkill, new AIFunctionArguments(), CancellationToken.None)); + } + + [Fact] + public async Task ExecuteAsync_WithAgentFileSkill_DelegatesToExecutorAsync() + { + // Arrange + var executorCalled = false; + Task executorAsync(AgentFileSkill skill, AgentFileSkillScript scriptArg, AIFunctionArguments args, CancellationToken ct) + { + executorCalled = true; + return Task.FromResult("executed"); + } + var script = CreateScript("run-me", "/scripts/run-me.sh", executorAsync); + var fileSkill = new AgentFileSkill( + new AgentSkillFrontmatter("my-skill", "A file skill"), + "---\nname: my-skill\n---\nContent", + "/skills/my-skill"); + + // Act + var result = await script.ExecuteAsync(fileSkill, new AIFunctionArguments(), CancellationToken.None); + + // Assert + Assert.True(executorCalled); + Assert.Equal("executed", result); + } + + [Fact] + public async Task ExecuteAsync_ExecutorReceivesCorrectArgumentsAsync() + { + // Arrange + AgentFileSkill? capturedSkill = null; + AgentFileSkillScript? capturedScript = null; + Task executorAsync(AgentFileSkill skill, AgentFileSkillScript scriptArg, AIFunctionArguments args, CancellationToken ct) + { + capturedSkill = skill; + capturedScript = scriptArg; + return Task.FromResult(null); + } + var script = CreateScript("capture", "/scripts/capture.py", executorAsync); + var fileSkill = new AgentFileSkill( + new AgentSkillFrontmatter("owner-skill", "Owner"), + "Content", + "/skills/owner-skill"); + + // Act + await script.ExecuteAsync(fileSkill, new AIFunctionArguments(), CancellationToken.None); + + // Assert + Assert.Same(fileSkill, capturedSkill); + Assert.Same(script, capturedScript); + } + + [Fact] + public void Script_HasCorrectNameAndPath() + { + // Arrange & Act + static Task ExecutorAsync(AgentFileSkill s, AgentFileSkillScript sc, AIFunctionArguments a, CancellationToken ct) => Task.FromResult(null); + var script = CreateScript("my-script", "/path/to/my-script.py", ExecutorAsync); + + // Assert + Assert.Equal("my-script", script.Name); + Assert.Equal("/path/to/my-script.py", script.FullPath); + } + + /// + /// Helper to create an via reflection since the constructor is internal. + /// + private static AgentFileSkillScript CreateScript(string name, string fullPath, AgentFileSkillScriptExecutor executor) + { + var ctor = typeof(AgentFileSkillScript).GetConstructor( + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance, + null, + [typeof(string), typeof(string), typeof(AgentFileSkillScriptExecutor)], + null) ?? throw new InvalidOperationException("Could not find internal constructor."); + + return (AgentFileSkillScript)ctor.Invoke([name, fullPath, executor]); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillsSourceScriptTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillsSourceScriptTests.cs new file mode 100644 index 0000000000..57f9a0beee --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillsSourceScriptTests.cs @@ -0,0 +1,237 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.UnitTests.AgentSkills; + +/// +/// Unit tests for script discovery and execution in . +/// +public sealed class AgentFileSkillsSourceScriptTests : IDisposable +{ + private static readonly string[] s_rubyExtension = new[] { ".rb" }; + private static readonly AgentFileSkillScriptExecutor s_noOpExecutor = (skill, script, args, ct) => Task.FromResult(null); + + private readonly string _testRoot; + + public AgentFileSkillsSourceScriptTests() + { + this._testRoot = Path.Combine(Path.GetTempPath(), "skills-source-script-tests-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(this._testRoot); + } + + public void Dispose() + { + if (Directory.Exists(this._testRoot)) + { + Directory.Delete(this._testRoot, recursive: true); + } + } + + [Fact] + public async Task GetSkillsAsync_WithScriptFiles_DiscoversScriptsAsync() + { + // Arrange + CreateSkillWithScript(this._testRoot, "my-skill", "A test skill", "Body.", "scripts/convert.py", "print('hello')"); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + + // Act + var skills = await source.GetSkillsAsync(CancellationToken.None); + + // Assert + Assert.Single(skills); + var skill = skills[0]; + Assert.NotNull(skill.Scripts); + Assert.Single(skill.Scripts!); + Assert.Equal("scripts/convert.py", skill.Scripts![0].Name); + } + + [Fact] + public async Task GetSkillsAsync_WithMultipleScriptExtensions_DiscoversAllAsync() + { + // Arrange + string skillDir = CreateSkillDir(this._testRoot, "multi-ext-skill", "Multi-extension skill", "Body."); + CreateFile(skillDir, "scripts/run.py", "print('py')"); + CreateFile(skillDir, "scripts/run.sh", "echo 'sh'"); + CreateFile(skillDir, "scripts/run.js", "console.log('js')"); + CreateFile(skillDir, "scripts/run.ps1", "Write-Host 'ps'"); + CreateFile(skillDir, "scripts/run.cs", "Console.WriteLine();"); + CreateFile(skillDir, "scripts/run.csx", "Console.WriteLine();"); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + + // Act + var skills = await source.GetSkillsAsync(CancellationToken.None); + + // Assert + Assert.Single(skills); + var scriptNames = skills[0].Scripts!.Select(s => s.Name).OrderBy(n => n, StringComparer.Ordinal).ToList(); + Assert.Equal(6, scriptNames.Count); + Assert.Contains("scripts/run.cs", scriptNames); + Assert.Contains("scripts/run.csx", scriptNames); + Assert.Contains("scripts/run.js", scriptNames); + Assert.Contains("scripts/run.ps1", scriptNames); + Assert.Contains("scripts/run.py", scriptNames); + Assert.Contains("scripts/run.sh", scriptNames); + } + + [Fact] + public async Task GetSkillsAsync_NonScriptExtensionsAreNotDiscoveredAsync() + { + // Arrange + string skillDir = CreateSkillDir(this._testRoot, "no-script-skill", "Non-script skill", "Body."); + CreateFile(skillDir, "scripts/data.txt", "text data"); + CreateFile(skillDir, "scripts/config.json", "{}"); + CreateFile(skillDir, "scripts/notes.md", "# Notes"); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + + // Act + var skills = await source.GetSkillsAsync(CancellationToken.None); + + // Assert + Assert.Single(skills); + Assert.Empty(skills[0].Scripts!); + } + + [Fact] + public async Task GetSkillsAsync_NoScriptFiles_ReturnsEmptyScriptsAsync() + { + // Arrange + CreateSkillDir(this._testRoot, "no-scripts", "No scripts skill", "Body."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + + // Act + var skills = await source.GetSkillsAsync(CancellationToken.None); + + // Assert + Assert.Single(skills); + Assert.NotNull(skills[0].Scripts); + Assert.Empty(skills[0].Scripts!); + } + + [Fact] + public async Task GetSkillsAsync_ScriptsOutsideScriptsDir_AreAlsoDiscoveredAsync() + { + // Arrange — scripts at any depth in the skill directory are discovered + string skillDir = CreateSkillDir(this._testRoot, "root-scripts", "Root scripts skill", "Body."); + CreateFile(skillDir, "convert.py", "print('root')"); + CreateFile(skillDir, "tools/helper.sh", "echo 'helper'"); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + + // Act + var skills = await source.GetSkillsAsync(CancellationToken.None); + + // Assert + Assert.Single(skills); + var scriptNames = skills[0].Scripts!.Select(s => s.Name).OrderBy(n => n, StringComparer.Ordinal).ToList(); + Assert.Equal(2, scriptNames.Count); + Assert.Contains("convert.py", scriptNames); + Assert.Contains("tools/helper.sh", scriptNames); + } + + [Fact] + public async Task GetSkillsAsync_WithExecutor_ScriptsCanExecuteAsync() + { + // Arrange + CreateSkillWithScript(this._testRoot, "exec-skill", "Executor test", "Body.", "scripts/test.py", "print('ok')"); + var executorCalled = false; + var source = new AgentFileSkillsSource( + this._testRoot, + (skill, script, args, ct) => + { + executorCalled = true; + Assert.Equal("exec-skill", skill.Frontmatter.Name); + Assert.Equal("scripts/test.py", script.Name); + Assert.Equal(Path.GetFullPath(Path.Combine(this._testRoot, "exec-skill", "scripts", "test.py")), script.FullPath); + return Task.FromResult("executed"); + }); + + // Act + var skills = await source.GetSkillsAsync(CancellationToken.None); + var scriptResult = await skills[0].Scripts![0].ExecuteAsync(skills[0], new AIFunctionArguments(), CancellationToken.None); + + // Assert + Assert.True(executorCalled); + Assert.Equal("executed", scriptResult); + } + + [Fact] + public void Constructor_NullExecutor_ThrowsArgumentNullException() + { + Assert.Throws(() => new AgentFileSkillsSource(this._testRoot, null!)); + } + + [Fact] + public async Task GetSkillsAsync_CustomScriptExtensions_OnlyDiscoversMatchingAsync() + { + // Arrange + string skillDir = CreateSkillDir(this._testRoot, "custom-ext-skill", "Custom extensions", "Body."); + CreateFile(skillDir, "scripts/run.py", "print('py')"); + CreateFile(skillDir, "scripts/run.rb", "puts 'rb'"); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { AllowedScriptExtensions = s_rubyExtension }); + + // Act + var skills = await source.GetSkillsAsync(CancellationToken.None); + + // Assert + Assert.Single(skills); + Assert.Single(skills[0].Scripts!); + Assert.Equal("scripts/run.rb", skills[0].Scripts![0].Name); + } + + [Fact] + public async Task GetSkillsAsync_ExecutorReceivesArgumentsAsync() + { + // Arrange + CreateSkillWithScript(this._testRoot, "args-skill", "Args test", "Body.", "scripts/test.py", "print('ok')"); + AIFunctionArguments? capturedArgs = null; + var source = new AgentFileSkillsSource( + this._testRoot, + (skill, script, args, ct) => + { + capturedArgs = args; + return Task.FromResult("done"); + }); + + // Act + var skills = await source.GetSkillsAsync(CancellationToken.None); + var arguments = new AIFunctionArguments + { + ["value"] = 26.2, + ["factor"] = 1.60934 + }; + await skills[0].Scripts![0].ExecuteAsync(skills[0], arguments, CancellationToken.None); + + // Assert + Assert.NotNull(capturedArgs); + Assert.Equal(26.2, capturedArgs["value"]); + Assert.Equal(1.60934, capturedArgs["factor"]); + } + + private static string CreateSkillDir(string root, string name, string description, string body) + { + string skillDir = Path.Combine(root, name); + Directory.CreateDirectory(skillDir); + File.WriteAllText( + Path.Combine(skillDir, "SKILL.md"), + $"---\nname: {name}\ndescription: {description}\n---\n{body}"); + return skillDir; + } + + private static void CreateSkillWithScript(string root, string name, string description, string body, string scriptRelativePath, string scriptContent) + { + string skillDir = CreateSkillDir(root, name, description, body); + CreateFile(skillDir, scriptRelativePath, scriptContent); + } + + private static void CreateFile(string root, string relativePath, string content) + { + string fullPath = Path.Combine(root, relativePath.Replace('/', Path.DirectorySeparatorChar)); + Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); + File.WriteAllText(fullPath, content); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillFrontmatterValidatorTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillFrontmatterValidatorTests.cs new file mode 100644 index 0000000000..c0f8412655 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillFrontmatterValidatorTests.cs @@ -0,0 +1,260 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.Agents.AI.UnitTests.AgentSkills; + +/// +/// Unit tests for validation. +/// +public sealed class AgentSkillFrontmatterValidatorTests +{ + [Theory] + [InlineData("my-skill")] + [InlineData("a")] + [InlineData("skill123")] + [InlineData("a1b2c3")] + public void ValidateName_ValidName_ReturnsTrue(string name) + { + // Act + bool result = AgentSkillFrontmatter.ValidateName(name, out string? reason); + + // Assert + Assert.True(result); + Assert.Null(reason); + } + + [Theory] + [InlineData("-leading-hyphen")] + [InlineData("trailing-hyphen-")] + [InlineData("has spaces")] + [InlineData("UPPERCASE")] + [InlineData("consecutive--hyphens")] + [InlineData("special!chars")] + public void ValidateName_InvalidName_ReturnsFalse(string name) + { + // Act + bool result = AgentSkillFrontmatter.ValidateName(name, out string? reason); + + // Assert + Assert.False(result); + Assert.NotNull(reason); + Assert.Contains("name", reason, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ValidateName_NameExceedsMaxLength_ReturnsFalse() + { + // Arrange + string longName = new('a', 65); + + // Act + bool result = AgentSkillFrontmatter.ValidateName(longName, out string? reason); + + // Assert + Assert.False(result); + Assert.NotNull(reason); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void ValidateName_NullOrWhitespace_ReturnsFalse(string? name) + { + // Act + bool result = AgentSkillFrontmatter.ValidateName(name, out string? reason); + + // Assert + Assert.False(result); + Assert.NotNull(reason); + } + + [Fact] + public void ValidateDescription_ValidDescription_ReturnsTrue() + { + // Act + bool result = AgentSkillFrontmatter.ValidateDescription("A valid description.", out string? reason); + + // Assert + Assert.True(result); + Assert.Null(reason); + } + + [Fact] + public void ValidateDescription_DescriptionExceedsMaxLength_ReturnsFalse() + { + // Arrange + string longDesc = new('x', 1025); + + // Act + bool result = AgentSkillFrontmatter.ValidateDescription(longDesc, out string? reason); + + // Assert + Assert.False(result); + Assert.NotNull(reason); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void ValidateDescription_NullOrWhitespace_ReturnsFalse(string? description) + { + // Act + bool result = AgentSkillFrontmatter.ValidateDescription(description, out string? reason); + + // Assert + Assert.False(result); + Assert.NotNull(reason); + } + + [Fact] + public void ValidateCompatibility_Null_ReturnsTrue() + { + // Act + bool result = AgentSkillFrontmatter.ValidateCompatibility(null, out string? reason); + + // Assert + Assert.True(result); + Assert.Null(reason); + } + + [Fact] + public void ValidateCompatibility_WithinMaxLength_ReturnsTrue() + { + // Arrange + string compatibility = new('x', 500); + + // Act + bool result = AgentSkillFrontmatter.ValidateCompatibility(compatibility, out string? reason); + + // Assert + Assert.True(result); + Assert.Null(reason); + } + + [Fact] + public void ValidateCompatibility_ExceedsMaxLength_ReturnsFalse() + { + // Arrange + string compatibility = new('x', 501); + + // Act + bool result = AgentSkillFrontmatter.ValidateCompatibility(compatibility, out string? reason); + + // Assert + Assert.False(result); + Assert.NotNull(reason); + } + + [Theory] + [InlineData("UPPERCASE")] + [InlineData("-leading")] + [InlineData("trailing-")] + [InlineData("consecutive--hyphens")] + public void Constructor_InvalidName_ThrowsArgumentException(string name) + { + // Act & Assert + var ex = Assert.Throws(() => new AgentSkillFrontmatter(name, "A valid description.")); + Assert.Contains("name", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Constructor_NameExceedsMaxLength_ThrowsArgumentException() + { + // Arrange + string longName = new('a', 65); + + // Act & Assert + Assert.Throws(() => new AgentSkillFrontmatter(longName, "A valid description.")); + } + + [Fact] + public void Constructor_DescriptionExceedsMaxLength_ThrowsArgumentException() + { + // Arrange + string longDesc = new('x', 1025); + + // Act & Assert + Assert.Throws(() => new AgentSkillFrontmatter("valid-name", longDesc)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Constructor_NullOrWhitespaceName_ThrowsArgumentException(string? name) + { + // Act & Assert + Assert.Throws(() => new AgentSkillFrontmatter(name!, "A valid description.")); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Constructor_NullOrWhitespaceDescription_ThrowsArgumentException(string? description) + { + // Act & Assert + Assert.Throws(() => new AgentSkillFrontmatter("valid-name", description!)); + } + + [Fact] + public void Compatibility_ExceedsMaxLength_ThrowsArgumentException() + { + // Arrange + var frontmatter = new AgentSkillFrontmatter("valid-name", "A valid description."); + string longCompatibility = new('x', 501); + + // Act & Assert + Assert.Throws(() => frontmatter.Compatibility = longCompatibility); + } + + [Fact] + public void Compatibility_WithinMaxLength_Succeeds() + { + // Arrange + var frontmatter = new AgentSkillFrontmatter("valid-name", "A valid description."); + string compatibility = new('x', 500); + + // Act + frontmatter.Compatibility = compatibility; + + // Assert + Assert.Equal(compatibility, frontmatter.Compatibility); + } + + [Fact] + public void Compatibility_Null_Succeeds() + { + // Arrange + var frontmatter = new AgentSkillFrontmatter("valid-name", "A valid description."); + + // Act + frontmatter.Compatibility = null; + + // Assert + Assert.Null(frontmatter.Compatibility); + } + + [Fact] + public void Constructor_WithCompatibility_SetsValue() + { + // Arrange & Act + var frontmatter = new AgentSkillFrontmatter("valid-name", "A valid description.", "Requires Python 3.10+"); + + // Assert + Assert.Equal("Requires Python 3.10+", frontmatter.Compatibility); + } + + [Fact] + public void Constructor_CompatibilityExceedsMaxLength_ThrowsArgumentException() + { + // Arrange + string longCompatibility = new('x', 501); + + // Act & Assert + Assert.Throws(() => new AgentSkillFrontmatter("valid-name", "A valid description.", longCompatibility)); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderBuilderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderBuilderTests.cs new file mode 100644 index 0000000000..578adc9cb2 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderBuilderTests.cs @@ -0,0 +1,228 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.UnitTests.AgentSkills; + +/// +/// Unit tests for . +/// +public sealed class AgentSkillsProviderBuilderTests +{ + private readonly TestAIAgent _agent = new(); + + private AIContextProvider.InvokingContext CreateInvokingContext() + { + return new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + } + + [Fact] + public void Build_NoSourceConfigured_ThrowsInvalidOperationException() + { + // Arrange + var builder = new AgentSkillsProviderBuilder(); + + // Act & Assert + Assert.Throws(() => builder.Build()); + } + + [Fact] + public void Build_WithCustomSource_Succeeds() + { + // Arrange + var source = new TestAgentSkillsSource( + new TestAgentSkill("custom", "Custom skill", "Instructions.")); + var builder = new AgentSkillsProviderBuilder() + .UseSource(source); + + // Act + var provider = builder.Build(); + + // Assert + Assert.NotNull(provider); + } + + [Fact] + public void UseSource_NullSource_ThrowsArgumentNullException() + { + // Arrange + var builder = new AgentSkillsProviderBuilder(); + + // Act & Assert + Assert.Throws(() => builder.UseSource(null!)); + } + + [Fact] + public void UseFilter_NullPredicate_ThrowsArgumentNullException() + { + // Arrange + var builder = new AgentSkillsProviderBuilder(); + + // Act & Assert + Assert.Throws(() => builder.UseFilter(null!)); + } + + [Fact] + public void UseFileScriptExecutor_NullExecutor_ThrowsArgumentNullException() + { + // Arrange + var builder = new AgentSkillsProviderBuilder(); + + // Act & Assert + Assert.Throws(() => builder.UseFileScriptExecutor(null!)); + } + + [Fact] + public void UseOptions_NullConfigure_ThrowsArgumentNullException() + { + // Arrange + var builder = new AgentSkillsProviderBuilder(); + + // Act & Assert + Assert.Throws(() => builder.UseOptions(null!)); + } + + [Fact] + public async Task Build_WithFilter_AppliesFilterToSkillsAsync() + { + // Arrange + var source = new TestAgentSkillsSource( + new TestAgentSkill("keep-me", "Keep", "Instructions."), + new TestAgentSkill("drop-me", "Drop", "Instructions.")); + var provider = new AgentSkillsProviderBuilder() + .UseSource(source) + .UseFilter(skill => skill.Frontmatter.Name.StartsWith("keep", StringComparison.OrdinalIgnoreCase)) + .Build(); + + // Act + var result = await provider.InvokingAsync( + this.CreateInvokingContext(), CancellationToken.None); + + // Assert — the instructions should mention "keep-me" but not "drop-me" + Assert.NotNull(result.Instructions); + Assert.Contains("keep-me", result.Instructions); + Assert.DoesNotContain("drop-me", result.Instructions); + } + + [Fact] + public async Task Build_WithCacheDisabled_ReloadsOnEachCallAsync() + { + // Arrange + var countingSource = new CountingSource( + new TestAgentSkill("skill-a", "A", "Instructions.")); + var provider = new AgentSkillsProviderBuilder() + .UseSource(countingSource) + .UseCache(false) + .Build(); + + // Act + await provider.InvokingAsync(this.CreateInvokingContext(), CancellationToken.None); + await provider.InvokingAsync(this.CreateInvokingContext(), CancellationToken.None); + + // Assert — inner source should be called each time (dedup still calls through) + Assert.True(countingSource.CallCount >= 2); + } + + [Fact] + public async Task Build_WithCacheEnabled_CachesSkillsAsync() + { + // Arrange + var countingSource = new CountingSource( + new TestAgentSkill("skill-a", "A", "Instructions.")); + var provider = new AgentSkillsProviderBuilder() + .UseSource(countingSource) + .UseCache(true) + .Build(); + + // Act + await provider.InvokingAsync(this.CreateInvokingContext(), CancellationToken.None); + await provider.InvokingAsync(this.CreateInvokingContext(), CancellationToken.None); + + // Assert — inner source should only be called once due to caching + Assert.Equal(1, countingSource.CallCount); + } + + [Fact] + public void Build_FluentChaining_ReturnsSameBuilder() + { + // Arrange + var builder = new AgentSkillsProviderBuilder(); + var source = new TestAgentSkillsSource( + new TestAgentSkill("test", "Test", "Instructions.")); + + // Act — all fluent methods should return the same builder + var result = builder + .UseSource(source) + .UseCache(true) + .UseScriptApproval(false) + .UsePromptTemplate("Skills:\n{skills}\n{runner_instructions}"); + + // Assert + Assert.Same(builder, result); + } + + [Fact] + public void Build_UseOptions_ConfiguresOptions() + { + // Arrange + var source = new TestAgentSkillsSource( + new TestAgentSkill("test", "Test", "Instructions.")); + + // Act — UseOptions should not throw and successfully configure + var provider = new AgentSkillsProviderBuilder() + .UseSource(source) + .UseOptions(opts => opts.ScriptApproval = true) + .Build(); + + // Assert + Assert.NotNull(provider); + } + + [Fact] + public async Task Build_WithMultipleCustomSources_AggregatesAllAsync() + { + // Arrange + var source1 = new TestAgentSkillsSource( + new TestAgentSkill("from-one", "Source 1", "Instructions 1.")); + var source2 = new TestAgentSkillsSource( + new TestAgentSkill("from-two", "Source 2", "Instructions 2.")); + var provider = new AgentSkillsProviderBuilder() + .UseSource(source1) + .UseSource(source2) + .Build(); + + // Act + var result = await provider.InvokingAsync( + this.CreateInvokingContext(), CancellationToken.None); + + // Assert + Assert.NotNull(result.Instructions); + Assert.Contains("from-one", result.Instructions); + Assert.Contains("from-two", result.Instructions); + } + + /// + /// A test source that counts how many times GetSkillsAsync is called. + /// + private sealed class CountingSource : AgentSkillsSource + { + private readonly AgentSkill[] _skills; + private int _callCount; + + public CountingSource(params AgentSkill[] skills) + { + this._skills = skills; + } + + public int CallCount => this._callCount; + + public override Task> GetSkillsAsync(CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref this._callCount); + return Task.FromResult>(this._skills); + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderTests.cs new file mode 100644 index 0000000000..bcc8028291 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderTests.cs @@ -0,0 +1,701 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.UnitTests.AgentSkills; + +/// +/// Unit tests for the class with . +/// +public sealed class AgentSkillsProviderTests : IDisposable +{ + private static readonly AgentFileSkillScriptExecutor s_noOpExecutor = (skill, script, args, ct) => Task.FromResult(null); + private readonly string _testRoot; + private readonly TestAIAgent _agent = new(); + + public AgentSkillsProviderTests() + { + this._testRoot = Path.Combine(Path.GetTempPath(), "skills-provider-tests-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(this._testRoot); + } + + public void Dispose() + { + if (Directory.Exists(this._testRoot)) + { + Directory.Delete(this._testRoot, recursive: true); + } + } + + [Fact] + public async Task InvokingCoreAsync_NoSkills_ReturnsInputContextUnchangedAsync() + { + // Arrange + var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor)); + var inputContext = new AIContext { Instructions = "Original instructions" }; + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert + Assert.Equal("Original instructions", result.Instructions); + Assert.Null(result.Tools); + } + + [Fact] + public async Task InvokingCoreAsync_WithSkills_AppendsInstructionsAndToolsAsync() + { + // Arrange + this.CreateSkill("provider-skill", "Provider skill test", "Skill instructions body."); + var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor)); + var inputContext = new AIContext { Instructions = "Base instructions" }; + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert + Assert.NotNull(result.Instructions); + Assert.Contains("Base instructions", result.Instructions); + Assert.Contains("provider-skill", result.Instructions); + Assert.Contains("Provider skill test", result.Instructions); + + // Should have load_skill 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 AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor)); + var inputContext = new AIContext(); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert + Assert.NotNull(result.Instructions); + Assert.Contains("null-instr-skill", result.Instructions); + } + + [Fact] + public async Task InvokingCoreAsync_CustomPromptTemplate_UsesCustomTemplateAsync() + { + // Arrange + this.CreateSkill("custom-prompt-skill", "Custom prompt", "Body."); + var options = new AgentSkillsProviderOptions + { + SkillsInstructionPrompt = "Custom template: {skills}\n{runner_instructions}" + }; + var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor), options); + var inputContext = new AIContext(); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert + Assert.NotNull(result.Instructions); + Assert.StartsWith("Custom template:", result.Instructions); + Assert.Contains("custom-prompt-skill", result.Instructions); + Assert.Contains("Custom prompt", result.Instructions); + } + + [Fact] + public void Constructor_PromptWithoutSkillsPlaceholder_ThrowsArgumentException() + { + // Arrange + var options = new AgentSkillsProviderOptions + { + SkillsInstructionPrompt = "No skills placeholder here {runner_instructions}" + }; + + // Act & Assert + var ex = Assert.Throws(() => + new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor), options)); + Assert.Contains("{skills}", ex.Message); + Assert.Equal("options", ex.ParamName); + } + + [Fact] + public void Constructor_PromptWithoutRunnerInstructionsPlaceholder_ThrowsArgumentException() + { + // Arrange + var options = new AgentSkillsProviderOptions + { + SkillsInstructionPrompt = "Has skills {skills} but no runner instructions" + }; + + // Act & Assert + var ex = Assert.Throws(() => + new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor), options)); + Assert.Contains("{runner_instructions}", ex.Message); + Assert.Equal("options", ex.ParamName); + } + + [Fact] + public void Constructor_PromptWithBothPlaceholders_Succeeds() + { + // Arrange + var options = new AgentSkillsProviderOptions + { + SkillsInstructionPrompt = "Skills: {skills}\nRunner: {runner_instructions}" + }; + + // Act — should not throw + var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor), options); + + // Assert + Assert.NotNull(provider); + } + + [Fact] + public async Task InvokingCoreAsync_SkillNamesAreXmlEscapedAsync() + { + // Arrange — description with XML-sensitive characters + string skillDir = Path.Combine(this._testRoot, "xml-skill"); + Directory.CreateDirectory(skillDir); + File.WriteAllText( + Path.Combine(skillDir, "SKILL.md"), + "---\nname: xml-skill\ndescription: Uses & \"quotes\"\n---\nBody."); + var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor)); + var inputContext = new AIContext(); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert + Assert.NotNull(result.Instructions); + Assert.Contains("<tags>", result.Instructions); + Assert.Contains("&", result.Instructions); + } + + [Fact] + public async Task Constructor_WithMultiplePaths_LoadsFromAllAsync() + { + // Arrange + string dir1 = Path.Combine(this._testRoot, "dir1"); + string dir2 = Path.Combine(this._testRoot, "dir2"); + CreateSkillIn(dir1, "skill-a", "Skill A", "Body A."); + CreateSkillIn(dir2, "skill-b", "Skill B", "Body B."); + + // Act + var provider = new AgentSkillsProvider(new AgentFileSkillsSource(new[] { dir1, dir2 }, s_noOpExecutor)); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + + // Assert + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + Assert.NotNull(result.Instructions); + Assert.Contains("skill-a", result.Instructions); + Assert.Contains("skill-b", result.Instructions); + } + + [Fact] + public async Task InvokingCoreAsync_PreservesExistingInputToolsAsync() + { + // Arrange + this.CreateSkill("tools-skill", "Tools test", "Body."); + var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor)); + + var existingTool = AIFunctionFactory.Create(() => "test", name: "existing_tool", description: "An existing tool."); + var inputContext = new AIContext { Tools = new[] { existingTool } }; + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert — existing tool should be preserved alongside the new skill tools + Assert.NotNull(result.Tools); + var toolNames = result.Tools!.Select(t => t.Name).ToList(); + Assert.Contains("existing_tool", toolNames); + Assert.Contains("load_skill", toolNames); + 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 AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor)); + var inputContext = new AIContext(); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert — skills should appear in alphabetical order in the prompt + Assert.NotNull(result.Instructions); + int alphaIndex = result.Instructions!.IndexOf("alpha-skill", StringComparison.Ordinal); + int mikeIndex = result.Instructions.IndexOf("mike-skill", StringComparison.Ordinal); + int zuluIndex = result.Instructions.IndexOf("zulu-skill", StringComparison.Ordinal); + Assert.True(alphaIndex < mikeIndex, "alpha-skill should appear before mike-skill"); + Assert.True(mikeIndex < zuluIndex, "mike-skill should appear before zulu-skill"); + } + + [Fact] + public async Task ProvideAIContextAsync_ConcurrentCalls_LoadsSkillsOnlyOnceAsync() + { + // Arrange + var source = new CountingAgentSkillsSource( + [ + new TestAgentSkill("concurrent-skill", "Concurrent test", "Body.") + ]); + var cachingSource = new CachingAgentSkillsSource(source); + var provider = new AgentSkillsProvider(cachingSource); + + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + + // Act — invoke concurrently from multiple threads + var tasks = Enumerable.Range(0, 10) + .Select(_ => provider.InvokingAsync(invokingContext, CancellationToken.None).AsTask()) + .ToArray(); + await Task.WhenAll(tasks); + + // Assert — GetSkillsAsync should have been called exactly once + Assert.Equal(1, source.GetSkillsCallCount); + } + + [Fact] + public async Task InvokingCoreAsync_WithScripts_IncludesRunSkillScriptToolAsync() + { + // Arrange + string skillDir = Path.Combine(this._testRoot, "script-skill"); + Directory.CreateDirectory(Path.Combine(skillDir, "scripts")); + File.WriteAllText( + Path.Combine(skillDir, "SKILL.md"), + "---\nname: script-skill\ndescription: Skill with scripts\n---\nBody."); + File.WriteAllText( + Path.Combine(skillDir, "scripts", "test.py"), + "print('hello')"); + + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + var provider = new AgentSkillsProvider(source); + var inputContext = new AIContext(); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert + Assert.NotNull(result.Tools); + var toolNames = result.Tools!.Select(t => t.Name).ToList(); + Assert.Contains("run_skill_script", toolNames); + Assert.Contains("load_skill", toolNames); + Assert.Contains("read_skill_resource", toolNames); + } + + [Fact] + public async Task InvokingCoreAsync_WithoutScripts_NoRunSkillScriptToolAsync() + { + // Arrange + this.CreateSkill("no-script-skill", "No scripts", "Body."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + var provider = new AgentSkillsProvider(source); + var inputContext = new AIContext(); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert + Assert.NotNull(result.Tools); + var toolNames = result.Tools!.Select(t => t.Name).ToList(); + Assert.DoesNotContain("run_skill_script", toolNames); + } + + [Fact] + public void Build_WithFileSkillsButNoExecutor_ThrowsInvalidOperationException() + { + // Arrange + var builder = new AgentSkillsProviderBuilder() + .UseFileSkill(this._testRoot); + + // Act & Assert + Assert.Throws(() => builder.Build()); + } + + [Fact] + public async Task Builder_UseFileSkillWithOptions_DiscoverSkillsAsync() + { + // Arrange + this.CreateSkill("opts-skill", "Options skill", "Options body."); + var options = new AgentFileSkillsSourceOptions(); + var provider = new AgentSkillsProviderBuilder() + .UseFileSkill(this._testRoot, options) + .UseFileScriptExecutor(s_noOpExecutor) + .UseCache(false) + .Build(); + + // Act + var inputContext = new AIContext(); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert + Assert.NotNull(result.Instructions); + Assert.Contains("opts-skill", result.Instructions); + } + + [Fact] + public async Task Builder_UseFileSkillsWithOptions_DiscoverMultipleSkillsAsync() + { + // Arrange + string dir1 = Path.Combine(this._testRoot, "multi-opts-1"); + string dir2 = Path.Combine(this._testRoot, "multi-opts-2"); + CreateSkillIn(dir1, "skill-x", "Skill X", "Body X."); + CreateSkillIn(dir2, "skill-y", "Skill Y", "Body Y."); + + var options = new AgentFileSkillsSourceOptions(); + var provider = new AgentSkillsProviderBuilder() + .UseFileSkills(new[] { dir1, dir2 }, options) + .UseFileScriptExecutor(s_noOpExecutor) + .UseCache(false) + .Build(); + + // Act + var inputContext = new AIContext(); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert + Assert.NotNull(result.Instructions); + Assert.Contains("skill-x", result.Instructions); + Assert.Contains("skill-y", result.Instructions); + } + + [Fact] + public async Task Builder_UseFileSkillWithOptionsResourceFilter_FiltersResourcesAsync() + { + // Arrange — create a skill with both .md and .json resources + string skillDir = Path.Combine(this._testRoot, "res-filter-opts"); + CreateSkillIn(skillDir, "filter-skill", "Filter test", "Filter body."); + File.WriteAllText(Path.Combine(skillDir, "data.json"), "{}", System.Text.Encoding.UTF8); + File.WriteAllText(Path.Combine(skillDir, "notes.txt"), "notes", System.Text.Encoding.UTF8); + + // Only allow .json resources + var options = new AgentFileSkillsSourceOptions + { + AllowedResourceExtensions = [".json"], + }; + var source = new AgentFileSkillsSource(skillDir, s_noOpExecutor, options); + + // Act + var skills = await source.GetSkillsAsync(); + + // Assert + Assert.Single(skills); + var fileSkill = Assert.IsType(skills[0]); + Assert.All(fileSkill.Resources, r => Assert.EndsWith(".json", r.Name)); + } + + private void CreateSkill(string name, string description, string body) + { + CreateSkillIn(this._testRoot, name, description, body); + } + + [Fact] + public async Task LoadSkill_DefaultOptions_ReturnsFullContentAsync() + { + // Arrange + this.CreateSkill("content-skill", "Content test", "Skill body."); + var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor)); + var inputContext = new AIContext(); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + var loadSkillTool = result.Tools!.First(t => t.Name == "load_skill") as AIFunction; + + // Act + var content = await loadSkillTool!.InvokeAsync(new AIFunctionArguments(new Dictionary { ["skillName"] = "content-skill" })); + + // Assert — should contain frontmatter and body + var text = content!.ToString()!; + Assert.Contains("---", text); + Assert.Contains("name: content-skill", text); + Assert.Contains("Skill body.", text); + } + + [Fact] + public async Task Builder_UseFileScriptExecutorAfterUseFileSkills_ExecutorIsUsedAsync() + { + // Arrange — create a skill with a script file + string skillDir = Path.Combine(this._testRoot, "builder-skill"); + Directory.CreateDirectory(Path.Combine(skillDir, "scripts")); + File.WriteAllText( + Path.Combine(skillDir, "SKILL.md"), + "---\nname: builder-skill\ndescription: Builder test\n---\nBody."); + File.WriteAllText( + Path.Combine(skillDir, "scripts", "run.py"), + "print('ok')"); + + var executorCalled = false; + + // Act — call UseFileScriptExecutor AFTER UseFileSkill (the bug scenario) + var provider = new AgentSkillsProviderBuilder() + .UseFileSkill(this._testRoot) + .UseFileScriptExecutor((skill, script, args, ct) => + { + executorCalled = true; + return Task.FromResult("executed"); + }) + .Build(); + + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert — run_skill_script tool should be present and executor should work + Assert.NotNull(result.Tools); + var toolNames = result.Tools!.Select(t => t.Name).ToList(); + Assert.Contains("run_skill_script", toolNames); + + var runScriptTool = result.Tools!.First(t => t.Name == "run_skill_script") as AIFunction; + await runScriptTool!.InvokeAsync(new AIFunctionArguments(new Dictionary + { + ["skillName"] = "builder-skill", + ["scriptName"] = "scripts/run.py", + })); + + Assert.True(executorCalled); + } + + private static void CreateSkillIn(string root, string name, string description, string body) + { + string skillDir = Path.Combine(root, name); + Directory.CreateDirectory(skillDir); + File.WriteAllText( + Path.Combine(skillDir, "SKILL.md"), + $"---\nname: {name}\ndescription: {description}\n---\n{body}"); + } + + [Fact] + public async Task Build_WithCachingDisabled_ReloadsSkillsOnEachCallAsync() + { + // Arrange + var source = new CountingAgentSkillsSource( + [ + new TestAgentSkill("no-cache-skill", "No cache test", "Body.") + ]); + var provider = new AgentSkillsProviderBuilder() + .UseSource(source) + .UseCache(false) + .Build(); + + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + + // Act + await provider.InvokingAsync(invokingContext, CancellationToken.None); + await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert — source should be called more than once since caching is disabled + Assert.True(source.GetSkillsCallCount > 1); + } + + [Fact] + public async Task Build_WithCachingEnabled_CachesSkillsAsync() + { + // Arrange + var source = new CountingAgentSkillsSource( + [ + new TestAgentSkill("cached-skill", "Cached test", "Body.") + ]); + var provider = new AgentSkillsProviderBuilder() + .UseSource(source) + .UseCache(true) + .Build(); + + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + + // Act + await provider.InvokingAsync(invokingContext, CancellationToken.None); + await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert — source should be called exactly once + Assert.Equal(1, source.GetSkillsCallCount); + } + + [Fact] + public async Task Build_DefaultOptions_CachesSkillsAsync() + { + // Arrange + var source = new CountingAgentSkillsSource( + [ + new TestAgentSkill("default-skill", "Default test", "Body.") + ]); + var provider = new AgentSkillsProviderBuilder() + .UseSource(source) + .Build(); + + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + + // Act + await provider.InvokingAsync(invokingContext, CancellationToken.None); + await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert — default behavior caches + Assert.Equal(1, source.GetSkillsCallCount); + } + + [Fact] + public async Task Build_PreservesSourceRegistrationOrderAsync() + { + // Arrange — register file skills + string dir1 = Path.Combine(this._testRoot, "dir1"); + string dir2 = Path.Combine(this._testRoot, "dir2"); + CreateSkillIn(dir1, "file-skill-1", "First file skill", "Body 1."); + CreateSkillIn(dir2, "file-skill-2", "Second file skill", "Body 2."); + + var provider = new AgentSkillsProviderBuilder() + .UseFileSkills([dir1, dir2]) + .UseFileScriptExecutor(s_noOpExecutor) + .UseCache(false) + .Build(); + + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert — both skills should be present in alphabetical order in the prompt + Assert.NotNull(result.Instructions); + Assert.Contains("file-skill-1", result.Instructions); + Assert.Contains("file-skill-2", result.Instructions); + } + + [Fact] + public async Task Build_WithCustomSource_AllSkillsDiscoveredAsync() + { + // Arrange — use a custom source with multiple skills + var customSource = new CountingAgentSkillsSource( + [ + new TestAgentSkill("custom-skill", "Custom source skill", "Body custom."), + new TestAgentSkill("another-skill", "Another skill", "Body another."), + ]); + + var provider = new AgentSkillsProviderBuilder() + .UseSource(customSource) + .UseCache(false) + .Build(); + + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert — all skills from the source are present + Assert.NotNull(result.Instructions); + Assert.Contains("custom-skill", result.Instructions); + Assert.Contains("another-skill", result.Instructions); + } + + [Fact] + public async Task InvokingCoreAsync_WithScriptsAndScriptApproval_WrapsRunScriptToolAsync() + { + // Arrange — create a skill with a script and enable ScriptApproval + string skillDir = Path.Combine(this._testRoot, "approval-skill"); + Directory.CreateDirectory(Path.Combine(skillDir, "scripts")); + File.WriteAllText( + Path.Combine(skillDir, "SKILL.md"), + "---\nname: approval-skill\ndescription: Approval test\n---\nBody."); + File.WriteAllText( + Path.Combine(skillDir, "scripts", "run.py"), + "print('hello')"); + + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + var options = new AgentSkillsProviderOptions { ScriptApproval = true }; + var provider = new AgentSkillsProvider(source, options); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert — run_skill_script tool should be wrapped in ApprovalRequiredAIFunction + Assert.NotNull(result.Tools); + var scriptTool = result.Tools!.FirstOrDefault(t => t.Name == "run_skill_script"); + Assert.NotNull(scriptTool); + Assert.IsType(scriptTool); + } + + [Fact] + public async Task InvokingCoreAsync_WithScriptsNoScriptApproval_DoesNotWrapRunScriptToolAsync() + { + // Arrange — create a skill with a script, default options (no approval) + string skillDir = Path.Combine(this._testRoot, "no-approval-skill"); + Directory.CreateDirectory(Path.Combine(skillDir, "scripts")); + File.WriteAllText( + Path.Combine(skillDir, "SKILL.md"), + "---\nname: no-approval-skill\ndescription: No approval test\n---\nBody."); + File.WriteAllText( + Path.Combine(skillDir, "scripts", "run.py"), + "print('hello')"); + + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + var provider = new AgentSkillsProvider(source); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert — run_skill_script tool should NOT be wrapped + Assert.NotNull(result.Tools); + var scriptTool = result.Tools!.FirstOrDefault(t => t.Name == "run_skill_script"); + Assert.NotNull(scriptTool); + Assert.IsNotType(scriptTool); + } + + [Fact] + public async Task InvokingCoreAsync_MultipleInvocations_ToolsAreNotSharedAsync() + { + // Arrange — verify tools are built fresh per invocation (statelessness) + this.CreateSkill("fresh-tools-skill", "Fresh tools test", "Body."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + var provider = new AgentSkillsProvider(source); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + + // Act + var result1 = await provider.InvokingAsync(invokingContext, CancellationToken.None); + var result2 = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert — tool lists should not be the same reference + Assert.NotNull(result1.Tools); + Assert.NotNull(result2.Tools); + Assert.NotSame(result1.Tools, result2.Tools); + } + + /// + /// A test skill source that counts how many times is called. + /// + private sealed class CountingAgentSkillsSource : AgentSkillsSource + { + private readonly IList _skills; + private int _callCount; + + public CountingAgentSkillsSource(IList skills) + { + this._skills = skills; + } + + public int GetSkillsCallCount => this._callCount; + + public override Task> GetSkillsAsync(CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref this._callCount); + return Task.FromResult(this._skills); + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AggregateAgentSkillsSourceTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AggregateAgentSkillsSourceTests.cs new file mode 100644 index 0000000000..1510c591e2 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AggregateAgentSkillsSourceTests.cs @@ -0,0 +1,134 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.UnitTests.AgentSkills; + +/// +/// Unit tests for . +/// +public sealed class AggregateAgentSkillsSourceTests +{ + [Fact] + public async Task GetSkillsAsync_MultipleSources_AggregatesInRegistrationOrderAsyncAsync() + { + // Arrange + var source1 = new TestAgentSkillsSource( + new TestAgentSkill("alpha", "Alpha", "Instructions A.")); + var source2 = new TestAgentSkillsSource( + new TestAgentSkill("beta", "Beta", "Instructions B."), + new TestAgentSkill("gamma", "Gamma", "Instructions C.")); + var aggregate = new AggregateAgentSkillsSource([source1, source2]); + + // Act + var result = await aggregate.GetSkillsAsync(CancellationToken.None); + + // Assert + Assert.Equal(3, result.Count); + Assert.Equal("alpha", result[0].Frontmatter.Name); + Assert.Equal("beta", result[1].Frontmatter.Name); + Assert.Equal("gamma", result[2].Frontmatter.Name); + } + + [Fact] + public async Task GetSkillsAsync_SingleSource_ReturnsItsSkillsAsyncAsync() + { + // Arrange + var inner = new TestAgentSkillsSource( + new TestAgentSkill("only", "Only skill", "Instructions.")); + var aggregate = new AggregateAgentSkillsSource([inner]); + + // Act + var result = await aggregate.GetSkillsAsync(CancellationToken.None); + + // Assert + Assert.Single(result); + Assert.Equal("only", result[0].Frontmatter.Name); + } + + [Fact] + public async Task GetSkillsAsync_AllSourcesEmpty_ReturnsEmptyListAsyncAsync() + { + // Arrange + var source1 = new TestAgentSkillsSource(Array.Empty()); + var source2 = new TestAgentSkillsSource(Array.Empty()); + var aggregate = new AggregateAgentSkillsSource([source1, source2]); + + // Act + var result = await aggregate.GetSkillsAsync(CancellationToken.None); + + // Assert + Assert.Empty(result); + } + + [Fact] + public async Task GetSkillsAsync_MixedEmptyAndNonEmptySources_ReturnsNonEmptySkillsAsyncAsync() + { + // Arrange + var empty = new TestAgentSkillsSource(Array.Empty()); + var populated = new TestAgentSkillsSource( + new TestAgentSkill("present", "Present", "Instructions.")); + var aggregate = new AggregateAgentSkillsSource([empty, populated, empty]); + + // Act + var result = await aggregate.GetSkillsAsync(CancellationToken.None); + + // Assert + Assert.Single(result); + Assert.Equal("present", result[0].Frontmatter.Name); + } + + [Fact] + public async Task GetSkillsAsync_DoesNotDeduplicateAsyncAsync() + { + // Arrange + var source1 = new TestAgentSkillsSource( + new TestAgentSkill("shared", "From source 1", "Instructions 1.")); + var source2 = new TestAgentSkillsSource( + new TestAgentSkill("shared", "From source 2", "Instructions 2.")); + var aggregate = new AggregateAgentSkillsSource([source1, source2]); + + // Act + var result = await aggregate.GetSkillsAsync(CancellationToken.None); + + // Assert — duplicates are preserved (dedup is a separate decorator) + Assert.Equal(2, result.Count); + Assert.All(result, s => Assert.Equal("shared", s.Frontmatter.Name)); + } + + [Fact] + public async Task GetSkillsAsync_CancellationTokenIsPropagatedAsyncAsync() + { + // Arrange + using var cts = new CancellationTokenSource(); + cts.Cancel(); + var source = new CancellationAwareSource(); + var aggregate = new AggregateAgentSkillsSource([source]); + + // Act & Assert + await Assert.ThrowsAsync( + () => aggregate.GetSkillsAsync(cts.Token)); + } + + [Fact] + public void Constructor_NullSources_ThrowsArgumentNullException() + { + // Act & Assert + Assert.Throws(() => new AggregateAgentSkillsSource(null!)); + } + + /// + /// A source that throws when the token is cancelled. + /// + private sealed class CancellationAwareSource : AgentSkillsSource + { + public override Task> GetSkillsAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult>(new List()); + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/CachingAgentSkillsSourceTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/CachingAgentSkillsSourceTests.cs new file mode 100644 index 0000000000..b1d36abc76 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/CachingAgentSkillsSourceTests.cs @@ -0,0 +1,153 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.UnitTests.AgentSkills; + +/// +/// Unit tests for . +/// +public sealed class CachingAgentSkillsSourceTests +{ + [Fact] + public async Task GetSkillsAsync_ReturnsCachedResultAsync() + { + // Arrange + var inner = new CountingSource(new TestAgentSkill("cached", "Cached skill", "Instructions.")); + var source = new CachingAgentSkillsSource(inner); + + // Act + var result1 = await source.GetSkillsAsync(CancellationToken.None); + var result2 = await source.GetSkillsAsync(CancellationToken.None); + + // Assert + Assert.Equal(1, inner.CallCount); + Assert.Same(result1, result2); + } + + [Fact] + public async Task GetSkillsAsync_ConcurrentCalls_LoadsOnlyOnceAsync() + { + // Arrange + var inner = new CountingSource(new TestAgentSkill("concurrent", "Concurrent", "Instructions.")); + var source = new CachingAgentSkillsSource(inner); + + // Act + var tasks = Enumerable.Range(0, 10) + .Select(_ => source.GetSkillsAsync(CancellationToken.None)) + .ToArray(); + await Task.WhenAll(tasks); + + // Assert + Assert.Equal(1, inner.CallCount); + } + + [Fact] + public async Task GetSkillsAsync_EmptySource_CachesEmptyResultAsync() + { + // Arrange + var inner = new CountingSource(); + var source = new CachingAgentSkillsSource(inner); + + // Act + var result1 = await source.GetSkillsAsync(CancellationToken.None); + _ = await source.GetSkillsAsync(CancellationToken.None); + + // Assert + Assert.Empty(result1); + Assert.Equal(1, inner.CallCount); + } + + [Fact] + public async Task GetSkillsAsync_CancellationTokenForwardedToInnerSourceAsyncAsync() + { + // Arrange + using var cts = new CancellationTokenSource(); + cts.Cancel(); + var inner = new CancellationAwareSource(); + var source = new CachingAgentSkillsSource(inner); + + // Act & Assert — the token should be forwarded to the inner source + await Assert.ThrowsAsync( + () => source.GetSkillsAsync(cts.Token)); + } + + [Fact] + public async Task GetSkillsAsync_SeparateInstances_CacheIndependentlyAsyncAsync() + { + // Arrange — use a source that returns a new list each time + var callCount = 0; + var freshSource = new DelegatingTestSource(() => + { + Interlocked.Increment(ref callCount); + return Task.FromResult>( + new List { new TestAgentSkill("shared", "Shared", "Instructions.") }); + }); + var source1 = new CachingAgentSkillsSource(freshSource); + var source2 = new CachingAgentSkillsSource(freshSource); + + // Act + var result1 = await source1.GetSkillsAsync(CancellationToken.None); + var result2 = await source2.GetSkillsAsync(CancellationToken.None); + + // Assert — each instance caches independently, so inner is called twice + Assert.Equal(2, callCount); + Assert.NotSame(result1, result2); + } + + /// + /// A test source that counts calls. + /// + private sealed class CountingSource : AgentSkillsSource + { + private readonly IList _skills; + private int _callCount; + + public CountingSource(params AgentSkill[] skills) + { + this._skills = skills; + } + + public int CallCount => this._callCount; + + public override Task> GetSkillsAsync(CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref this._callCount); + return Task.FromResult(this._skills); + } + } + + /// + /// A source that throws when the token is cancelled. + /// + private sealed class CancellationAwareSource : AgentSkillsSource + { + public override Task> GetSkillsAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult>(new List()); + } + } + + /// + /// A source that delegates to a provided function, returning a fresh result each call. + /// + private sealed class DelegatingTestSource : AgentSkillsSource + { + private readonly Func>> _factory; + + public DelegatingTestSource(Func>> factory) + { + this._factory = factory; + } + + public override Task> GetSkillsAsync(CancellationToken cancellationToken = default) + { + return this._factory(); + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/DeduplicatingAgentSkillsSourceTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/DeduplicatingAgentSkillsSourceTests.cs new file mode 100644 index 0000000000..3c3f9dcbb3 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/DeduplicatingAgentSkillsSourceTests.cs @@ -0,0 +1,105 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.UnitTests.AgentSkills; + +/// +/// Unit tests for . +/// +public sealed class DeduplicatingAgentSkillsSourceTests +{ + [Fact] + public async Task GetSkillsAsync_NoDuplicates_ReturnsAllSkillsAsync() + { + // Arrange + var inner = new TestAgentSkillsSource( + new TestAgentSkill("skill-a", "A", "Instructions A."), + new TestAgentSkill("skill-b", "B", "Instructions B.")); + var source = new DeduplicatingAgentSkillsSource(inner); + + // Act + var result = await source.GetSkillsAsync(CancellationToken.None); + + // Assert + Assert.Equal(2, result.Count); + } + + [Fact] + public async Task GetSkillsAsync_WithDuplicates_KeepsFirstOccurrenceAsync() + { + // Arrange + var skills = new AgentSkill[] + { + new TestAgentSkill("dupe", "First", "Instructions 1."), + new TestAgentSkill("dupe", "Second", "Instructions 2."), + new TestAgentSkill("unique", "Unique", "Instructions 3."), + }; + var inner = new TestAgentSkillsSource(skills); + var source = new DeduplicatingAgentSkillsSource(inner); + + // Act + var result = await source.GetSkillsAsync(CancellationToken.None); + + // Assert + Assert.Equal(2, result.Count); + Assert.Equal("First", result.First(s => s.Frontmatter.Name == "dupe").Frontmatter.Description); + Assert.Contains(result, s => s.Frontmatter.Name == "unique"); + } + + [Fact] + public async Task GetSkillsAsync_CaseInsensitiveDuplication_KeepsFirstAsync() + { + // Arrange + _ = new AgentSkill[] + { + new TestAgentSkill("my-skill", "Lowercase", "Instructions."), + }; + + // Use a custom source that returns skills with same name but different casing + var inner = new FakeDuplicateCaseSource(); + var source = new DeduplicatingAgentSkillsSource(inner); + + // Act + var result = await source.GetSkillsAsync(CancellationToken.None); + + // Assert + Assert.Single(result); + Assert.Equal("First", result[0].Frontmatter.Description); + } + + [Fact] + public async Task GetSkillsAsync_EmptySource_ReturnsEmptyAsync() + { + // Arrange + var inner = new TestAgentSkillsSource(System.Array.Empty()); + var source = new DeduplicatingAgentSkillsSource(inner); + + // Act + var result = await source.GetSkillsAsync(CancellationToken.None); + + // Assert + Assert.Empty(result); + } + + /// + /// A fake source that returns skills with names differing only by case. + /// + private sealed class FakeDuplicateCaseSource : AgentSkillsSource + { + public override Task> GetSkillsAsync(CancellationToken cancellationToken = default) + { + // AgentSkillFrontmatter validates names must be lowercase, so we build + // two skills with the same lowercase name to test case-insensitive dedup. + var skills = new List + { + new TestAgentSkill("my-skill", "First", "Instructions 1."), + new TestAgentSkill("my-skill", "Second", "Instructions 2."), + }; + return Task.FromResult>(skills); + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs index 6134b04feb..a4741431fe 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs @@ -4,25 +4,25 @@ using System.IO; using System.Linq; using System.Threading.Tasks; -using Microsoft.Extensions.Logging.Abstractions; namespace Microsoft.Agents.AI.UnitTests.AgentSkills; /// -/// Unit tests for the class. +/// Unit tests for the skill discovery and parsing logic. /// public sealed class FileAgentSkillLoaderTests : IDisposable { - private static readonly string[] s_traversalResource = new[] { "../secret.txt" }; + private static readonly string[] s_customExtensions = [".custom"]; + private static readonly string[] s_validExtensions = [".md", ".json", ".custom"]; + private static readonly string[] s_mixedValidInvalidExtensions = [".md", "json"]; + private static readonly AgentFileSkillScriptExecutor s_noOpExecutor = (skill, script, args, ct) => Task.FromResult(null); private readonly string _testRoot; - private readonly FileAgentSkillLoader _loader; public FileAgentSkillLoaderTests() { this._testRoot = Path.Combine(Path.GetTempPath(), "agent-skills-tests-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(this._testRoot); - this._loader = new FileAgentSkillLoader(NullLogger.Instance); } public void Dispose() @@ -34,23 +34,23 @@ public void Dispose() } [Fact] - public void DiscoverAndLoadSkills_ValidSkill_ReturnsSkill() + public async Task GetSkillsAsync_ValidSkill_ReturnsSkillAsync() { // Arrange _ = this.CreateSkillDirectory("my-skill", "A test skill", "Use this skill to do things."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert Assert.Single(skills); - Assert.True(skills.ContainsKey("my-skill")); - Assert.Equal("A test skill", skills["my-skill"].Frontmatter.Description); - Assert.Equal("Use this skill to do things.", skills["my-skill"].Body); + Assert.Equal("my-skill", skills[0].Frontmatter.Name); + Assert.Equal("A test skill", skills[0].Frontmatter.Description); } [Fact] - public void DiscoverAndLoadSkills_QuotedFrontmatterValues_ParsesCorrectly() + public async Task GetSkillsAsync_QuotedFrontmatterValues_ParsesCorrectlyAsync() { // Arrange string skillDir = Path.Combine(this._testRoot, "quoted-skill"); @@ -58,33 +58,35 @@ public void DiscoverAndLoadSkills_QuotedFrontmatterValues_ParsesCorrectly() File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), "---\nname: 'quoted-skill'\ndescription: \"A quoted description\"\n---\nBody text."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert Assert.Single(skills); - Assert.Equal("quoted-skill", skills["quoted-skill"].Frontmatter.Name); - Assert.Equal("A quoted description", skills["quoted-skill"].Frontmatter.Description); + Assert.Equal("quoted-skill", skills[0].Frontmatter.Name); + Assert.Equal("A quoted description", skills[0].Frontmatter.Description); } [Fact] - public void DiscoverAndLoadSkills_MissingFrontmatter_ExcludesSkill() + public async Task GetSkillsAsync_MissingFrontmatter_ExcludesSkillAsync() { // Arrange string skillDir = Path.Combine(this._testRoot, "bad-skill"); Directory.CreateDirectory(skillDir); File.WriteAllText(Path.Combine(skillDir, "SKILL.md"), "No frontmatter here."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert Assert.Empty(skills); } [Fact] - public void DiscoverAndLoadSkills_MissingNameField_ExcludesSkill() + public async Task GetSkillsAsync_MissingNameField_ExcludesSkillAsync() { // Arrange string skillDir = Path.Combine(this._testRoot, "no-name"); @@ -92,16 +94,17 @@ public void DiscoverAndLoadSkills_MissingNameField_ExcludesSkill() File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), "---\ndescription: A skill without a name\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert Assert.Empty(skills); } [Fact] - public void DiscoverAndLoadSkills_MissingDescriptionField_ExcludesSkill() + public async Task GetSkillsAsync_MissingDescriptionField_ExcludesSkillAsync() { // Arrange string skillDir = Path.Combine(this._testRoot, "no-desc"); @@ -109,9 +112,10 @@ public void DiscoverAndLoadSkills_MissingDescriptionField_ExcludesSkill() File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), "---\nname: no-desc\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert Assert.Empty(skills); @@ -123,7 +127,7 @@ public void DiscoverAndLoadSkills_MissingDescriptionField_ExcludesSkill() [InlineData("trailing-hyphen-")] [InlineData("has spaces")] [InlineData("consecutive--hyphens")] - public void DiscoverAndLoadSkills_InvalidName_ExcludesSkill(string invalidName) + public async Task GetSkillsAsync_InvalidName_ExcludesSkillAsync(string invalidName) { // Arrange string skillDir = Path.Combine(this._testRoot, invalidName); @@ -136,16 +140,17 @@ public void DiscoverAndLoadSkills_InvalidName_ExcludesSkill(string invalidName) File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), $"---\nname: {invalidName}\ndescription: A skill\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert Assert.Empty(skills); } [Fact] - public void DiscoverAndLoadSkills_DuplicateNames_KeepsFirstOnly() + public async Task GetSkillsAsync_DuplicateNames_KeepsFirstOnlyAsync() { // Arrange string dir1 = Path.Combine(this._testRoot, "dupe"); @@ -162,34 +167,37 @@ public void DiscoverAndLoadSkills_DuplicateNames_KeepsFirstOnly() File.WriteAllText( Path.Combine(nestedDir, "SKILL.md"), "---\nname: dupe\ndescription: Second\n---\nSecond body."); + var fileSource = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + var source = new DeduplicatingAgentSkillsSource(fileSource); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert – filesystem enumeration order is not guaranteed, so we only // verify that exactly one of the two duplicates was kept. Assert.Single(skills); - string desc = skills["dupe"].Frontmatter.Description; + string desc = skills[0].Frontmatter.Description; Assert.True(desc == "First" || desc == "Second", $"Unexpected description: {desc}"); } [Fact] - public void DiscoverAndLoadSkills_NameMismatchesDirectory_ExcludesSkill() + public async Task GetSkillsAsync_NameMismatchesDirectory_ExcludesSkillAsync() { // Arrange — directory name differs from the frontmatter name _ = this.CreateSkillDirectoryWithRawContent( "wrong-dir-name", "---\nname: actual-skill-name\ndescription: A skill\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert Assert.Empty(skills); } [Fact] - public void DiscoverAndLoadSkills_FilesWithMatchingExtensions_DiscoveredAsResources() + public async Task GetSkillsAsync_FilesWithMatchingExtensions_DiscoveredAsResourcesAsync() { // Arrange — create resource files in the skill directory string skillDir = Path.Combine(this._testRoot, "resource-skill"); @@ -200,20 +208,21 @@ public void DiscoverAndLoadSkills_FilesWithMatchingExtensions_DiscoveredAsResour File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), "---\nname: resource-skill\ndescription: Has resources\n---\nSee docs for details."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert Assert.Single(skills); - var skill = skills["resource-skill"]; - Assert.Equal(2, skill.ResourceNames.Count); - Assert.Contains(skill.ResourceNames, r => r.Equals("refs/FAQ.md", StringComparison.OrdinalIgnoreCase)); - Assert.Contains(skill.ResourceNames, r => r.Equals("refs/data.json", StringComparison.OrdinalIgnoreCase)); + var skill = skills[0]; + Assert.Equal(2, skill.Resources!.Count); + Assert.Contains(skill.Resources!, r => r.Name.Equals("refs/FAQ.md", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(skill.Resources!, r => r.Name.Equals("refs/data.json", StringComparison.OrdinalIgnoreCase)); } [Fact] - public void DiscoverAndLoadSkills_FilesWithNonMatchingExtensions_NotDiscovered() + public async Task GetSkillsAsync_FilesWithNonMatchingExtensions_NotDiscoveredAsync() { // Arrange — create a file with an extension not in the default list string skillDir = Path.Combine(this._testRoot, "ext-skill"); @@ -223,19 +232,20 @@ public void DiscoverAndLoadSkills_FilesWithNonMatchingExtensions_NotDiscovered() File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), "---\nname: ext-skill\ndescription: Extension test\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert Assert.Single(skills); - var skill = skills["ext-skill"]; - Assert.Single(skill.ResourceNames); - Assert.Equal("data.json", skill.ResourceNames[0]); + var skill = skills[0]; + Assert.Single(skill.Resources!); + Assert.Equal("data.json", skill.Resources![0].Name); } [Fact] - public void DiscoverAndLoadSkills_SkillMdFile_NotIncludedAsResource() + public async Task GetSkillsAsync_SkillMdFile_NotIncludedAsResourceAsync() { // Arrange — the SKILL.md file itself should not be in the resource list string skillDir = Path.Combine(this._testRoot, "selfref-skill"); @@ -244,19 +254,20 @@ public void DiscoverAndLoadSkills_SkillMdFile_NotIncludedAsResource() File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), "---\nname: selfref-skill\ndescription: Self ref test\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert Assert.Single(skills); - var skill = skills["selfref-skill"]; - Assert.Single(skill.ResourceNames); - Assert.Equal("notes.md", skill.ResourceNames[0]); + var skill = skills[0]; + Assert.Single(skill.Resources!); + Assert.Equal("notes.md", skill.Resources![0].Name); } [Fact] - public void DiscoverAndLoadSkills_NestedResourceFiles_Discovered() + public async Task GetSkillsAsync_NestedResourceFiles_DiscoveredAsync() { // Arrange — resource files in nested subdirectories string skillDir = Path.Combine(this._testRoot, "nested-res-skill"); @@ -266,26 +277,22 @@ public void DiscoverAndLoadSkills_NestedResourceFiles_Discovered() File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), "---\nname: nested-res-skill\ndescription: Nested resources\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert Assert.Single(skills); - var skill = skills["nested-res-skill"]; - Assert.Single(skill.ResourceNames); - Assert.Contains(skill.ResourceNames, r => r.Equals("level1/level2/deep.md", StringComparison.OrdinalIgnoreCase)); + var skill = skills[0]; + Assert.Single(skill.Resources!); + Assert.Contains(skill.Resources!, r => r.Name.Equals("level1/level2/deep.md", StringComparison.OrdinalIgnoreCase)); } - private static readonly string[] s_customExtensions = new[] { ".custom" }; - private static readonly string[] s_validExtensions = new[] { ".md", ".json", ".custom" }; - private static readonly string[] s_mixedValidInvalidExtensions = new[] { ".md", "json" }; - [Fact] - public void DiscoverAndLoadSkills_CustomResourceExtensions_UsedForDiscovery() + public async Task GetSkillsAsync_CustomResourceExtensions_UsedForDiscoveryAsync() { - // Arrange — use a loader with custom extensions - var customLoader = new FileAgentSkillLoader(NullLogger.Instance, s_customExtensions); + // Arrange — use a source with custom extensions string skillDir = Path.Combine(this._testRoot, "custom-ext-skill"); Directory.CreateDirectory(skillDir); File.WriteAllText(Path.Combine(skillDir, "data.custom"), "custom data"); @@ -293,15 +300,16 @@ public void DiscoverAndLoadSkills_CustomResourceExtensions_UsedForDiscovery() File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), "---\nname: custom-ext-skill\ndescription: Custom extensions\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { AllowedResourceExtensions = s_customExtensions }); // Act - var skills = customLoader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert — only .custom files should be discovered, not .json Assert.Single(skills); - var skill = skills["custom-ext-skill"]; - Assert.Single(skill.ResourceNames); - Assert.Equal("data.custom", skill.ResourceNames[0]); + var skill = skills[0]; + Assert.Single(skill.Resources!); + Assert.Equal("data.custom", skill.Resources![0].Name); } [Theory] @@ -311,39 +319,39 @@ public void DiscoverAndLoadSkills_CustomResourceExtensions_UsedForDiscovery() public void Constructor_InvalidExtension_ThrowsArgumentException(string badExtension) { // Arrange & Act & Assert - Assert.Throws(() => new FileAgentSkillLoader(NullLogger.Instance, new[] { badExtension })); + Assert.Throws(() => new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { AllowedResourceExtensions = new string[] { badExtension } })); } [Fact] - public void Constructor_NullExtensions_UsesDefaults() + public async Task Constructor_NullExtensions_UsesDefaultsAsync() { // Arrange & Act - var loader = new FileAgentSkillLoader(NullLogger.Instance, null); string skillDir = this.CreateSkillDirectory("null-ext", "A skill", "Body."); File.WriteAllText(Path.Combine(skillDir, "notes.md"), "notes"); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Assert — default extensions include .md - var skills = loader.DiscoverAndLoadSkills(new[] { this._testRoot }); - Assert.Single(skills["null-ext"].ResourceNames); + var skills = await source.GetSkillsAsync(); + Assert.Single(skills[0].Resources!); } [Fact] public void Constructor_ValidExtensions_DoesNotThrow() { // Arrange & Act & Assert — should not throw - var loader = new FileAgentSkillLoader(NullLogger.Instance, s_validExtensions); - Assert.NotNull(loader); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { AllowedResourceExtensions = s_validExtensions }); + Assert.NotNull(source); } [Fact] public void Constructor_MixOfValidAndInvalidExtensions_ThrowsArgumentException() { // Arrange & Act & Assert — one bad extension in the list should cause failure - Assert.Throws(() => new FileAgentSkillLoader(NullLogger.Instance, s_mixedValidInvalidExtensions)); + Assert.Throws(() => new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { AllowedResourceExtensions = s_mixedValidInvalidExtensions })); } [Fact] - public void DiscoverAndLoadSkills_ResourceInSkillRoot_Discovered() + public async Task GetSkillsAsync_ResourceInSkillRoot_DiscoveredAsync() { // Arrange — resource file directly in the skill directory (not in a subdirectory) string skillDir = Path.Combine(this._testRoot, "root-resource-skill"); @@ -353,54 +361,62 @@ public void DiscoverAndLoadSkills_ResourceInSkillRoot_Discovered() File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), "---\nname: root-resource-skill\ndescription: Root resources\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert — both root-level resource files should be discovered Assert.Single(skills); - var skill = skills["root-resource-skill"]; - Assert.Equal(2, skill.ResourceNames.Count); - Assert.Contains(skill.ResourceNames, r => r.Equals("guide.md", StringComparison.OrdinalIgnoreCase)); - Assert.Contains(skill.ResourceNames, r => r.Equals("config.json", StringComparison.OrdinalIgnoreCase)); + var skill = skills[0]; + Assert.Equal(2, skill.Resources!.Count); + Assert.Contains(skill.Resources!, r => r.Name.Equals("guide.md", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(skill.Resources!, r => r.Name.Equals("config.json", StringComparison.OrdinalIgnoreCase)); } [Fact] - public void DiscoverAndLoadSkills_NoResourceFiles_ReturnsEmptyResourceNames() + public async Task GetSkillsAsync_NoResourceFiles_ReturnsEmptyResourcesAsync() { // Arrange — skill with no resource files _ = this.CreateSkillDirectory("no-resources", "A skill", "No resources here."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert Assert.Single(skills); - Assert.Empty(skills["no-resources"].ResourceNames); + Assert.Empty(skills[0].Resources!); } [Fact] - public void DiscoverAndLoadSkills_EmptyPaths_ReturnsEmptyDictionary() + public async Task GetSkillsAsync_EmptyPaths_ReturnsEmptyListAsync() { + // Arrange + var source = new AgentFileSkillsSource(Enumerable.Empty(), s_noOpExecutor); + // Act - var skills = this._loader.DiscoverAndLoadSkills(Enumerable.Empty()); + var skills = await source.GetSkillsAsync(); // Assert Assert.Empty(skills); } [Fact] - public void DiscoverAndLoadSkills_NonExistentPath_ReturnsEmptyDictionary() + public async Task GetSkillsAsync_NonExistentPath_ReturnsEmptyListAsync() { + // Arrange + var source = new AgentFileSkillsSource(Path.Combine(this._testRoot, "does-not-exist"), s_noOpExecutor); + // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { Path.Combine(this._testRoot, "does-not-exist") }); + var skills = await source.GetSkillsAsync(); // Assert Assert.Empty(skills); } [Fact] - public void DiscoverAndLoadSkills_NestedSkillDirectory_DiscoveredWithinDepthLimit() + public async Task GetSkillsAsync_NestedSkillDirectory_DiscoveredWithinDepthLimitAsync() { // Arrange — nested 1 level deep (MaxSearchDepth = 2, so depth 0 = testRoot, depth 1 = level1) string nestedDir = Path.Combine(this._testRoot, "level1", "nested-skill"); @@ -408,13 +424,14 @@ public void DiscoverAndLoadSkills_NestedSkillDirectory_DiscoveredWithinDepthLimi File.WriteAllText( Path.Combine(nestedDir, "SKILL.md"), "---\nname: nested-skill\ndescription: Nested\n---\nNested body."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert Assert.Single(skills); - Assert.True(skills.ContainsKey("nested-skill")); + Assert.Equal("nested-skill", skills[0].Frontmatter.Name); } [Fact] @@ -425,54 +442,19 @@ public async Task ReadSkillResourceAsync_ValidResource_ReturnsContentAsync() string refsDir = Path.Combine(skillDir, "refs"); Directory.CreateDirectory(refsDir); File.WriteAllText(Path.Combine(refsDir, "doc.md"), "Document content here."); - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); - var skill = skills["read-skill"]; + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + var skills = await source.GetSkillsAsync(); + var resource = skills[0].Resources!.First(r => r.Name == "refs/doc.md"); // Act - string content = await this._loader.ReadSkillResourceAsync(skill, "refs/doc.md"); + var content = await resource.ReadAsync(new Extensions.AI.AIFunctionArguments()); // Assert Assert.Equal("Document content here.", content); } [Fact] - public async Task ReadSkillResourceAsync_UnregisteredResource_ThrowsInvalidOperationExceptionAsync() - { - // Arrange - string skillDir = this.CreateSkillDirectory("simple-skill", "A skill", "No resources."); - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); - var skill = skills["simple-skill"]; - - // Act & Assert - await Assert.ThrowsAsync( - () => this._loader.ReadSkillResourceAsync(skill, "unknown.md")); - } - - [Fact] - public async Task ReadSkillResourceAsync_PathTraversal_ThrowsInvalidOperationExceptionAsync() - { - // Arrange — skill with a legitimate resource, then try to read a traversal path at read time - string skillDir = this.CreateSkillDirectory("traverse-read", "A skill", "See docs."); - string refsDir = Path.Combine(skillDir, "refs"); - Directory.CreateDirectory(refsDir); - File.WriteAllText(Path.Combine(refsDir, "doc.md"), "legit"); - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); - var skill = skills["traverse-read"]; - - // Manually construct a skill with the traversal resource in its list to bypass discovery validation - var tampered = new FileAgentSkill( - skill.Frontmatter, - skill.Body, - skill.SourcePath, - s_traversalResource); - - // Act & Assert - await Assert.ThrowsAsync( - () => this._loader.ReadSkillResourceAsync(tampered, "../secret.txt")); - } - - [Fact] - public void DiscoverAndLoadSkills_NameExceedsMaxLength_ExcludesSkill() + public async Task GetSkillsAsync_NameExceedsMaxLength_ExcludesSkillAsync() { // Arrange — name longer than 64 characters string longName = new('a', 65); @@ -481,16 +463,17 @@ public void DiscoverAndLoadSkills_NameExceedsMaxLength_ExcludesSkill() File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), $"---\nname: {longName}\ndescription: A skill\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert Assert.Empty(skills); } [Fact] - public void DiscoverAndLoadSkills_DescriptionExceedsMaxLength_ExcludesSkill() + public async Task GetSkillsAsync_DescriptionExceedsMaxLength_ExcludesSkillAsync() { // Arrange — description longer than 1024 characters string longDesc = new('x', 1025); @@ -499,71 +482,18 @@ public void DiscoverAndLoadSkills_DescriptionExceedsMaxLength_ExcludesSkill() File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), $"---\nname: long-desc\ndescription: {longDesc}\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert Assert.Empty(skills); } - [Fact] - public async Task ReadSkillResourceAsync_DotSlashPrefix_MatchesNormalizedResourceAsync() - { - // Arrange — skill loaded with bare path, caller uses ./ prefix - string skillDir = this.CreateSkillDirectory("dotslash-read", "A skill", "See docs."); - string refsDir = Path.Combine(skillDir, "refs"); - Directory.CreateDirectory(refsDir); - File.WriteAllText(Path.Combine(refsDir, "doc.md"), "Document content."); - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); - var skill = skills["dotslash-read"]; - - // Act — caller passes ./refs/doc.md which should match refs/doc.md - string content = await this._loader.ReadSkillResourceAsync(skill, "./refs/doc.md"); - - // Assert - Assert.Equal("Document content.", content); - } - - [Fact] - public async Task ReadSkillResourceAsync_BackslashSeparator_MatchesNormalizedResourceAsync() - { - // Arrange — skill loaded with forward-slash path, caller uses backslashes - string skillDir = this.CreateSkillDirectory("backslash-read", "A skill", "See docs."); - string refsDir = Path.Combine(skillDir, "refs"); - Directory.CreateDirectory(refsDir); - File.WriteAllText(Path.Combine(refsDir, "doc.md"), "Backslash content."); - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); - var skill = skills["backslash-read"]; - - // Act — caller passes refs\doc.md which should match refs/doc.md - string content = await this._loader.ReadSkillResourceAsync(skill, "refs\\doc.md"); - - // Assert - Assert.Equal("Backslash content.", content); - } - - [Fact] - public async Task ReadSkillResourceAsync_DotSlashWithBackslash_MatchesNormalizedResourceAsync() - { - // Arrange — skill loaded with forward-slash path, caller uses .\ prefix with backslashes - string skillDir = this.CreateSkillDirectory("mixed-sep-read", "A skill", "See docs."); - string refsDir = Path.Combine(skillDir, "refs"); - Directory.CreateDirectory(refsDir); - File.WriteAllText(Path.Combine(refsDir, "doc.md"), "Mixed separator content."); - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); - var skill = skills["mixed-sep-read"]; - - // Act — caller passes .\refs\doc.md which should match refs/doc.md - string content = await this._loader.ReadSkillResourceAsync(skill, ".\\refs\\doc.md"); - - // Assert - Assert.Equal("Mixed separator content.", content); - } - #if NET [Fact] - public void DiscoverAndLoadSkills_SymlinkInPath_SkipsSymlinkedResources() + public async Task GetSkillsAsync_SymlinkInPath_SkipsSymlinkedResourcesAsync() { // Arrange — a "refs" subdirectory is a symlink pointing outside the skill directory string skillDir = Path.Combine(this._testRoot, "symlink-escape-skill"); @@ -588,71 +518,179 @@ public void DiscoverAndLoadSkills_SymlinkInPath_SkipsSymlinkedResources() File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), "---\nname: symlink-escape-skill\ndescription: Symlinked directory escape\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert — skill should still load, but symlinked resources should be excluded - Assert.True(skills.ContainsKey("symlink-escape-skill")); - var skill = skills["symlink-escape-skill"]; - Assert.Single(skill.ResourceNames); - Assert.Equal("legit.md", skill.ResourceNames[0]); + var skill = skills.FirstOrDefault(s => s.Frontmatter.Name == "symlink-escape-skill"); + Assert.NotNull(skill); + Assert.Single(skill.Resources!); + Assert.Equal("legit.md", skill.Resources![0].Name); } +#endif - private static readonly string[] s_symlinkResource = ["refs/data.md"]; + [Fact] + public async Task GetSkillsAsync_FileWithUtf8Bom_ParsesSuccessfullyAsync() + { + // Arrange — prepend a UTF-8 BOM (\uFEFF) before the frontmatter + _ = this.CreateSkillDirectoryWithRawContent( + "bom-skill", + "\uFEFF---\nname: bom-skill\ndescription: Skill with BOM\n---\nBody content."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + + // Act + var skills = await source.GetSkillsAsync(); + + // Assert + Assert.Single(skills); + Assert.Equal("bom-skill", skills[0].Frontmatter.Name); + Assert.Equal("Skill with BOM", skills[0].Frontmatter.Description); + } [Fact] - public async Task ReadSkillResourceAsync_SymlinkInPath_ThrowsInvalidOperationExceptionAsync() + public async Task GetSkillsAsync_LicenseField_ParsedCorrectlyAsync() { - // Arrange — build a skill with a symlinked subdirectory - string skillDir = Path.Combine(this._testRoot, "symlink-read-skill"); - string refsDir = Path.Combine(skillDir, "refs"); - Directory.CreateDirectory(skillDir); + // Arrange + _ = this.CreateSkillDirectoryWithRawContent( + "licensed-skill", + "---\nname: licensed-skill\ndescription: A skill with license\nlicense: MIT\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); - string outsideDir = Path.Combine(this._testRoot, "outside-read"); - Directory.CreateDirectory(outsideDir); - File.WriteAllText(Path.Combine(outsideDir, "data.md"), "external data"); + // Act + var skills = await source.GetSkillsAsync(); - try - { - Directory.CreateSymbolicLink(refsDir, outsideDir); - } - catch (IOException) - { - // Symlink creation requires elevation on some platforms; skip gracefully. - return; - } + // Assert + Assert.Single(skills); + Assert.Equal("MIT", skills[0].Frontmatter.License); + } + + [Fact] + public async Task GetSkillsAsync_CompatibilityField_ParsedCorrectlyAsync() + { + // Arrange + _ = this.CreateSkillDirectoryWithRawContent( + "compat-skill", + "---\nname: compat-skill\ndescription: A skill with compatibility\ncompatibility: Requires Node.js 18+\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); - // Manually construct a skill that bypasses discovery validation - var frontmatter = new SkillFrontmatter("symlink-read-skill", "A skill"); - var skill = new FileAgentSkill( - frontmatter: frontmatter, - body: "See [doc](refs/data.md).", - sourcePath: skillDir, - resourceNames: s_symlinkResource); + // Act + var skills = await source.GetSkillsAsync(); - // Act & Assert - await Assert.ThrowsAsync( - () => this._loader.ReadSkillResourceAsync(skill, "refs/data.md")); + // Assert + Assert.Single(skills); + Assert.Equal("Requires Node.js 18+", skills[0].Frontmatter.Compatibility); } -#endif [Fact] - public void DiscoverAndLoadSkills_FileWithUtf8Bom_ParsesSuccessfully() + public async Task GetSkillsAsync_AllowedToolsField_ParsedCorrectlyAsync() { - // Arrange — prepend a UTF-8 BOM (\uFEFF) before the frontmatter + // Arrange _ = this.CreateSkillDirectoryWithRawContent( - "bom-skill", - "\uFEFF---\nname: bom-skill\ndescription: Skill with BOM\n---\nBody content."); + "tools-skill", + "---\nname: tools-skill\ndescription: A skill with tools\nallowed-tools: grep glob bash\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + + // Act + var skills = await source.GetSkillsAsync(); + + // Assert + Assert.Single(skills); + Assert.Equal("grep glob bash", skills[0].Frontmatter.AllowedTools); + } + + [Fact] + public async Task GetSkillsAsync_MetadataField_ParsedCorrectlyAsync() + { + // Arrange + _ = this.CreateSkillDirectoryWithRawContent( + "meta-skill", + "---\nname: meta-skill\ndescription: A skill with metadata\nmetadata:\n author: test-user\n version: 1.0\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + + // Act + var skills = await source.GetSkillsAsync(); + + // Assert + Assert.Single(skills); + Assert.NotNull(skills[0].Frontmatter.Metadata); + Assert.Equal("test-user", skills[0].Frontmatter.Metadata!["author"]?.ToString()); + Assert.Equal("1.0", skills[0].Frontmatter.Metadata!["version"]?.ToString()); + } + + [Fact] + public async Task GetSkillsAsync_MetadataWithQuotedValues_ParsedCorrectlyAsync() + { + // Arrange + _ = this.CreateSkillDirectoryWithRawContent( + "quoted-meta", + "---\nname: quoted-meta\ndescription: Metadata with quotes\nmetadata:\n key1: 'single quoted'\n key2: \"double quoted\"\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + + // Act + var skills = await source.GetSkillsAsync(); + + // Assert + Assert.Single(skills); + Assert.NotNull(skills[0].Frontmatter.Metadata); + Assert.Equal("single quoted", skills[0].Frontmatter.Metadata!["key1"]?.ToString()); + Assert.Equal("double quoted", skills[0].Frontmatter.Metadata!["key2"]?.ToString()); + } + + [Fact] + public async Task GetSkillsAsync_AllOptionalFields_ParsedCorrectlyAsync() + { + // Arrange + string content = string.Join( + "\n", + "---", + "name: full-skill", + "description: A skill with all fields", + "license: Apache-2.0", + "compatibility: Requires Python 3.10+", + "allowed-tools: grep glob view", + "metadata:", + " org: contoso", + " tier: premium", + "---", + "Full body content."); + _ = this.CreateSkillDirectoryWithRawContent("full-skill", content); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + + // Act + var skills = await source.GetSkillsAsync(); + + // Assert + Assert.Single(skills); + var fm = skills[0].Frontmatter; + Assert.Equal("full-skill", fm.Name); + Assert.Equal("A skill with all fields", fm.Description); + Assert.Equal("Apache-2.0", fm.License); + Assert.Equal("Requires Python 3.10+", fm.Compatibility); + Assert.Equal("grep glob view", fm.AllowedTools); + Assert.NotNull(fm.Metadata); + Assert.Equal("contoso", fm.Metadata!["org"]?.ToString()); + Assert.Equal("premium", fm.Metadata!["tier"]?.ToString()); + } + + [Fact] + public async Task GetSkillsAsync_NoOptionalFields_DefaultsToNullAsync() + { + // Arrange + _ = this.CreateSkillDirectory("basic-skill", "A basic skill", "Body."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert Assert.Single(skills); - Assert.True(skills.ContainsKey("bom-skill")); - Assert.Equal("Skill with BOM", skills["bom-skill"].Frontmatter.Description); - Assert.Equal("Body content.", skills["bom-skill"].Body); + var fm = skills[0].Frontmatter; + Assert.Null(fm.License); + Assert.Null(fm.Compatibility); + Assert.Null(fm.AllowedTools); + Assert.Null(fm.Metadata); } private string CreateSkillDirectory(string name, string description, string body) diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillsProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillsProviderTests.cs deleted file mode 100644 index 5da49525d4..0000000000 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillsProviderTests.cs +++ /dev/null @@ -1,266 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.IO; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Extensions.AI; - -namespace Microsoft.Agents.AI.UnitTests.AgentSkills; - -/// -/// Unit tests for the class. -/// -public sealed class FileAgentSkillsProviderTests : IDisposable -{ - private readonly string _testRoot; - private readonly TestAIAgent _agent = new(); - - public FileAgentSkillsProviderTests() - { - this._testRoot = Path.Combine(Path.GetTempPath(), "skills-provider-tests-" + Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(this._testRoot); - } - - public void Dispose() - { - if (Directory.Exists(this._testRoot)) - { - Directory.Delete(this._testRoot, recursive: true); - } - } - - [Fact] - public async Task InvokingCoreAsync_NoSkills_ReturnsInputContextUnchangedAsync() - { - // Arrange - var provider = new FileAgentSkillsProvider(this._testRoot); - var inputContext = new AIContext { Instructions = "Original instructions" }; - var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); - - // Act - var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); - - // Assert - Assert.Equal("Original instructions", result.Instructions); - Assert.Null(result.Tools); - } - - [Fact] - public async Task InvokingCoreAsync_WithSkills_AppendsInstructionsAndToolsAsync() - { - // Arrange - this.CreateSkill("provider-skill", "Provider skill test", "Skill instructions body."); - var provider = new FileAgentSkillsProvider(this._testRoot); - var inputContext = new AIContext { Instructions = "Base instructions" }; - var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); - - // Act - var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); - - // Assert - Assert.NotNull(result.Instructions); - Assert.Contains("Base instructions", result.Instructions); - Assert.Contains("provider-skill", result.Instructions); - Assert.Contains("Provider skill test", result.Instructions); - - // Should have load_skill and read_skill_resource tools - Assert.NotNull(result.Tools); - var toolNames = result.Tools!.Select(t => t.Name).ToList(); - Assert.Contains("load_skill", toolNames); - Assert.Contains("read_skill_resource", toolNames); - } - - [Fact] - public async Task InvokingCoreAsync_NullInputInstructions_SetsInstructionsAsync() - { - // Arrange - this.CreateSkill("null-instr-skill", "Null instruction test", "Body."); - var provider = new FileAgentSkillsProvider(this._testRoot); - var inputContext = new AIContext(); - var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); - - // Act - var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); - - // Assert - Assert.NotNull(result.Instructions); - Assert.Contains("null-instr-skill", result.Instructions); - } - - [Fact] - public async Task InvokingCoreAsync_CustomPromptTemplate_UsesCustomTemplateAsync() - { - // Arrange - this.CreateSkill("custom-prompt-skill", "Custom prompt", "Body."); - var options = new FileAgentSkillsProviderOptions - { - SkillsInstructionPrompt = "Custom template: {0}" - }; - var provider = new FileAgentSkillsProvider(this._testRoot, options); - var inputContext = new AIContext(); - var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); - - // Act - var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); - - // Assert - Assert.NotNull(result.Instructions); - Assert.StartsWith("Custom template:", result.Instructions); - Assert.Contains("custom-prompt-skill", result.Instructions); - Assert.Contains("Custom prompt", result.Instructions); - } - - [Fact] - public void Constructor_InvalidPromptTemplate_ThrowsArgumentException() - { - // Arrange — template with unescaped braces and no valid {0} placeholder - var options = new FileAgentSkillsProviderOptions - { - SkillsInstructionPrompt = "Bad template with {unescaped} braces" - }; - - // Act & Assert - var ex = Assert.Throws(() => new FileAgentSkillsProvider(this._testRoot, options)); - Assert.Contains("SkillsInstructionPrompt", ex.Message); - Assert.Equal("options", ex.ParamName); - } - - [Fact] - public void Constructor_PromptWithoutPlaceholder_ThrowsArgumentException() - { - // Arrange -- valid format string but missing the required placeholder - var options = new FileAgentSkillsProviderOptions - { - SkillsInstructionPrompt = "No placeholder here" - }; - - var ex = Assert.Throws(() => new FileAgentSkillsProvider(this._testRoot, options)); - Assert.Contains("{0}", ex.Message); - Assert.Equal("options", ex.ParamName); - } - - [Fact] - public async Task Constructor_PromptWithPlaceholder_AppliesCustomTemplateAsync() - { - // Arrange — valid custom template with {0} placeholder - this.CreateSkill("custom-tpl-skill", "Custom template skill", "Body."); - var options = new FileAgentSkillsProviderOptions - { - SkillsInstructionPrompt = "== Skills ==\n{0}\n== End ==" - }; - var provider = new FileAgentSkillsProvider(this._testRoot, options); - var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); - - // Act - var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); - - // Assert — the custom template wraps the skill list - Assert.NotNull(result.Instructions); - Assert.StartsWith("== Skills ==", result.Instructions); - Assert.Contains("custom-tpl-skill", result.Instructions); - Assert.Contains("== End ==", result.Instructions); - } - - [Fact] - public async Task InvokingCoreAsync_SkillNamesAreXmlEscapedAsync() - { - // Arrange — description with XML-sensitive characters - string skillDir = Path.Combine(this._testRoot, "xml-skill"); - Directory.CreateDirectory(skillDir); - File.WriteAllText( - Path.Combine(skillDir, "SKILL.md"), - "---\nname: xml-skill\ndescription: Uses & \"quotes\"\n---\nBody."); - var provider = new FileAgentSkillsProvider(this._testRoot); - var inputContext = new AIContext(); - var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); - - // Act - var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); - - // Assert - Assert.NotNull(result.Instructions); - Assert.Contains("<tags>", result.Instructions); - Assert.Contains("&", result.Instructions); - } - - [Fact] - public async Task Constructor_WithMultiplePaths_LoadsFromAllAsync() - { - // Arrange - string dir1 = Path.Combine(this._testRoot, "dir1"); - string dir2 = Path.Combine(this._testRoot, "dir2"); - CreateSkillIn(dir1, "skill-a", "Skill A", "Body A."); - CreateSkillIn(dir2, "skill-b", "Skill B", "Body B."); - - // Act - var provider = new FileAgentSkillsProvider(new[] { dir1, dir2 }); - var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); - - // Assert - var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); - Assert.NotNull(result.Instructions); - Assert.Contains("skill-a", result.Instructions); - Assert.Contains("skill-b", result.Instructions); - } - - [Fact] - public async Task InvokingCoreAsync_PreservesExistingInputToolsAsync() - { - // Arrange - this.CreateSkill("tools-skill", "Tools test", "Body."); - var provider = new FileAgentSkillsProvider(this._testRoot); - - var existingTool = AIFunctionFactory.Create(() => "test", name: "existing_tool", description: "An existing tool."); - var inputContext = new AIContext { Tools = new[] { existingTool } }; - var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); - - // Act - var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); - - // Assert — existing tool should be preserved alongside the new skill tools - Assert.NotNull(result.Tools); - var toolNames = result.Tools!.Select(t => t.Name).ToList(); - Assert.Contains("existing_tool", toolNames); - Assert.Contains("load_skill", toolNames); - Assert.Contains("read_skill_resource", toolNames); - } - - [Fact] - public async Task InvokingCoreAsync_SkillsListIsSortedByNameAsync() - { - // Arrange — create skills in reverse alphabetical order - this.CreateSkill("zulu-skill", "Zulu skill", "Body Z."); - this.CreateSkill("alpha-skill", "Alpha skill", "Body A."); - this.CreateSkill("mike-skill", "Mike skill", "Body M."); - var provider = new FileAgentSkillsProvider(this._testRoot); - var inputContext = new AIContext(); - var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); - - // Act - var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); - - // Assert — skills should appear in alphabetical order in the prompt - Assert.NotNull(result.Instructions); - int alphaIndex = result.Instructions!.IndexOf("alpha-skill", StringComparison.Ordinal); - int mikeIndex = result.Instructions.IndexOf("mike-skill", StringComparison.Ordinal); - int zuluIndex = result.Instructions.IndexOf("zulu-skill", StringComparison.Ordinal); - Assert.True(alphaIndex < mikeIndex, "alpha-skill should appear before mike-skill"); - Assert.True(mikeIndex < zuluIndex, "mike-skill should appear before zulu-skill"); - } - - private void CreateSkill(string name, string description, string body) - { - CreateSkillIn(this._testRoot, name, description, body); - } - - private static void CreateSkillIn(string root, string name, string description, string body) - { - string skillDir = Path.Combine(root, name); - Directory.CreateDirectory(skillDir); - File.WriteAllText( - Path.Combine(skillDir, "SKILL.md"), - $"---\nname: {name}\ndescription: {description}\n---\n{body}"); - } -} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FilteringAgentSkillsSourceTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FilteringAgentSkillsSourceTests.cs new file mode 100644 index 0000000000..de145004e0 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FilteringAgentSkillsSourceTests.cs @@ -0,0 +1,120 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.UnitTests.AgentSkills; + +/// +/// Unit tests for . +/// +public sealed class FilteringAgentSkillsSourceTests +{ + [Fact] + public async Task GetSkillsAsync_PredicateIncludesAll_ReturnsAllSkillsAsync() + { + // Arrange + var inner = new TestAgentSkillsSource( + new TestAgentSkill("skill-a", "A", "Instructions A."), + new TestAgentSkill("skill-b", "B", "Instructions B.")); + var source = new FilteringAgentSkillsSource(inner, _ => true); + + // Act + var result = await source.GetSkillsAsync(CancellationToken.None); + + // Assert + Assert.Equal(2, result.Count); + } + + [Fact] + public async Task GetSkillsAsync_PredicateExcludesAll_ReturnsEmptyAsync() + { + // Arrange + var inner = new TestAgentSkillsSource( + new TestAgentSkill("skill-a", "A", "Instructions A."), + new TestAgentSkill("skill-b", "B", "Instructions B.")); + var source = new FilteringAgentSkillsSource(inner, _ => false); + + // Act + var result = await source.GetSkillsAsync(CancellationToken.None); + + // Assert + Assert.Empty(result); + } + + [Fact] + public async Task GetSkillsAsync_PartialFilter_ReturnsMatchingSkillsOnlyAsync() + { + // Arrange + var inner = new TestAgentSkillsSource( + new TestAgentSkill("keep-me", "Keep", "Instructions."), + new TestAgentSkill("drop-me", "Drop", "Instructions."), + new TestAgentSkill("keep-also", "KeepAlso", "Instructions.")); + var source = new FilteringAgentSkillsSource( + inner, + skill => skill.Frontmatter.Name.StartsWith("keep", StringComparison.OrdinalIgnoreCase)); + + // Act + var result = await source.GetSkillsAsync(CancellationToken.None); + + // Assert + Assert.Equal(2, result.Count); + Assert.All(result, s => Assert.StartsWith("keep", s.Frontmatter.Name)); + } + + [Fact] + public async Task GetSkillsAsync_EmptySource_ReturnsEmptyAsync() + { + // Arrange + var inner = new TestAgentSkillsSource(Array.Empty()); + var source = new FilteringAgentSkillsSource(inner, _ => true); + + // Act + var result = await source.GetSkillsAsync(CancellationToken.None); + + // Assert + Assert.Empty(result); + } + + [Fact] + public void Constructor_NullPredicate_Throws() + { + // Arrange + var inner = new TestAgentSkillsSource(Array.Empty()); + + // Act & Assert + Assert.Throws(() => new FilteringAgentSkillsSource(inner, null!)); + } + + [Fact] + public void Constructor_NullInnerSource_Throws() + { + // Act & Assert + Assert.Throws(() => new FilteringAgentSkillsSource(null!, _ => true)); + } + + [Fact] + public async Task GetSkillsAsync_PreservesOrderAsync() + { + // Arrange + var inner = new TestAgentSkillsSource( + new TestAgentSkill("alpha", "Alpha", "Instructions."), + new TestAgentSkill("beta", "Beta", "Instructions."), + new TestAgentSkill("gamma", "Gamma", "Instructions."), + new TestAgentSkill("delta", "Delta", "Instructions.")); + + // Keep only alpha and gamma + var source = new FilteringAgentSkillsSource( + inner, + skill => skill.Frontmatter.Name is "alpha" or "gamma"); + + // Act + var result = await source.GetSkillsAsync(CancellationToken.None); + + // Assert + Assert.Equal(2, result.Count); + Assert.Equal("alpha", result[0].Frontmatter.Name); + Assert.Equal("gamma", result[1].Frontmatter.Name); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/TestSkillTypes.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/TestSkillTypes.cs new file mode 100644 index 0000000000..8c97a31ae4 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/TestSkillTypes.cs @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.UnitTests.AgentSkills; + +/// +/// A simple in-memory implementation for unit tests. +/// +internal sealed class TestAgentSkill : AgentSkill +{ + private readonly AgentSkillFrontmatter _frontmatter; + private readonly string _content; + + /// + /// Initializes a new instance of the class. + /// + /// Kebab-case skill name. + /// Skill description. + /// Full skill content (body text). + public TestAgentSkill(string name, string description, string content) + { + this._frontmatter = new AgentSkillFrontmatter(name, description); + this._content = content; + } + + /// + public override AgentSkillFrontmatter Frontmatter => this._frontmatter; + + /// + public override string Content => this._content; + + /// + public override IReadOnlyList? Resources => null; + + /// + public override IReadOnlyList? Scripts => null; +} + +/// +/// A simple in-memory implementation for unit tests. +/// +internal sealed class TestAgentSkillsSource : AgentSkillsSource +{ + private readonly IList _skills; + + /// + /// Initializes a new instance of the class. + /// + /// The skills to return. + public TestAgentSkillsSource(IList skills) + { + this._skills = skills; + } + + /// + /// Initializes a new instance of the class. + /// + /// The skills to return. + public TestAgentSkillsSource(params AgentSkill[] skills) + { + this._skills = skills; + } + + /// + public override Task> GetSkillsAsync(CancellationToken cancellationToken = default) + { + return Task.FromResult(this._skills); + } +} From 7db9d614c91535319bd3b97628233a7f0245cea2 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Date: Mon, 23 Mar 2026 22:31:31 +0000 Subject: [PATCH 02/14] address comments --- .../AgentSkills/SubprocessScriptExecutor.cs | 48 ++++++++++++------- .../A2AServer/HostAgentFactory.cs | 6 +-- .../Skills/AgentSkillsProvider.cs | 3 +- 3 files changed, 37 insertions(+), 20 deletions(-) diff --git a/dotnet/samples/02-agents/AgentSkills/SubprocessScriptExecutor.cs b/dotnet/samples/02-agents/AgentSkills/SubprocessScriptExecutor.cs index 9f7c58bf99..8836ee829a 100644 --- a/dotnet/samples/02-agents/AgentSkills/SubprocessScriptExecutor.cs +++ b/dotnet/samples/02-agents/AgentSkills/SubprocessScriptExecutor.cs @@ -21,15 +21,15 @@ internal static class SubprocessScriptExecutor /// /// Runs a skill script as a local subprocess. /// - public static Task ExecuteAsync( - AgentSkill skill, + public static async Task ExecuteAsync( + AgentFileSkill skill, AgentFileSkillScript script, AIFunctionArguments arguments, CancellationToken cancellationToken) { if (!File.Exists(script.FullPath)) { - return Task.FromResult($"Error: Script file not found: {script.FullPath}"); + return $"Error: Script file not found: {script.FullPath}"; } string extension = Path.GetExtension(script.FullPath); @@ -85,29 +85,45 @@ internal static class SubprocessScriptExecutor using var process = Process.Start(startInfo); if (process is null) { - return Task.FromResult($"Error: Failed to start process for script '{script.Name}'."); + return $"Error: Failed to start process for script '{script.Name}'."; } - string output = process.StandardOutput.ReadToEnd(); - string error = process.StandardError.ReadToEnd(); + try + { + Task outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken); + Task errorTask = process.StandardError.ReadToEndAsync(cancellationToken); - process.WaitForExit(TimeSpan.FromSeconds(30)); + await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); - if (!string.IsNullOrEmpty(error)) - { - output += $"\nStderr:\n{error}"; - } + string output = await outputTask.ConfigureAwait(false); + string error = await errorTask.ConfigureAwait(false); - if (process.ExitCode != 0) + if (!string.IsNullOrEmpty(error)) + { + output += $"\nStderr:\n{error}"; + } + + if (process.ExitCode != 0) + { + output += $"\nScript exited with code {process.ExitCode}"; + } + + return string.IsNullOrEmpty(output) ? "(no output)" : output.Trim(); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { - output += $"\nScript exited with code {process.ExitCode}"; + // Kill the process on cancellation to avoid leaving orphaned subprocesses. + process.Kill(entireProcessTree: true); + throw; } - - return Task.FromResult(string.IsNullOrEmpty(output) ? "(no output)" : output.Trim()); + } + catch (OperationCanceledException) + { + throw; } catch (Exception ex) { - return Task.FromResult($"Error: Failed to execute script '{script.Name}': {ex.Message}"); + return $"Error: Failed to execute script '{script.Name}': {ex.Message}"; } } diff --git a/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/HostAgentFactory.cs b/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/HostAgentFactory.cs index 584b7db422..1149f9a293 100644 --- a/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/HostAgentFactory.cs +++ b/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/HostAgentFactory.cs @@ -59,7 +59,7 @@ private static AgentCard GetInvoiceAgentCard() PushNotifications = false, }; - var invoiceQuery = new AgentSkill() + var invoiceQuery = new A2A.AgentSkill() { Id = "id_invoice_agent", Name = "InvoiceQuery", @@ -91,7 +91,7 @@ private static AgentCard GetPolicyAgentCard() PushNotifications = false, }; - var policyQuery = new AgentSkill() + var policyQuery = new A2A.AgentSkill() { Id = "id_policy_agent", Name = "PolicyAgent", @@ -123,7 +123,7 @@ private static AgentCard GetLogisticsAgentCard() PushNotifications = false, }; - var logisticsQuery = new AgentSkill() + var logisticsQuery = new A2A.AgentSkill() { Id = "id_logistics_agent", Name = "LogisticsQuery", diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProvider.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProvider.cs index 2722c91c00..5a515dd9f8 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProvider.cs @@ -224,7 +224,8 @@ private string LoadSkill(IList skills, string skillName) return "Error: Script name cannot be empty."; } - var skill = skills?.FirstOrDefault(skill => skill.Frontmatter.Name == skillName); if (skill == null) + var skill = skills?.FirstOrDefault(skill => skill.Frontmatter.Name == skillName); + if (skill == null) { return $"Error: Skill '{skillName}' not found."; } From b23dabdc2793cf1e2f8a7608320a92f2e84aa503 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Date: Mon, 23 Mar 2026 23:05:53 +0000 Subject: [PATCH 03/14] address comments --- .../Decorators/CachingAgentSkillsSource.cs | 34 ++++++++++++++++--- .../Decorators/FilteringAgentSkillsSource.cs | 2 +- .../DeduplicatingAgentSkillsSourceTests.cs | 8 +---- .../AgentSkills/FileAgentSkillLoaderTests.cs | 2 +- 4 files changed, 32 insertions(+), 14 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/CachingAgentSkillsSource.cs b/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/CachingAgentSkillsSource.cs index 475a9a5d87..563d165ae0 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/CachingAgentSkillsSource.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/CachingAgentSkillsSource.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Threading; @@ -12,13 +13,13 @@ namespace Microsoft.Agents.AI; /// A skill source decorator that caches the result of the first call. /// /// -/// Thread-safe: concurrent first callers may redundantly load from the inner source, but the result -/// is idempotent and subsequent calls always return the cached value. +/// Thread-safe: the first concurrent caller loads from the inner source and all other callers +/// await the same in-flight task. If the load fails, the field is reset so future callers can retry. /// [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] public sealed class CachingAgentSkillsSource : DelegatingAgentSkillsSource { - private IList? _cachedSkills; + private Task>? _loadTask; /// /// Initializes a new instance of the class. @@ -30,8 +31,31 @@ public CachingAgentSkillsSource(AgentSkillsSource innerSource) } /// - public override async Task> GetSkillsAsync(CancellationToken cancellationToken = default) + public override Task> GetSkillsAsync(CancellationToken cancellationToken = default) { - return this._cachedSkills ??= await this.InnerSource.GetSkillsAsync(cancellationToken).ConfigureAwait(false); + return this._loadTask ?? this.LoadAsync(cancellationToken); + } + + private async Task> LoadAsync(CancellationToken cancellationToken) + { + var tcs = new TaskCompletionSource>(TaskCreationOptions.RunContinuationsAsynchronously); + + if (Interlocked.CompareExchange(ref this._loadTask, tcs.Task, null) is { } existing) + { + return await existing.ConfigureAwait(false); + } + + try + { + var result = await this.InnerSource.GetSkillsAsync(cancellationToken).ConfigureAwait(false); + tcs.SetResult(result); + return result; + } + catch (Exception ex) + { + this._loadTask = null; + tcs.TrySetException(ex); + throw; + } } } diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/FilteringAgentSkillsSource.cs b/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/FilteringAgentSkillsSource.cs index 09d6ab0d78..24d266eb8d 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/FilteringAgentSkillsSource.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/FilteringAgentSkillsSource.cs @@ -18,7 +18,7 @@ namespace Microsoft.Agents.AI; /// /// Skills for which the predicate returns are included in the result; /// skills for which it returns are excluded and logged at -/// level. +/// level. /// [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] public sealed partial class FilteringAgentSkillsSource : DelegatingAgentSkillsSource diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/DeduplicatingAgentSkillsSourceTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/DeduplicatingAgentSkillsSourceTests.cs index 3c3f9dcbb3..860f402005 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/DeduplicatingAgentSkillsSourceTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/DeduplicatingAgentSkillsSourceTests.cs @@ -53,13 +53,7 @@ public async Task GetSkillsAsync_WithDuplicates_KeepsFirstOccurrenceAsync() [Fact] public async Task GetSkillsAsync_CaseInsensitiveDuplication_KeepsFirstAsync() { - // Arrange - _ = new AgentSkill[] - { - new TestAgentSkill("my-skill", "Lowercase", "Instructions."), - }; - - // Use a custom source that returns skills with same name but different casing + // Arrange — use a custom source that returns skills with same name but different casing var inner = new FakeDuplicateCaseSource(); var source = new DeduplicatingAgentSkillsSource(inner); diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs index a4741431fe..3c1a77762a 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs @@ -447,7 +447,7 @@ public async Task ReadSkillResourceAsync_ValidResource_ReturnsContentAsync() var resource = skills[0].Resources!.First(r => r.Name == "refs/doc.md"); // Act - var content = await resource.ReadAsync(new Extensions.AI.AIFunctionArguments()); + var content = await resource.ReadAsync([]); // Assert Assert.Equal("Document content here.", content); From eefa6da34731dabea7533e3084447e06c8ad45ac Mon Sep 17 00:00:00 2001 From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Date: Mon, 23 Mar 2026 23:16:34 +0000 Subject: [PATCH 04/14] address comments --- .../Skills/Decorators/FilteringAgentSkillsSource.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/FilteringAgentSkillsSource.cs b/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/FilteringAgentSkillsSource.cs index 24d266eb8d..0249440826 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/FilteringAgentSkillsSource.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/FilteringAgentSkillsSource.cs @@ -17,8 +17,7 @@ namespace Microsoft.Agents.AI; /// /// /// Skills for which the predicate returns are included in the result; -/// skills for which it returns are excluded and logged at -/// level. +/// skills for which it returns are excluded and logged at debug level. /// [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] public sealed partial class FilteringAgentSkillsSource : DelegatingAgentSkillsSource From 2486d7d78e9efe71c512646dd506db8b9cf7c672 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Date: Wed, 25 Mar 2026 21:33:56 +0000 Subject: [PATCH 05/14] address comments --- .../Agent_Step01_FileBasedSkills/Program.cs | 9 +- .../Agent_Step01_FileBasedSkills/README.md | 2 +- .../Skills/AgentSkillFrontmatter.cs | 6 +- .../Skills/AgentSkillResource.cs | 6 +- .../Skills/AgentSkillScript.cs | 4 +- .../Skills/AgentSkillsProvider.cs | 143 ++++++++++-- .../Skills/AgentSkillsProviderBuilder.cs | 92 +++----- .../Skills/AgentSkillsProviderOptions.cs | 13 +- .../Skills/AggregatingAgentSkillsSource.cs | 46 ++++ .../Decorators/CachingAgentSkillsSource.cs | 2 +- .../DeduplicatingAgentSkillsSource.cs | 4 +- .../Decorators/DelegatingAgentSkillsSource.cs | 2 +- .../Decorators/FilteringAgentSkillsSource.cs | 2 +- .../Skills/File/AgentFileSkill.cs | 8 +- .../Skills/File/AgentFileSkillResource.cs | 4 +- .../Skills/File/AgentFileSkillScript.cs | 25 +- .../Skills/File/AgentFileSkillScriptRunner.cs | 27 +++ .../Skills/File/AgentFileSkillsSource.cs | 28 +-- .../File/AgentFileSkillsSourceOptions.cs | 2 +- .../AgentSkills/AgentFileSkillScriptTests.cs | 38 +-- .../AgentFileSkillsSourceScriptTests.cs | 8 +- .../AgentSkillsProviderBuilderTests.cs | 19 +- .../AgentSkills/AgentSkillsProviderTests.cs | 220 +++++++++++------- .../AgentSkills/FileAgentSkillLoaderTests.cs | 4 +- 24 files changed, 455 insertions(+), 259 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/AggregatingAgentSkillsSource.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillScriptRunner.cs diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/Program.cs b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/Program.cs index ebddb34a8a..923a6fe868 100644 --- a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/Program.cs +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/Program.cs @@ -20,11 +20,10 @@ // --- Skills Provider --- // Discovers skills from the 'skills' directory containing SKILL.md files. -// The script executor runs file-based scripts (e.g. Python) as local subprocesses. -var skillsProvider = new AgentSkillsProviderBuilder() - .UseFileSkill(Path.Combine(AppContext.BaseDirectory, "skills")) - .UseFileScriptExecutor(SubprocessScriptExecutor.ExecuteAsync) - .Build(); +// The script runner runs file-based scripts (e.g. Python) as local subprocesses. +var skillsProvider = new AgentSkillsProvider( + Path.Combine(AppContext.BaseDirectory, "skills"), + SubprocessScriptExecutor.ExecuteAsync); // --- Agent Setup --- AIAgent agent = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()) .GetResponsesClient() diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/README.md b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/README.md index 0f0dbb960a..41b813b98f 100644 --- a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/README.md +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/README.md @@ -6,7 +6,7 @@ This sample demonstrates how to use **file-based Agent Skills** with a `ChatClie - Discovering skills from `SKILL.md` files on disk via `AgentFileSkillsSource` - The progressive disclosure pattern: advertise → load → read resources → run scripts -- Using the `AgentSkillsProviderBuilder` with `UseFileSkill` and `UseFileScriptExecutor` +- Using the `AgentSkillsProvider` constructor with a skill directory path and script executor - Running file-based scripts (Python) via a subprocess-based executor ## Skills Included diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillFrontmatter.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillFrontmatter.cs index 992f396a36..5a3ad4d296 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillFrontmatter.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillFrontmatter.cs @@ -9,7 +9,7 @@ namespace Microsoft.Agents.AI; /// -/// Represents the frontmatter metadata for an agent skill. +/// Represents the YAML frontmatter metadata parsed from a SKILL.md file. /// /// /// @@ -19,10 +19,6 @@ namespace Microsoft.Agents.AI; /// without loading the full skill content. /// /// -/// Instances can be parsed from the YAML frontmatter of a SKILL.md file (see ) -/// or constructed programmatically. -/// -/// /// The constructor validates the name and description against specification rules /// and throws if either value is invalid. /// diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillResource.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillResource.cs index ef477c23c7..b3cfc3f117 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillResource.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillResource.cs @@ -1,9 +1,9 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.Diagnostics.CodeAnalysis; using System.Threading; using System.Threading.Tasks; -using Microsoft.Extensions.AI; using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; @@ -39,8 +39,8 @@ protected AgentSkillResource(string name, string? description = null) /// /// Reads the resource content asynchronously. /// - /// Arguments for the resource read operation. + /// Optional service provider for dependency injection. /// Cancellation token. /// The resource content. - public abstract Task ReadAsync(AIFunctionArguments arguments, CancellationToken cancellationToken = default); + public abstract Task ReadAsync(IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default); } diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillScript.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillScript.cs index 00c7836c7a..ad647d2eb0 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillScript.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillScript.cs @@ -37,11 +37,11 @@ protected AgentSkillScript(string name, string? description = null) public string? Description { get; } /// - /// Executes the script with the given arguments. + /// Runs the script with the given arguments. /// /// The skill that owns this script. /// Arguments for script execution. /// Cancellation token. /// The script execution result. - public abstract Task ExecuteAsync(AgentSkill skill, AIFunctionArguments arguments, CancellationToken cancellationToken = default); + public abstract Task RunAsync(AgentSkill skill, AIFunctionArguments arguments, CancellationToken cancellationToken = default); } diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProvider.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProvider.cs index 5a515dd9f8..c66eea8118 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProvider.cs @@ -22,7 +22,7 @@ namespace Microsoft.Agents.AI; /// /// /// This provider implements the progressive disclosure pattern from the -/// Agent Skills specification: +/// Agent Skills specification: /// /// /// Advertise — skill names and descriptions are injected into the system prompt. @@ -40,9 +40,14 @@ public sealed partial class AgentSkillsProvider : AIContextProvider private const string SkillsPlaceholder = "{skills}"; /// - /// Placeholder token for the runner/script instructions in the prompt template. + /// Placeholder token for the script instructions in the prompt template. /// - private const string RunnerInstructionsPlaceholder = "{runner_instructions}"; + private const string ScriptInstructionsPlaceholder = "{script_instructions}"; + + /// + /// Placeholder token for the resource instructions in the prompt template. + /// + private const string ResourceInstructionsPlaceholder = "{resource_instructions}"; private const string DefaultSkillsInstructionPrompt = """ @@ -56,18 +61,65 @@ You have access to skills containing domain-specific knowledge and capabilities. When a task aligns with a skill's domain, follow these steps in exact order: - Use `load_skill` to retrieve the skill's instructions. - Follow the provided guidance. - - Use `read_skill_resource` to read any referenced resources, using the name exactly as listed - (e.g. `"style-guide"` not `"style-guide.md"`, `"references/FAQ.md"` not `"FAQ.md"`). - {runner_instructions} + {resource_instructions} + {script_instructions} Only load what is needed, when it is needed. """; private readonly AgentSkillsSource _source; private readonly AgentSkillsProviderOptions? _options; private readonly ILogger _logger; + private Task? _contextTask; + + /// + /// Initializes a new instance of the class + /// that discovers file-based skills from a single directory. + /// Duplicate skill names are automatically deduplicated (first occurrence wins). + /// + /// Path to search for skills. + /// The delegate that runs file-based scripts. + /// Optional options that control skill discovery behavior. + /// Optional provider configuration. + /// Optional logger factory. + public AgentSkillsProvider( + string skillPath, + AgentFileSkillScriptRunner scriptRunner, + AgentFileSkillsSourceOptions? fileOptions = null, + AgentSkillsProviderOptions? options = null, + ILoggerFactory? loggerFactory = null) + : this([Throw.IfNull(skillPath)], scriptRunner, fileOptions, options, loggerFactory) + { + } + + /// + /// Initializes a new instance of the class + /// that discovers file-based skills from multiple directories. + /// Duplicate skill names are automatically deduplicated (first occurrence wins). + /// + /// Paths to search for skills. + /// The delegate that runs file-based scripts. + /// Optional options that control skill discovery behavior. + /// Optional provider configuration. + /// Optional logger factory. + public AgentSkillsProvider( + IEnumerable skillPaths, + AgentFileSkillScriptRunner scriptRunner, + AgentFileSkillsSourceOptions? fileOptions = null, + AgentSkillsProviderOptions? options = null, + ILoggerFactory? loggerFactory = null) + : this( + new DeduplicatingAgentSkillsSource( + new AgentFileSkillsSource(skillPaths, Throw.IfNull(scriptRunner), fileOptions, loggerFactory), + loggerFactory), + options, + loggerFactory) + { + } /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class + /// from a custom . Unlike other constructors, this one does not + /// apply automatic deduplication, allowing callers to customize deduplication behavior via the source pipeline. /// /// The skill source providing skills. /// Optional configuration. @@ -86,6 +138,16 @@ public AgentSkillsProvider(AgentSkillsSource source, AgentSkillsProviderOptions? /// protected override async ValueTask ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default) + { + if (this._options?.DisableCaching == true) + { + return await this.CreateContextAsync(context, cancellationToken).ConfigureAwait(false); + } + + return await this.GetOrCreateContextAsync(context, cancellationToken).ConfigureAwait(false); + } + + private async Task CreateContextAsync(InvokingContext context, CancellationToken cancellationToken) { var skills = await this._source.GetSkillsAsync(cancellationToken).ConfigureAwait(false); if (skills is not { Count: > 0 }) @@ -94,15 +156,39 @@ protected override async ValueTask ProvideAIContextAsync(InvokingCont } bool hasScripts = skills.Any(s => s.Scripts is { Count: > 0 }); + bool hasResources = skills.Any(s => s.Resources is { Count: > 0 }); return new AIContext { - Instructions = this.BuildSkillsInstructions(skills, includeScriptInstructions: hasScripts), - Tools = this.BuildTools(skills, hasScripts), + Instructions = this.BuildSkillsInstructions(skills, includeScriptInstructions: hasScripts, hasResources), + Tools = this.BuildTools(skills, hasScripts, hasResources), }; } - private IList BuildTools(IList skills, bool hasScripts) + private async Task GetOrCreateContextAsync(InvokingContext context, CancellationToken cancellationToken) + { + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + if (Interlocked.CompareExchange(ref this._contextTask, tcs.Task, null) is { } existing) + { + return await existing.ConfigureAwait(false); + } + + try + { + var result = await this.CreateContextAsync(context, cancellationToken).ConfigureAwait(false); + tcs.SetResult(result); + return result; + } + catch (Exception ex) + { + this._contextTask = null; + tcs.TrySetException(ex); + throw; + } + } + + private IList BuildTools(IList skills, bool hasScripts, bool hasResources) { IList tools = [ @@ -110,12 +196,16 @@ private IList BuildTools(IList skills, bool hasScripts) (string skillName) => this.LoadSkill(skills, skillName), name: "load_skill", description: "Loads the full content of a specific skill"), - AIFunctionFactory.Create( + ]; + + if (hasResources) + { + tools.Add(AIFunctionFactory.Create( (string skillName, string resourceName, IServiceProvider? serviceProvider, CancellationToken cancellationToken = default) => this.ReadSkillResourceAsync(skills, skillName, resourceName, serviceProvider, cancellationToken), name: "read_skill_resource", - description: "Reads a resource associated with a skill, such as references, assets, or dynamic data."), - ]; + description: "Reads a resource associated with a skill, such as references, assets, or dynamic data.")); + } if (!hasScripts) { @@ -136,7 +226,7 @@ private IList BuildTools(IList skills, bool hasScripts) return [.. tools, scriptFunction]; } - private string? BuildSkillsInstructions(IList skills, bool includeScriptInstructions) + private string? BuildSkillsInstructions(IList skills, bool includeScriptInstructions, bool includeResourceInstructions) { string promptTemplate = this._options?.SkillsInstructionPrompt ?? DefaultSkillsInstructionPrompt; @@ -149,13 +239,21 @@ private IList BuildTools(IList skills, bool hasScripts) sb.AppendLine(" "); } + string resourceInstruction = includeResourceInstructions + ? """ + - Use `read_skill_resource` to read any referenced resources, using the name exactly as listed + (e.g. `"style-guide"` not `"style-guide.md"`, `"references/FAQ.md"` not `"FAQ.md"`). + """ + : string.Empty; + string scriptInstruction = includeScriptInstructions ? "- Use `run_skill_script` to run referenced scripts, using the name exactly as listed." : string.Empty; return new StringBuilder(promptTemplate) .Replace(SkillsPlaceholder, sb.ToString().TrimEnd()) - .Replace(RunnerInstructionsPlaceholder, scriptInstruction) + .Replace(ResourceInstructionsPlaceholder, resourceInstruction) + .Replace(ScriptInstructionsPlaceholder, scriptInstruction) .ToString(); } @@ -203,7 +301,7 @@ private string LoadSkill(IList skills, string skillName) try { - return await resource.ReadAsync(new AIFunctionArguments() { Services = serviceProvider }, cancellationToken).ConfigureAwait(false); + return await resource.ReadAsync(serviceProvider, cancellationToken).ConfigureAwait(false); } catch (Exception ex) { @@ -238,7 +336,7 @@ private string LoadSkill(IList skills, string skillName) try { - return await script.ExecuteAsync(skill, new AIFunctionArguments(arguments) { Services = serviceProvider }, cancellationToken).ConfigureAwait(false); + return await script.RunAsync(skill, new AIFunctionArguments(arguments) { Services = serviceProvider }, cancellationToken).ConfigureAwait(false); } catch (Exception ex) { @@ -259,10 +357,17 @@ private static void ValidatePromptTemplate(string template, string paramName) paramName); } - if (template.IndexOf(RunnerInstructionsPlaceholder, StringComparison.Ordinal) < 0) + if (template.IndexOf(ResourceInstructionsPlaceholder, StringComparison.Ordinal) < 0) + { + throw new ArgumentException( + $"The custom prompt template must contain the '{ResourceInstructionsPlaceholder}' placeholder for resource instructions.", + paramName); + } + + if (template.IndexOf(ScriptInstructionsPlaceholder, StringComparison.Ordinal) < 0) { throw new ArgumentException( - $"The custom prompt template must contain the '{RunnerInstructionsPlaceholder}' placeholder for script runner instructions.", + $"The custom prompt template must contain the '{ScriptInstructionsPlaceholder}' placeholder for script instructions.", paramName); } } diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProviderBuilder.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProviderBuilder.cs index 2e38781677..35b8285fad 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProviderBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProviderBuilder.cs @@ -10,7 +10,7 @@ namespace Microsoft.Agents.AI; /// -/// Fluent builder for constructing an backed by one or more skill sources. +/// Fluent builder for constructing an backed by a composite source. /// /// /// @@ -22,62 +22,45 @@ namespace Microsoft.Agents.AI; [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] public sealed class AgentSkillsProviderBuilder { - private readonly List> _sourceFactories = []; + private readonly List> _sourceFactories = []; private AgentSkillsProviderOptions? _options; private ILoggerFactory? _loggerFactory; - private AgentFileSkillScriptExecutor? _scriptExecutor; + private AgentFileSkillScriptRunner? _scriptRunner; private Func? _filter; - private bool _cacheSkills = true; /// /// Adds a file-based skill source that discovers skills from a filesystem directory. /// - /// - /// The script executor is resolved using the following fallback order: - /// - /// The passed to this method, if provided. - /// The builder-level executor set via . - /// - /// If neither is available, throws . - /// /// Path to search for skills. /// Optional options that control skill discovery behavior. - /// - /// Optional executor for file-based scripts. When provided, overrides the builder-level executor - /// set via for this source. + /// + /// Optional runner for file-based scripts. When provided, overrides the builder-level runner + /// set via . /// /// This builder instance for chaining. - public AgentSkillsProviderBuilder UseFileSkill(string skillPath, AgentFileSkillsSourceOptions? options = null, AgentFileSkillScriptExecutor? scriptExecutor = null) + public AgentSkillsProviderBuilder UseFileSkill(string skillPath, AgentFileSkillsSourceOptions? options = null, AgentFileSkillScriptRunner? scriptRunner = null) { - return this.UseFileSkills([skillPath], options, scriptExecutor); + return this.UseFileSkills([skillPath], options, scriptRunner); } /// /// Adds a file-based skill source that discovers skills from multiple filesystem directories. /// - /// - /// The script executor is resolved using the following fallback order: - /// - /// The passed to this method, if provided. - /// The builder-level executor set via . - /// - /// If neither is available, throws . - /// /// Paths to search for skills. /// Optional options that control skill discovery behavior. - /// - /// Optional executor for file-based scripts. When provided, overrides the builder-level executor - /// set via for this source. + /// + /// Optional runner for file-based scripts. When provided, overrides the builder-level runner + /// set via . /// /// This builder instance for chaining. - public AgentSkillsProviderBuilder UseFileSkills(IEnumerable skillPaths, AgentFileSkillsSourceOptions? options = null, AgentFileSkillScriptExecutor? scriptExecutor = null) + public AgentSkillsProviderBuilder UseFileSkills(IEnumerable skillPaths, AgentFileSkillsSourceOptions? options = null, AgentFileSkillScriptRunner? scriptRunner = null) { - this._sourceFactories.Add((builderScriptExecutor, loggerFactory) => + this._sourceFactories.Add((builderScriptRunner, loggerFactory) => { - var resolvedExecutor = scriptExecutor - ?? builderScriptExecutor - ?? throw new InvalidOperationException($"File-based skill sources require a script executor. Call {nameof(this.UseFileScriptExecutor)} or pass an executor to {nameof(this.UseFileSkill)}/{nameof(this.UseFileSkills)}."); - return new AgentFileSkillsSource(skillPaths, resolvedExecutor, options, loggerFactory); + var resolvedRunner = scriptRunner + ?? builderScriptRunner + ?? throw new InvalidOperationException($"File-based skill sources require a script runner. Call {nameof(this.UseFileScriptRunner)} or pass a runner to {nameof(this.UseFileSkill)}/{nameof(this.UseFileSkills)}."); + return new AgentFileSkillsSource(skillPaths, resolvedRunner, options, loggerFactory); }); return this; } @@ -97,8 +80,9 @@ public AgentSkillsProviderBuilder UseSource(AgentSkillsSource source) /// /// Sets a custom system prompt template. /// - /// The prompt template with {skills} placeholder for the skills list - /// and {runner_instructions} for optional script runner instructions. + /// The prompt template with {skills} placeholder for the skills list, + /// {resource_instructions} for optional resource instructions, + /// and {script_instructions} for optional script instructions. /// This builder instance for chaining. public AgentSkillsProviderBuilder UsePromptTemplate(string promptTemplate) { @@ -118,13 +102,13 @@ public AgentSkillsProviderBuilder UseScriptApproval(bool enabled = true) } /// - /// Sets the executor for file-based skill scripts. + /// Sets the runner for file-based skill scripts. /// - /// The delegate that executes file-based scripts. + /// The delegate that runs file-based scripts. /// This builder instance for chaining. - public AgentSkillsProviderBuilder UseFileScriptExecutor(AgentFileSkillScriptExecutor executor) + public AgentSkillsProviderBuilder UseFileScriptRunner(AgentFileSkillScriptRunner runner) { - this._scriptExecutor = Throw.IfNull(executor); + this._scriptRunner = Throw.IfNull(runner); return this; } @@ -156,17 +140,6 @@ public AgentSkillsProviderBuilder UseFilter(Func predicate) return this; } - /// - /// Enables or disables skill caching after the first load. - /// - /// to cache skills (default); to reload from sources on every call. - /// This builder instance for chaining. - public AgentSkillsProviderBuilder UseCache(bool enabled = true) - { - this._cacheSkills = enabled; - return this; - } - /// /// Configures the using the provided delegate. /// @@ -185,15 +158,10 @@ public AgentSkillsProviderBuilder UseOptions(Action /// A configured . public AgentSkillsProvider Build() { - if (this._sourceFactories.Count == 0) - { - throw new InvalidOperationException("At least one skill source must be configured."); - } - var resolvedSources = new List(this._sourceFactories.Count); foreach (var factory in this._sourceFactories) { - resolvedSources.Add(factory(this._scriptExecutor, this._loggerFactory)); + resolvedSources.Add(factory(this._scriptRunner, this._loggerFactory)); } AgentSkillsSource source; @@ -203,23 +171,17 @@ public AgentSkillsProvider Build() } else { - source = new AggregateAgentSkillsSource(resolvedSources); + source = new AggregatingAgentSkillsSource(resolvedSources); } - // Apply user-specified filter, then dedup, then optionally cache. + // Apply user-specified filter, then dedup. if (this._filter != null) { source = new FilteringAgentSkillsSource(source, this._filter, this._loggerFactory); } - // Wrap with dedup (first) then caching so duplicates are resolved before the result is cached. source = new DeduplicatingAgentSkillsSource(source, this._loggerFactory); - if (this._cacheSkills) - { - source = new CachingAgentSkillsSource(source); - } - return new AgentSkillsProvider(source, this._options, this._loggerFactory); } diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProviderOptions.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProviderOptions.cs index 90cd3fa39e..2f89ebfda6 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProviderOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProviderOptions.cs @@ -13,8 +13,9 @@ public sealed class AgentSkillsProviderOptions { /// /// Gets or sets a custom system prompt template for advertising skills. - /// The template must contain {skills} as the placeholder for the generated skills list - /// and {runner_instructions} for script runner instructions. + /// The template must contain {skills} as the placeholder for the generated skills list, + /// {resource_instructions} for resource instructions, + /// and {script_instructions} for script instructions. /// When , a default template is used. /// public string? SkillsInstructionPrompt { get; set; } @@ -25,4 +26,12 @@ public sealed class AgentSkillsProviderOptions /// Defaults to . /// public bool ScriptApproval { get; set; } + + /// + /// Gets or sets a value indicating whether caching of tools and instructions is disabled. + /// When (the default), the provider caches the tools and instructions + /// after the first build and returns the cached instance on subsequent calls. + /// Set to to rebuild tools and instructions on every invocation. + /// + public bool DisableCaching { get; set; } } diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AggregatingAgentSkillsSource.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AggregatingAgentSkillsSource.cs new file mode 100644 index 0000000000..bd0a71e029 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/AggregatingAgentSkillsSource.cs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// A skill source that aggregates multiple child sources, preserving their registration order. +/// +/// +/// Skills from each child source are returned in the order the sources were registered, +/// with each source's skills appended sequentially. No deduplication or filtering is applied. +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +internal sealed class AggregatingAgentSkillsSource : AgentSkillsSource +{ + private readonly IEnumerable _sources; + + /// + /// Initializes a new instance of the class. + /// + /// The child sources to aggregate. + public AggregatingAgentSkillsSource(IEnumerable sources) + { + _ = Throw.IfNull(sources); + this._sources = sources; + } + + /// + public override async Task> GetSkillsAsync(CancellationToken cancellationToken = default) + { + var allSkills = new List(); + foreach (var source in this._sources) + { + var skills = await source.GetSkillsAsync(cancellationToken).ConfigureAwait(false); + allSkills.AddRange(skills); + } + + return allSkills; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/CachingAgentSkillsSource.cs b/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/CachingAgentSkillsSource.cs index 563d165ae0..759da9bf0c 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/CachingAgentSkillsSource.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/CachingAgentSkillsSource.cs @@ -17,7 +17,7 @@ namespace Microsoft.Agents.AI; /// await the same in-flight task. If the load fails, the field is reset so future callers can retry. /// [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] -public sealed class CachingAgentSkillsSource : DelegatingAgentSkillsSource +internal sealed class CachingAgentSkillsSource : DelegatingAgentSkillsSource { private Task>? _loadTask; diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/DeduplicatingAgentSkillsSource.cs b/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/DeduplicatingAgentSkillsSource.cs index caaab40158..bf943daae5 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/DeduplicatingAgentSkillsSource.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/DeduplicatingAgentSkillsSource.cs @@ -12,10 +12,10 @@ namespace Microsoft.Agents.AI; /// -/// A skill source decorator that removes duplicate skills by name (case-insensitive), keeping only the first occurrence. +/// A skill source decorator that removes duplicate skills by name, keeping only the first occurrence. /// [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] -public sealed partial class DeduplicatingAgentSkillsSource : DelegatingAgentSkillsSource +internal sealed partial class DeduplicatingAgentSkillsSource : DelegatingAgentSkillsSource { private readonly ILogger _logger; diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/DelegatingAgentSkillsSource.cs b/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/DelegatingAgentSkillsSource.cs index 900d33be46..920ad0428b 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/DelegatingAgentSkillsSource.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/DelegatingAgentSkillsSource.cs @@ -19,7 +19,7 @@ namespace Microsoft.Agents.AI; /// filtering, etc.) while delegating core operations to an underlying source. /// [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] -public abstract class DelegatingAgentSkillsSource : AgentSkillsSource +internal abstract class DelegatingAgentSkillsSource : AgentSkillsSource { /// /// Initializes a new instance of the class with the specified inner source. diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/FilteringAgentSkillsSource.cs b/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/FilteringAgentSkillsSource.cs index 0249440826..2bd26acce2 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/FilteringAgentSkillsSource.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/FilteringAgentSkillsSource.cs @@ -20,7 +20,7 @@ namespace Microsoft.Agents.AI; /// skills for which it returns are excluded and logged at debug level. /// [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] -public sealed partial class FilteringAgentSkillsSource : DelegatingAgentSkillsSource +internal sealed partial class FilteringAgentSkillsSource : DelegatingAgentSkillsSource { private readonly Func _predicate; private readonly ILogger _logger; diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkill.cs b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkill.cs index 0f68c0f912..4bb62e99a8 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkill.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkill.cs @@ -49,13 +49,9 @@ internal AgentFileSkill( /// public string Path { get; } - /// - /// Gets the resources discovered for this skill. Returns an empty list when the skill has no resources. - /// + /// public override IReadOnlyList Resources => this._resources; - /// - /// Gets the scripts discovered for this skill. Returns an empty list when the skill has no scripts. - /// + /// public override IReadOnlyList Scripts => this._scripts; } diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillResource.cs b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillResource.cs index d4dac48ad7..51e45a2a49 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillResource.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillResource.cs @@ -1,10 +1,10 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.IO; using System.Text; using System.Threading; using System.Threading.Tasks; -using Microsoft.Extensions.AI; using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI; @@ -33,7 +33,7 @@ public AgentFileSkillResource(string name, string fullPath) /// #pragma warning disable CA1725 // Parameter names should match base declaration #pragma warning disable RCS1168 // Parameter name differs from base name - public override async Task ReadAsync(AIFunctionArguments _, CancellationToken cancellationToken = default) + public override async Task ReadAsync(IServiceProvider? _, CancellationToken cancellationToken = default) #pragma warning restore RCS1168 // Parameter name differs from base name #pragma warning restore CA1725 // Parameter names should match base declaration { diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillScript.cs b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillScript.cs index fd1c8aa2bd..7e45cc87f2 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillScript.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillScript.cs @@ -11,24 +11,24 @@ namespace Microsoft.Agents.AI; /// -/// A file-path-backed skill script. Represents a script file on disk that requires an external executor to run. +/// A file-path-backed skill script. Represents a script file on disk that requires an external runner to run. /// [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] public sealed class AgentFileSkillScript : AgentSkillScript { - private readonly AgentFileSkillScriptExecutor _executor; + private readonly AgentFileSkillScriptRunner _runner; /// /// Initializes a new instance of the class. /// /// The script name. /// The absolute file path to the script. - /// External executor for running the script. - internal AgentFileSkillScript(string name, string fullPath, AgentFileSkillScriptExecutor executor) + /// External runner for running the script. + internal AgentFileSkillScript(string name, string fullPath, AgentFileSkillScriptRunner runner) : base(name) { this.FullPath = Throw.IfNullOrWhitespace(fullPath); - this._executor = Throw.IfNull(executor); + this._runner = Throw.IfNull(runner); } /// @@ -36,23 +36,14 @@ internal AgentFileSkillScript(string name, string fullPath, AgentFileSkillScript /// public string FullPath { get; } - /// - /// Executes the file-based script using the configured external executor. - /// - /// The skill that owns this script. Must be an . - /// Arguments for script execution. - /// Cancellation token. - /// The script execution result. - /// - /// Thrown when is not an . - /// - public override async Task ExecuteAsync(AgentSkill skill, AIFunctionArguments arguments, CancellationToken cancellationToken = default) + /// + public override async Task RunAsync(AgentSkill skill, AIFunctionArguments arguments, CancellationToken cancellationToken = default) { if (skill is not AgentFileSkill fileSkill) { throw new InvalidOperationException($"File-based script '{this.Name}' requires an {nameof(AgentFileSkill)} but received '{skill.GetType().Name}'."); } - return await this._executor(fileSkill, this, arguments, cancellationToken).ConfigureAwait(false); + return await this._runner(fileSkill, this, arguments, cancellationToken).ConfigureAwait(false); } } diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillScriptRunner.cs b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillScriptRunner.cs new file mode 100644 index 0000000000..c19d19e056 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillScriptRunner.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI; + +/// +/// Delegate for running file-based skill scripts. +/// +/// +/// Implementations determine the execution strategy (e.g., local subprocess, hosted code execution environment). +/// +/// The skill that owns the script. +/// The file-based script to run. +/// Optional arguments for the script, provided by the agent/LLM. +/// Cancellation token. +/// The script execution result. +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public delegate Task AgentFileSkillScriptRunner( + AgentFileSkill skill, + AgentFileSkillScript script, + AIFunctionArguments arguments, + CancellationToken cancellationToken); diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSource.cs b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSource.cs index 43db8c50c8..0f74624db0 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSource.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSource.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.IO; +using System.Linq; using System.Text; using System.Text.RegularExpressions; using System.Threading; @@ -26,7 +27,7 @@ namespace Microsoft.Agents.AI; /// Resource and script paths are checked against path traversal and symlink escape attacks. /// [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] -public sealed partial class AgentFileSkillsSource : AgentSkillsSource +internal sealed partial class AgentFileSkillsSource : AgentSkillsSource { private const string SkillFileName = "SKILL.md"; private const int MaxSearchDepth = 2; @@ -55,22 +56,22 @@ public sealed partial class AgentFileSkillsSource : AgentSkillsSource private readonly IEnumerable _skillPaths; private readonly HashSet _allowedResourceExtensions; private readonly HashSet _allowedScriptExtensions; - private readonly AgentFileSkillScriptExecutor _scriptExecutor; + private readonly AgentFileSkillScriptRunner _scriptRunner; private readonly ILogger _logger; /// /// Initializes a new instance of the class. /// /// Path to search for skills. - /// Executor for file-based scripts. + /// Runner for file-based scripts. /// Optional options that control skill discovery behavior. /// Optional logger factory. public AgentFileSkillsSource( string skillPath, - AgentFileSkillScriptExecutor scriptExecutor, + AgentFileSkillScriptRunner scriptRunner, AgentFileSkillsSourceOptions? options = null, ILoggerFactory? loggerFactory = null) - : this([skillPath], scriptExecutor, options, loggerFactory) + : this([skillPath], scriptRunner, options, loggerFactory) { } @@ -78,12 +79,12 @@ public AgentFileSkillsSource( /// Initializes a new instance of the class. /// /// Paths to search for skills. - /// Executor for file-based scripts. + /// Runner for file-based scripts. /// Optional options that control skill discovery behavior. /// Optional logger factory. public AgentFileSkillsSource( IEnumerable skillPaths, - AgentFileSkillScriptExecutor scriptExecutor, + AgentFileSkillScriptRunner scriptRunner, AgentFileSkillsSourceOptions? options = null, ILoggerFactory? loggerFactory = null) { @@ -102,8 +103,7 @@ public AgentFileSkillsSource( resolvedOptions.AllowedScriptExtensions ?? s_defaultScriptExtensions, StringComparer.OrdinalIgnoreCase); - this._scriptExecutor = Throw.IfNull(scriptExecutor); - + this._scriptRunner = Throw.IfNull(scriptRunner); this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger(); } @@ -286,10 +286,10 @@ private bool TryParseFrontmatter(string content, string skillFilePath, [NotNullW /// Scans a skill directory for resource files matching the configured extensions. /// /// - /// Recursively walks and collects - /// files whose extension matches the allowed set, excluding SKILL.md itself. - /// Each candidate is validated against path-traversal and symlink-escape checks; unsafe files - /// are skipped with a warning. + /// Recursively walks and collects files whose extension + /// matches the allowed set, excluding SKILL.md itself. Each candidate + /// is validated against path-traversal and symlink-escape checks; unsafe files are skipped with + /// a warning. /// private List DiscoverResourceFiles(string skillDirectoryFullPath, string skillName) { @@ -423,7 +423,7 @@ private List DiscoverScriptFiles(string skillDirectoryFull // Compute relative path and normalize to forward slashes string relativePath = NormalizePath(resolvedFilePath.Substring(normalizedSkillDirectoryFullPath.Length)); - scripts.Add(new AgentFileSkillScript(relativePath, resolvedFilePath, this._scriptExecutor)); + scripts.Add(new AgentFileSkillScript(relativePath, resolvedFilePath, this._scriptRunner)); } return scripts; diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSourceOptions.cs b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSourceOptions.cs index b90eb12ee7..edaec327fa 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSourceOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSourceOptions.cs @@ -7,7 +7,7 @@ namespace Microsoft.Agents.AI; /// -/// Configuration options for . +/// Configuration options for file-based skill sources. /// /// /// Use this class to configure file-based skill discovery without relying on diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillScriptTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillScriptTests.cs index d2744a1e3a..eb4f706f30 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillScriptTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillScriptTests.cs @@ -13,62 +13,62 @@ namespace Microsoft.Agents.AI.UnitTests.AgentSkills; public sealed class AgentFileSkillScriptTests { [Fact] - public async Task ExecuteAsync_SkillIsNotAgentFileSkill_ThrowsInvalidOperationExceptionAsync() + public async Task RunAsync_SkillIsNotAgentFileSkill_ThrowsInvalidOperationExceptionAsync() { // Arrange - static Task ExecutorAsync(AgentFileSkill s, AgentFileSkillScript sc, AIFunctionArguments a, CancellationToken ct) => Task.FromResult("result"); - var script = CreateScript("test-script", "/path/to/script.py", ExecutorAsync); + static Task RunnerAsync(AgentFileSkill s, AgentFileSkillScript sc, AIFunctionArguments a, CancellationToken ct) => Task.FromResult("result"); + var script = CreateScript("test-script", "/path/to/script.py", RunnerAsync); var nonFileSkill = new TestAgentSkill("my-skill", "A skill", "Instructions."); // Act & Assert await Assert.ThrowsAsync( - () => script.ExecuteAsync(nonFileSkill, new AIFunctionArguments(), CancellationToken.None)); + () => script.RunAsync(nonFileSkill, new AIFunctionArguments(), CancellationToken.None)); } [Fact] - public async Task ExecuteAsync_WithAgentFileSkill_DelegatesToExecutorAsync() + public async Task RunAsync_WithAgentFileSkill_DelegatesToRunnerAsync() { // Arrange - var executorCalled = false; - Task executorAsync(AgentFileSkill skill, AgentFileSkillScript scriptArg, AIFunctionArguments args, CancellationToken ct) + var runnerCalled = false; + Task runnerAsync(AgentFileSkill skill, AgentFileSkillScript scriptArg, AIFunctionArguments args, CancellationToken ct) { - executorCalled = true; + runnerCalled = true; return Task.FromResult("executed"); } - var script = CreateScript("run-me", "/scripts/run-me.sh", executorAsync); + var script = CreateScript("run-me", "/scripts/run-me.sh", runnerAsync); var fileSkill = new AgentFileSkill( new AgentSkillFrontmatter("my-skill", "A file skill"), "---\nname: my-skill\n---\nContent", "/skills/my-skill"); // Act - var result = await script.ExecuteAsync(fileSkill, new AIFunctionArguments(), CancellationToken.None); + var result = await script.RunAsync(fileSkill, new AIFunctionArguments(), CancellationToken.None); // Assert - Assert.True(executorCalled); + Assert.True(runnerCalled); Assert.Equal("executed", result); } [Fact] - public async Task ExecuteAsync_ExecutorReceivesCorrectArgumentsAsync() + public async Task RunAsync_RunnerReceivesCorrectArgumentsAsync() { // Arrange AgentFileSkill? capturedSkill = null; AgentFileSkillScript? capturedScript = null; - Task executorAsync(AgentFileSkill skill, AgentFileSkillScript scriptArg, AIFunctionArguments args, CancellationToken ct) + Task runnerAsync(AgentFileSkill skill, AgentFileSkillScript scriptArg, AIFunctionArguments args, CancellationToken ct) { capturedSkill = skill; capturedScript = scriptArg; return Task.FromResult(null); } - var script = CreateScript("capture", "/scripts/capture.py", executorAsync); + var script = CreateScript("capture", "/scripts/capture.py", runnerAsync); var fileSkill = new AgentFileSkill( new AgentSkillFrontmatter("owner-skill", "Owner"), "Content", "/skills/owner-skill"); // Act - await script.ExecuteAsync(fileSkill, new AIFunctionArguments(), CancellationToken.None); + await script.RunAsync(fileSkill, new AIFunctionArguments(), CancellationToken.None); // Assert Assert.Same(fileSkill, capturedSkill); @@ -79,8 +79,8 @@ public async Task ExecuteAsync_ExecutorReceivesCorrectArgumentsAsync() public void Script_HasCorrectNameAndPath() { // Arrange & Act - static Task ExecutorAsync(AgentFileSkill s, AgentFileSkillScript sc, AIFunctionArguments a, CancellationToken ct) => Task.FromResult(null); - var script = CreateScript("my-script", "/path/to/my-script.py", ExecutorAsync); + static Task RunnerAsync(AgentFileSkill s, AgentFileSkillScript sc, AIFunctionArguments a, CancellationToken ct) => Task.FromResult(null); + var script = CreateScript("my-script", "/path/to/my-script.py", RunnerAsync); // Assert Assert.Equal("my-script", script.Name); @@ -90,12 +90,12 @@ public void Script_HasCorrectNameAndPath() /// /// Helper to create an via reflection since the constructor is internal. /// - private static AgentFileSkillScript CreateScript(string name, string fullPath, AgentFileSkillScriptExecutor executor) + private static AgentFileSkillScript CreateScript(string name, string fullPath, AgentFileSkillScriptRunner executor) { var ctor = typeof(AgentFileSkillScript).GetConstructor( System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance, null, - [typeof(string), typeof(string), typeof(AgentFileSkillScriptExecutor)], + [typeof(string), typeof(string), typeof(AgentFileSkillScriptRunner)], null) ?? throw new InvalidOperationException("Could not find internal constructor."); return (AgentFileSkillScript)ctor.Invoke([name, fullPath, executor]); diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillsSourceScriptTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillsSourceScriptTests.cs index 57f9a0beee..0be238b285 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillsSourceScriptTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillsSourceScriptTests.cs @@ -15,7 +15,7 @@ namespace Microsoft.Agents.AI.UnitTests.AgentSkills; public sealed class AgentFileSkillsSourceScriptTests : IDisposable { private static readonly string[] s_rubyExtension = new[] { ".rb" }; - private static readonly AgentFileSkillScriptExecutor s_noOpExecutor = (skill, script, args, ct) => Task.FromResult(null); + private static readonly AgentFileSkillScriptRunner s_noOpExecutor = (skill, script, args, ct) => Task.FromResult(null); private readonly string _testRoot; @@ -134,7 +134,7 @@ public async Task GetSkillsAsync_ScriptsOutsideScriptsDir_AreAlsoDiscoveredAsync } [Fact] - public async Task GetSkillsAsync_WithExecutor_ScriptsCanExecuteAsync() + public async Task GetSkillsAsync_WithRunner_ScriptsCanRunAsync() { // Arrange CreateSkillWithScript(this._testRoot, "exec-skill", "Executor test", "Body.", "scripts/test.py", "print('ok')"); @@ -152,7 +152,7 @@ public async Task GetSkillsAsync_WithExecutor_ScriptsCanExecuteAsync() // Act var skills = await source.GetSkillsAsync(CancellationToken.None); - var scriptResult = await skills[0].Scripts![0].ExecuteAsync(skills[0], new AIFunctionArguments(), CancellationToken.None); + var scriptResult = await skills[0].Scripts![0].RunAsync(skills[0], new AIFunctionArguments(), CancellationToken.None); // Assert Assert.True(executorCalled); @@ -204,7 +204,7 @@ public async Task GetSkillsAsync_ExecutorReceivesArgumentsAsync() ["value"] = 26.2, ["factor"] = 1.60934 }; - await skills[0].Scripts![0].ExecuteAsync(skills[0], arguments, CancellationToken.None); + await skills[0].Scripts![0].RunAsync(skills[0], arguments, CancellationToken.None); // Assert Assert.NotNull(capturedArgs); diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderBuilderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderBuilderTests.cs index 578adc9cb2..85335256a7 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderBuilderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderBuilderTests.cs @@ -20,13 +20,16 @@ private AIContextProvider.InvokingContext CreateInvokingContext() } [Fact] - public void Build_NoSourceConfigured_ThrowsInvalidOperationException() + public void Build_NoSourceConfigured_Succeeds() { // Arrange var builder = new AgentSkillsProviderBuilder(); - // Act & Assert - Assert.Throws(() => builder.Build()); + // Act + var provider = builder.Build(); + + // Assert + Assert.NotNull(provider); } [Fact] @@ -66,13 +69,13 @@ public void UseFilter_NullPredicate_ThrowsArgumentNullException() } [Fact] - public void UseFileScriptExecutor_NullExecutor_ThrowsArgumentNullException() + public void UseFileScriptRunner_NullRunner_ThrowsArgumentNullException() { // Arrange var builder = new AgentSkillsProviderBuilder(); // Act & Assert - Assert.Throws(() => builder.UseFileScriptExecutor(null!)); + Assert.Throws(() => builder.UseFileScriptRunner(null!)); } [Fact] @@ -115,7 +118,7 @@ public async Task Build_WithCacheDisabled_ReloadsOnEachCallAsync() new TestAgentSkill("skill-a", "A", "Instructions.")); var provider = new AgentSkillsProviderBuilder() .UseSource(countingSource) - .UseCache(false) + .UseOptions(o => o.DisableCaching = true) .Build(); // Act @@ -134,7 +137,6 @@ public async Task Build_WithCacheEnabled_CachesSkillsAsync() new TestAgentSkill("skill-a", "A", "Instructions.")); var provider = new AgentSkillsProviderBuilder() .UseSource(countingSource) - .UseCache(true) .Build(); // Act @@ -156,9 +158,8 @@ public void Build_FluentChaining_ReturnsSameBuilder() // Act — all fluent methods should return the same builder var result = builder .UseSource(source) - .UseCache(true) .UseScriptApproval(false) - .UsePromptTemplate("Skills:\n{skills}\n{runner_instructions}"); + .UsePromptTemplate("Skills:\n{skills}\n{resource_instructions}\n{script_instructions}"); // Assert Assert.Same(builder, result); diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderTests.cs index bcc8028291..6dfc45918a 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderTests.cs @@ -15,7 +15,7 @@ namespace Microsoft.Agents.AI.UnitTests.AgentSkills; /// public sealed class AgentSkillsProviderTests : IDisposable { - private static readonly AgentFileSkillScriptExecutor s_noOpExecutor = (skill, script, args, ct) => Task.FromResult(null); + private static readonly AgentFileSkillScriptRunner s_noOpExecutor = (skill, script, args, ct) => Task.FromResult(null); private readonly string _testRoot; private readonly TestAIAgent _agent = new(); @@ -67,11 +67,11 @@ public async Task InvokingCoreAsync_WithSkills_AppendsInstructionsAndToolsAsync( Assert.Contains("provider-skill", result.Instructions); Assert.Contains("Provider skill test", result.Instructions); - // Should have load_skill and read_skill_resource tools + // Should have load_skill tool (no resources, so no read_skill_resource) Assert.NotNull(result.Tools); var toolNames = result.Tools!.Select(t => t.Name).ToList(); Assert.Contains("load_skill", toolNames); - Assert.Contains("read_skill_resource", toolNames); + Assert.DoesNotContain("read_skill_resource", toolNames); } [Fact] @@ -98,7 +98,7 @@ public async Task InvokingCoreAsync_CustomPromptTemplate_UsesCustomTemplateAsync this.CreateSkill("custom-prompt-skill", "Custom prompt", "Body."); var options = new AgentSkillsProviderOptions { - SkillsInstructionPrompt = "Custom template: {skills}\n{runner_instructions}" + SkillsInstructionPrompt = "Custom template: {skills}\n{resource_instructions}\n{script_instructions}" }; var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor), options); var inputContext = new AIContext(); @@ -120,7 +120,7 @@ public void Constructor_PromptWithoutSkillsPlaceholder_ThrowsArgumentException() // Arrange var options = new AgentSkillsProviderOptions { - SkillsInstructionPrompt = "No skills placeholder here {runner_instructions}" + SkillsInstructionPrompt = "No skills placeholder here {resource_instructions} {script_instructions}" }; // Act & Assert @@ -136,13 +136,13 @@ public void Constructor_PromptWithoutRunnerInstructionsPlaceholder_ThrowsArgumen // Arrange var options = new AgentSkillsProviderOptions { - SkillsInstructionPrompt = "Has skills {skills} but no runner instructions" + SkillsInstructionPrompt = "Has skills {skills} but no runner instructions {resource_instructions}" }; // Act & Assert var ex = Assert.Throws(() => new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor), options)); - Assert.Contains("{runner_instructions}", ex.Message); + Assert.Contains("{script_instructions}", ex.Message); Assert.Equal("options", ex.ParamName); } @@ -152,7 +152,7 @@ public void Constructor_PromptWithBothPlaceholders_Succeeds() // Arrange var options = new AgentSkillsProviderOptions { - SkillsInstructionPrompt = "Skills: {skills}\nRunner: {runner_instructions}" + SkillsInstructionPrompt = "Skills: {skills}\nResources: {resource_instructions}\nRunner: {script_instructions}" }; // Act — should not throw @@ -162,6 +162,22 @@ public void Constructor_PromptWithBothPlaceholders_Succeeds() Assert.NotNull(provider); } + [Fact] + public void Constructor_PromptWithoutResourceInstructionsPlaceholder_ThrowsArgumentException() + { + // Arrange + var options = new AgentSkillsProviderOptions + { + SkillsInstructionPrompt = "Has skills {skills} and runner {script_instructions} but no resource instructions" + }; + + // Act & Assert + var ex = Assert.Throws(() => + new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor), options)); + Assert.Contains("{resource_instructions}", ex.Message); + Assert.Equal("options", ex.ParamName); + } + [Fact] public async Task InvokingCoreAsync_SkillNamesAreXmlEscapedAsync() { @@ -223,7 +239,6 @@ public async Task InvokingCoreAsync_PreservesExistingInputToolsAsync() var toolNames = result.Tools!.Select(t => t.Name).ToList(); Assert.Contains("existing_tool", toolNames); Assert.Contains("load_skill", toolNames); - Assert.Contains("read_skill_resource", toolNames); } [Fact] @@ -257,8 +272,7 @@ public async Task ProvideAIContextAsync_ConcurrentCalls_LoadsSkillsOnlyOnceAsync [ new TestAgentSkill("concurrent-skill", "Concurrent test", "Body.") ]); - var cachingSource = new CachingAgentSkillsSource(source); - var provider = new AgentSkillsProvider(cachingSource); + var provider = new AgentSkillsProvider(source); var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); @@ -268,7 +282,7 @@ public async Task ProvideAIContextAsync_ConcurrentCalls_LoadsSkillsOnlyOnceAsync .ToArray(); await Task.WhenAll(tasks); - // Assert — GetSkillsAsync should have been called exactly once + // Assert — GetSkillsAsync should have been called exactly once (provider-level caching) Assert.Equal(1, source.GetSkillsCallCount); } @@ -298,7 +312,6 @@ public async Task InvokingCoreAsync_WithScripts_IncludesRunSkillScriptToolAsync( var toolNames = result.Tools!.Select(t => t.Name).ToList(); Assert.Contains("run_skill_script", toolNames); Assert.Contains("load_skill", toolNames); - Assert.Contains("read_skill_resource", toolNames); } [Fact] @@ -339,8 +352,8 @@ public async Task Builder_UseFileSkillWithOptions_DiscoverSkillsAsync() var options = new AgentFileSkillsSourceOptions(); var provider = new AgentSkillsProviderBuilder() .UseFileSkill(this._testRoot, options) - .UseFileScriptExecutor(s_noOpExecutor) - .UseCache(false) + .UseFileScriptRunner(s_noOpExecutor) + .UseOptions(o => o.DisableCaching = true) .Build(); // Act @@ -365,8 +378,8 @@ public async Task Builder_UseFileSkillsWithOptions_DiscoverMultipleSkillsAsync() var options = new AgentFileSkillsSourceOptions(); var provider = new AgentSkillsProviderBuilder() .UseFileSkills(new[] { dir1, dir2 }, options) - .UseFileScriptExecutor(s_noOpExecutor) - .UseCache(false) + .UseFileScriptRunner(s_noOpExecutor) + .UseOptions(o => o.DisableCaching = true) .Build(); // Act @@ -432,7 +445,7 @@ public async Task LoadSkill_DefaultOptions_ReturnsFullContentAsync() } [Fact] - public async Task Builder_UseFileScriptExecutorAfterUseFileSkills_ExecutorIsUsedAsync() + public async Task Builder_UseFileScriptRunnerAfterUseFileSkills_RunnerIsUsedAsync() { // Arrange — create a skill with a script file string skillDir = Path.Combine(this._testRoot, "builder-skill"); @@ -446,10 +459,10 @@ public async Task Builder_UseFileScriptExecutorAfterUseFileSkills_ExecutorIsUsed var executorCalled = false; - // Act — call UseFileScriptExecutor AFTER UseFileSkill (the bug scenario) + // Act — call UseFileScriptRunner AFTER UseFileSkill (the bug scenario) var provider = new AgentSkillsProviderBuilder() .UseFileSkill(this._testRoot) - .UseFileScriptExecutor((skill, script, args, ct) => + .UseFileScriptRunner((skill, script, args, ct) => { executorCalled = true; return Task.FromResult("executed"); @@ -493,7 +506,7 @@ public async Task Build_WithCachingDisabled_ReloadsSkillsOnEachCallAsync() ]); var provider = new AgentSkillsProviderBuilder() .UseSource(source) - .UseCache(false) + .UseOptions(o => o.DisableCaching = true) .Build(); var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); @@ -516,7 +529,6 @@ public async Task Build_WithCachingEnabled_CachesSkillsAsync() ]); var provider = new AgentSkillsProviderBuilder() .UseSource(source) - .UseCache(true) .Build(); var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); @@ -525,7 +537,7 @@ public async Task Build_WithCachingEnabled_CachesSkillsAsync() await provider.InvokingAsync(invokingContext, CancellationToken.None); await provider.InvokingAsync(invokingContext, CancellationToken.None); - // Assert — source should be called exactly once + // Assert — source should be called exactly once (caching is on by default) Assert.Equal(1, source.GetSkillsCallCount); } @@ -551,58 +563,6 @@ public async Task Build_DefaultOptions_CachesSkillsAsync() Assert.Equal(1, source.GetSkillsCallCount); } - [Fact] - public async Task Build_PreservesSourceRegistrationOrderAsync() - { - // Arrange — register file skills - string dir1 = Path.Combine(this._testRoot, "dir1"); - string dir2 = Path.Combine(this._testRoot, "dir2"); - CreateSkillIn(dir1, "file-skill-1", "First file skill", "Body 1."); - CreateSkillIn(dir2, "file-skill-2", "Second file skill", "Body 2."); - - var provider = new AgentSkillsProviderBuilder() - .UseFileSkills([dir1, dir2]) - .UseFileScriptExecutor(s_noOpExecutor) - .UseCache(false) - .Build(); - - var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); - - // Act - var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); - - // Assert — both skills should be present in alphabetical order in the prompt - Assert.NotNull(result.Instructions); - Assert.Contains("file-skill-1", result.Instructions); - Assert.Contains("file-skill-2", result.Instructions); - } - - [Fact] - public async Task Build_WithCustomSource_AllSkillsDiscoveredAsync() - { - // Arrange — use a custom source with multiple skills - var customSource = new CountingAgentSkillsSource( - [ - new TestAgentSkill("custom-skill", "Custom source skill", "Body custom."), - new TestAgentSkill("another-skill", "Another skill", "Body another."), - ]); - - var provider = new AgentSkillsProviderBuilder() - .UseSource(customSource) - .UseCache(false) - .Build(); - - var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); - - // Act - var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); - - // Assert — all skills from the source are present - Assert.NotNull(result.Instructions); - Assert.Contains("custom-skill", result.Instructions); - Assert.Contains("another-skill", result.Instructions); - } - [Fact] public async Task InvokingCoreAsync_WithScriptsAndScriptApproval_WrapsRunScriptToolAsync() { @@ -659,10 +619,10 @@ public async Task InvokingCoreAsync_WithScriptsNoScriptApproval_DoesNotWrapRunSc } [Fact] - public async Task InvokingCoreAsync_MultipleInvocations_ToolsAreNotSharedAsync() + public async Task InvokingCoreAsync_MultipleInvocations_ToolsAreSharedWhenCachedAsync() { - // Arrange — verify tools are built fresh per invocation (statelessness) - this.CreateSkill("fresh-tools-skill", "Fresh tools test", "Body."); + // Arrange — with default caching, tools should be the same reference + this.CreateSkill("cached-tools-skill", "Cached tools test", "Body."); var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); var provider = new AgentSkillsProvider(source); var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); @@ -671,12 +631,97 @@ public async Task InvokingCoreAsync_MultipleInvocations_ToolsAreNotSharedAsync() var result1 = await provider.InvokingAsync(invokingContext, CancellationToken.None); var result2 = await provider.InvokingAsync(invokingContext, CancellationToken.None); + // Assert — tool lists should be the same reference (cached) + Assert.NotNull(result1.Tools); + Assert.NotNull(result2.Tools); + Assert.Same(result1.Tools, result2.Tools); + } + + [Fact] + public async Task InvokingCoreAsync_MultipleInvocations_ToolsAreNotSharedWhenCachingDisabledAsync() + { + // Arrange — with caching disabled, tools should be rebuilt per invocation + this.CreateSkill("fresh-tools-skill", "Fresh tools test", "Body."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + var options = new AgentSkillsProviderOptions { DisableCaching = true }; + var provider = new AgentSkillsProvider(source, options); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + + // Act + var result1 = await provider.InvokingAsync(invokingContext, CancellationToken.None); + var result2 = await provider.InvokingAsync(invokingContext, CancellationToken.None); + // Assert — tool lists should not be the same reference Assert.NotNull(result1.Tools); Assert.NotNull(result2.Tools); Assert.NotSame(result1.Tools, result2.Tools); } + [Fact] + public async Task Constructor_SingleDirectory_DiscoverFileSkillsAsync() + { + // Arrange + this.CreateSkill("file-ctor-skill", "File ctor test", "File body."); + var provider = new AgentSkillsProvider(this._testRoot, s_noOpExecutor); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert + Assert.NotNull(result.Instructions); + Assert.Contains("file-ctor-skill", result.Instructions); + Assert.NotNull(result.Tools); + Assert.Contains(result.Tools!, t => t.Name == "load_skill"); + } + + [Fact] + public async Task Constructor_MultipleDirectories_DiscoverFileSkillsAsync() + { + // Arrange + string dir1 = Path.Combine(this._testRoot, "dir1"); + string dir2 = Path.Combine(this._testRoot, "dir2"); + Directory.CreateDirectory(dir1); + Directory.CreateDirectory(dir2); + CreateSkillIn(dir1, "skill-a", "Skill A", "Body A."); + CreateSkillIn(dir2, "skill-b", "Skill B", "Body B."); + + var provider = new AgentSkillsProvider(new[] { dir1, dir2 }, s_noOpExecutor); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert + Assert.NotNull(result.Instructions); + Assert.Contains("skill-a", result.Instructions); + Assert.Contains("skill-b", result.Instructions); + } + + [Fact] + public async Task Constructor_MultipleDirectories_DeduplicatesSkillsByNameAsync() + { + // Arrange — same skill name in two directories + string dir1 = Path.Combine(this._testRoot, "dup1"); + string dir2 = Path.Combine(this._testRoot, "dup2"); + Directory.CreateDirectory(dir1); + Directory.CreateDirectory(dir2); + CreateSkillIn(dir1, "dup-skill", "First", "Body 1."); + CreateSkillIn(dir2, "dup-skill", "Second", "Body 2."); + + var provider = new AgentSkillsProvider(new[] { dir1, dir2 }, s_noOpExecutor); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + var loadSkillTool = result.Tools!.First(t => t.Name == "load_skill") as AIFunction; + var content = await loadSkillTool!.InvokeAsync(new AIFunctionArguments(new Dictionary { ["skillName"] = "dup-skill" })); + + // Assert — only first occurrence should survive + Assert.NotNull(content); + Assert.Contains("Body 1.", content!.ToString()!); + } + /// /// A test skill source that counts how many times is called. /// @@ -698,4 +743,23 @@ public override Task> GetSkillsAsync(CancellationToken cancell return Task.FromResult(this._skills); } } + + private sealed class TestAgentSkill : AgentSkill + { + private readonly string _content; + + public TestAgentSkill(string name, string description, string content) + { + this.Frontmatter = new AgentSkillFrontmatter(name, description); + this._content = content; + } + + public override AgentSkillFrontmatter Frontmatter { get; } + + public override string Content => this._content; + + public override IReadOnlyList? Resources => null; + + public override IReadOnlyList? Scripts => null; + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs index 3c1a77762a..e9dc2e0358 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs @@ -15,7 +15,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable private static readonly string[] s_customExtensions = [".custom"]; private static readonly string[] s_validExtensions = [".md", ".json", ".custom"]; private static readonly string[] s_mixedValidInvalidExtensions = [".md", "json"]; - private static readonly AgentFileSkillScriptExecutor s_noOpExecutor = (skill, script, args, ct) => Task.FromResult(null); + private static readonly AgentFileSkillScriptRunner s_noOpExecutor = (skill, script, args, ct) => Task.FromResult(null); private readonly string _testRoot; @@ -447,7 +447,7 @@ public async Task ReadSkillResourceAsync_ValidResource_ReturnsContentAsync() var resource = skills[0].Resources!.First(r => r.Name == "refs/doc.md"); // Act - var content = await resource.ReadAsync([]); + var content = await resource.ReadAsync(); // Assert Assert.Equal("Document content here.", content); From 500ae2576345eb2c052c6ad3ab4541c0490d4888 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Date: Thu, 26 Mar 2026 09:34:15 +0000 Subject: [PATCH 06/14] rename executor to runner to align naming with python implementation --- .../Agent_Step01_FileBasedSkills.csproj | 2 +- .../AgentSkills/Agent_Step01_FileBasedSkills/Program.cs | 2 +- ...ubprocessScriptExecutor.cs => SubprocessScriptRunner.cs} | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) rename dotnet/samples/02-agents/AgentSkills/{SubprocessScriptExecutor.cs => SubprocessScriptRunner.cs} (96%) diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/Agent_Step01_FileBasedSkills.csproj b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/Agent_Step01_FileBasedSkills.csproj index 4d89721fde..7e7e9ef0fa 100644 --- a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/Agent_Step01_FileBasedSkills.csproj +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/Agent_Step01_FileBasedSkills.csproj @@ -15,7 +15,7 @@ - + diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/Program.cs b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/Program.cs index 923a6fe868..4410639832 100644 --- a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/Program.cs +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/Program.cs @@ -23,7 +23,7 @@ // The script runner runs file-based scripts (e.g. Python) as local subprocesses. var skillsProvider = new AgentSkillsProvider( Path.Combine(AppContext.BaseDirectory, "skills"), - SubprocessScriptExecutor.ExecuteAsync); + SubprocessScriptRunner.ExecuteAsync); // --- Agent Setup --- AIAgent agent = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()) .GetResponsesClient() diff --git a/dotnet/samples/02-agents/AgentSkills/SubprocessScriptExecutor.cs b/dotnet/samples/02-agents/AgentSkills/SubprocessScriptRunner.cs similarity index 96% rename from dotnet/samples/02-agents/AgentSkills/SubprocessScriptExecutor.cs rename to dotnet/samples/02-agents/AgentSkills/SubprocessScriptRunner.cs index 8836ee829a..2d4292298c 100644 --- a/dotnet/samples/02-agents/AgentSkills/SubprocessScriptExecutor.cs +++ b/dotnet/samples/02-agents/AgentSkills/SubprocessScriptRunner.cs @@ -1,6 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. -// Sample subprocess-based skill script executor. +// Sample subprocess-based skill script runner. // Executes file-based skill scripts as local subprocesses. // This is provided for demonstration purposes only. @@ -12,11 +12,11 @@ /// Executes file-based skill scripts as local subprocesses. /// /// -/// This executor uses the script's absolute path, converts the arguments +/// This runner uses the script's absolute path, converts the arguments /// to CLI flags, and returns captured output. It is intended for /// demonstration purposes only. /// -internal static class SubprocessScriptExecutor +internal static class SubprocessScriptRunner { /// /// Runs a skill script as a local subprocess. From 6c4a2962c174b58f0f3b4e2d64831ae9bc08f55d Mon Sep 17 00:00:00 2001 From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Date: Thu, 26 Mar 2026 09:38:16 +0000 Subject: [PATCH 07/14] rename runner execute method to run method --- .../AgentSkills/Agent_Step01_FileBasedSkills/Program.cs | 2 +- dotnet/samples/02-agents/AgentSkills/SubprocessScriptRunner.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/Program.cs b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/Program.cs index 4410639832..b787bb86a3 100644 --- a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/Program.cs +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/Program.cs @@ -23,7 +23,7 @@ // The script runner runs file-based scripts (e.g. Python) as local subprocesses. var skillsProvider = new AgentSkillsProvider( Path.Combine(AppContext.BaseDirectory, "skills"), - SubprocessScriptRunner.ExecuteAsync); + SubprocessScriptRunner.RunAsync); // --- Agent Setup --- AIAgent agent = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()) .GetResponsesClient() diff --git a/dotnet/samples/02-agents/AgentSkills/SubprocessScriptRunner.cs b/dotnet/samples/02-agents/AgentSkills/SubprocessScriptRunner.cs index 2d4292298c..f509c7f951 100644 --- a/dotnet/samples/02-agents/AgentSkills/SubprocessScriptRunner.cs +++ b/dotnet/samples/02-agents/AgentSkills/SubprocessScriptRunner.cs @@ -21,7 +21,7 @@ internal static class SubprocessScriptRunner /// /// Runs a skill script as a local subprocess. /// - public static async Task ExecuteAsync( + public static async Task RunAsync( AgentFileSkill skill, AgentFileSkillScript script, AIFunctionArguments arguments, From fbaa0d1093979451cd7e188cb9f44e83a398ceb9 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Date: Thu, 26 Mar 2026 09:50:48 +0000 Subject: [PATCH 08/14] remove poc leftovers and fix compilation issues --- .../Skills/AggregateAgentSkillsSource.cs | 46 ------ .../Decorators/CachingAgentSkillsSource.cs | 61 ------- .../File/AgentFileSkillScriptExecutor.cs | 27 ---- .../Skills/File/AgentFileSkillsSource.cs | 1 - .../AggregateAgentSkillsSourceTests.cs | 134 --------------- .../CachingAgentSkillsSourceTests.cs | 153 ------------------ 6 files changed, 422 deletions(-) delete mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/AggregateAgentSkillsSource.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/Decorators/CachingAgentSkillsSource.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillScriptExecutor.cs delete mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AggregateAgentSkillsSourceTests.cs delete mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/CachingAgentSkillsSourceTests.cs diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AggregateAgentSkillsSource.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AggregateAgentSkillsSource.cs deleted file mode 100644 index 7f36e6c2d1..0000000000 --- a/dotnet/src/Microsoft.Agents.AI/Skills/AggregateAgentSkillsSource.cs +++ /dev/null @@ -1,46 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Shared.DiagnosticIds; -using Microsoft.Shared.Diagnostics; - -namespace Microsoft.Agents.AI; - -/// -/// A skill source that aggregates multiple child sources, preserving their registration order. -/// -/// -/// Skills from each child source are returned in the order the sources were registered, -/// with each source's skills appended sequentially. No deduplication or filtering is applied. -/// -[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] -public sealed class AggregateAgentSkillsSource : AgentSkillsSource -{ - private readonly IReadOnlyList _sources; - - /// - /// Initializes a new instance of the class. - /// - /// The child sources to aggregate. - public AggregateAgentSkillsSource(IEnumerable sources) - { - _ = Throw.IfNull(sources); - this._sources = new List(sources); - } - - /// - public override async Task> GetSkillsAsync(CancellationToken cancellationToken = default) - { - var allSkills = new List(); - foreach (var source in this._sources) - { - var skills = await source.GetSkillsAsync(cancellationToken).ConfigureAwait(false); - allSkills.AddRange(skills); - } - - return allSkills; - } -} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/CachingAgentSkillsSource.cs b/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/CachingAgentSkillsSource.cs deleted file mode 100644 index 759da9bf0c..0000000000 --- a/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/CachingAgentSkillsSource.cs +++ /dev/null @@ -1,61 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Shared.DiagnosticIds; - -namespace Microsoft.Agents.AI; - -/// -/// A skill source decorator that caches the result of the first call. -/// -/// -/// Thread-safe: the first concurrent caller loads from the inner source and all other callers -/// await the same in-flight task. If the load fails, the field is reset so future callers can retry. -/// -[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] -internal sealed class CachingAgentSkillsSource : DelegatingAgentSkillsSource -{ - private Task>? _loadTask; - - /// - /// Initializes a new instance of the class. - /// - /// The inner source to cache. - public CachingAgentSkillsSource(AgentSkillsSource innerSource) - : base(innerSource) - { - } - - /// - public override Task> GetSkillsAsync(CancellationToken cancellationToken = default) - { - return this._loadTask ?? this.LoadAsync(cancellationToken); - } - - private async Task> LoadAsync(CancellationToken cancellationToken) - { - var tcs = new TaskCompletionSource>(TaskCreationOptions.RunContinuationsAsynchronously); - - if (Interlocked.CompareExchange(ref this._loadTask, tcs.Task, null) is { } existing) - { - return await existing.ConfigureAwait(false); - } - - try - { - var result = await this.InnerSource.GetSkillsAsync(cancellationToken).ConfigureAwait(false); - tcs.SetResult(result); - return result; - } - catch (Exception ex) - { - this._loadTask = null; - tcs.TrySetException(ex); - throw; - } - } -} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillScriptExecutor.cs b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillScriptExecutor.cs deleted file mode 100644 index 81c39e0f4b..0000000000 --- a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillScriptExecutor.cs +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Diagnostics.CodeAnalysis; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Extensions.AI; -using Microsoft.Shared.DiagnosticIds; - -namespace Microsoft.Agents.AI; - -/// -/// Delegate for executing file-based skill scripts. -/// -/// -/// Implementations determine the execution strategy (e.g., local subprocess, hosted code execution environment). -/// -/// The skill that owns the script. -/// The file-based script to execute. -/// Optional arguments for the script, provided by the agent/LLM. -/// Cancellation token. -/// The script execution result. -[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] -public delegate Task AgentFileSkillScriptExecutor( - AgentFileSkill skill, - AgentFileSkillScript script, - AIFunctionArguments arguments, - CancellationToken cancellationToken); diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSource.cs b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSource.cs index 0f74624db0..0a75a897d0 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSource.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSource.cs @@ -4,7 +4,6 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.IO; -using System.Linq; using System.Text; using System.Text.RegularExpressions; using System.Threading; diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AggregateAgentSkillsSourceTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AggregateAgentSkillsSourceTests.cs deleted file mode 100644 index 1510c591e2..0000000000 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AggregateAgentSkillsSourceTests.cs +++ /dev/null @@ -1,134 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; - -namespace Microsoft.Agents.AI.UnitTests.AgentSkills; - -/// -/// Unit tests for . -/// -public sealed class AggregateAgentSkillsSourceTests -{ - [Fact] - public async Task GetSkillsAsync_MultipleSources_AggregatesInRegistrationOrderAsyncAsync() - { - // Arrange - var source1 = new TestAgentSkillsSource( - new TestAgentSkill("alpha", "Alpha", "Instructions A.")); - var source2 = new TestAgentSkillsSource( - new TestAgentSkill("beta", "Beta", "Instructions B."), - new TestAgentSkill("gamma", "Gamma", "Instructions C.")); - var aggregate = new AggregateAgentSkillsSource([source1, source2]); - - // Act - var result = await aggregate.GetSkillsAsync(CancellationToken.None); - - // Assert - Assert.Equal(3, result.Count); - Assert.Equal("alpha", result[0].Frontmatter.Name); - Assert.Equal("beta", result[1].Frontmatter.Name); - Assert.Equal("gamma", result[2].Frontmatter.Name); - } - - [Fact] - public async Task GetSkillsAsync_SingleSource_ReturnsItsSkillsAsyncAsync() - { - // Arrange - var inner = new TestAgentSkillsSource( - new TestAgentSkill("only", "Only skill", "Instructions.")); - var aggregate = new AggregateAgentSkillsSource([inner]); - - // Act - var result = await aggregate.GetSkillsAsync(CancellationToken.None); - - // Assert - Assert.Single(result); - Assert.Equal("only", result[0].Frontmatter.Name); - } - - [Fact] - public async Task GetSkillsAsync_AllSourcesEmpty_ReturnsEmptyListAsyncAsync() - { - // Arrange - var source1 = new TestAgentSkillsSource(Array.Empty()); - var source2 = new TestAgentSkillsSource(Array.Empty()); - var aggregate = new AggregateAgentSkillsSource([source1, source2]); - - // Act - var result = await aggregate.GetSkillsAsync(CancellationToken.None); - - // Assert - Assert.Empty(result); - } - - [Fact] - public async Task GetSkillsAsync_MixedEmptyAndNonEmptySources_ReturnsNonEmptySkillsAsyncAsync() - { - // Arrange - var empty = new TestAgentSkillsSource(Array.Empty()); - var populated = new TestAgentSkillsSource( - new TestAgentSkill("present", "Present", "Instructions.")); - var aggregate = new AggregateAgentSkillsSource([empty, populated, empty]); - - // Act - var result = await aggregate.GetSkillsAsync(CancellationToken.None); - - // Assert - Assert.Single(result); - Assert.Equal("present", result[0].Frontmatter.Name); - } - - [Fact] - public async Task GetSkillsAsync_DoesNotDeduplicateAsyncAsync() - { - // Arrange - var source1 = new TestAgentSkillsSource( - new TestAgentSkill("shared", "From source 1", "Instructions 1.")); - var source2 = new TestAgentSkillsSource( - new TestAgentSkill("shared", "From source 2", "Instructions 2.")); - var aggregate = new AggregateAgentSkillsSource([source1, source2]); - - // Act - var result = await aggregate.GetSkillsAsync(CancellationToken.None); - - // Assert — duplicates are preserved (dedup is a separate decorator) - Assert.Equal(2, result.Count); - Assert.All(result, s => Assert.Equal("shared", s.Frontmatter.Name)); - } - - [Fact] - public async Task GetSkillsAsync_CancellationTokenIsPropagatedAsyncAsync() - { - // Arrange - using var cts = new CancellationTokenSource(); - cts.Cancel(); - var source = new CancellationAwareSource(); - var aggregate = new AggregateAgentSkillsSource([source]); - - // Act & Assert - await Assert.ThrowsAsync( - () => aggregate.GetSkillsAsync(cts.Token)); - } - - [Fact] - public void Constructor_NullSources_ThrowsArgumentNullException() - { - // Act & Assert - Assert.Throws(() => new AggregateAgentSkillsSource(null!)); - } - - /// - /// A source that throws when the token is cancelled. - /// - private sealed class CancellationAwareSource : AgentSkillsSource - { - public override Task> GetSkillsAsync(CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - return Task.FromResult>(new List()); - } - } -} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/CachingAgentSkillsSourceTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/CachingAgentSkillsSourceTests.cs deleted file mode 100644 index b1d36abc76..0000000000 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/CachingAgentSkillsSourceTests.cs +++ /dev/null @@ -1,153 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; - -namespace Microsoft.Agents.AI.UnitTests.AgentSkills; - -/// -/// Unit tests for . -/// -public sealed class CachingAgentSkillsSourceTests -{ - [Fact] - public async Task GetSkillsAsync_ReturnsCachedResultAsync() - { - // Arrange - var inner = new CountingSource(new TestAgentSkill("cached", "Cached skill", "Instructions.")); - var source = new CachingAgentSkillsSource(inner); - - // Act - var result1 = await source.GetSkillsAsync(CancellationToken.None); - var result2 = await source.GetSkillsAsync(CancellationToken.None); - - // Assert - Assert.Equal(1, inner.CallCount); - Assert.Same(result1, result2); - } - - [Fact] - public async Task GetSkillsAsync_ConcurrentCalls_LoadsOnlyOnceAsync() - { - // Arrange - var inner = new CountingSource(new TestAgentSkill("concurrent", "Concurrent", "Instructions.")); - var source = new CachingAgentSkillsSource(inner); - - // Act - var tasks = Enumerable.Range(0, 10) - .Select(_ => source.GetSkillsAsync(CancellationToken.None)) - .ToArray(); - await Task.WhenAll(tasks); - - // Assert - Assert.Equal(1, inner.CallCount); - } - - [Fact] - public async Task GetSkillsAsync_EmptySource_CachesEmptyResultAsync() - { - // Arrange - var inner = new CountingSource(); - var source = new CachingAgentSkillsSource(inner); - - // Act - var result1 = await source.GetSkillsAsync(CancellationToken.None); - _ = await source.GetSkillsAsync(CancellationToken.None); - - // Assert - Assert.Empty(result1); - Assert.Equal(1, inner.CallCount); - } - - [Fact] - public async Task GetSkillsAsync_CancellationTokenForwardedToInnerSourceAsyncAsync() - { - // Arrange - using var cts = new CancellationTokenSource(); - cts.Cancel(); - var inner = new CancellationAwareSource(); - var source = new CachingAgentSkillsSource(inner); - - // Act & Assert — the token should be forwarded to the inner source - await Assert.ThrowsAsync( - () => source.GetSkillsAsync(cts.Token)); - } - - [Fact] - public async Task GetSkillsAsync_SeparateInstances_CacheIndependentlyAsyncAsync() - { - // Arrange — use a source that returns a new list each time - var callCount = 0; - var freshSource = new DelegatingTestSource(() => - { - Interlocked.Increment(ref callCount); - return Task.FromResult>( - new List { new TestAgentSkill("shared", "Shared", "Instructions.") }); - }); - var source1 = new CachingAgentSkillsSource(freshSource); - var source2 = new CachingAgentSkillsSource(freshSource); - - // Act - var result1 = await source1.GetSkillsAsync(CancellationToken.None); - var result2 = await source2.GetSkillsAsync(CancellationToken.None); - - // Assert — each instance caches independently, so inner is called twice - Assert.Equal(2, callCount); - Assert.NotSame(result1, result2); - } - - /// - /// A test source that counts calls. - /// - private sealed class CountingSource : AgentSkillsSource - { - private readonly IList _skills; - private int _callCount; - - public CountingSource(params AgentSkill[] skills) - { - this._skills = skills; - } - - public int CallCount => this._callCount; - - public override Task> GetSkillsAsync(CancellationToken cancellationToken = default) - { - Interlocked.Increment(ref this._callCount); - return Task.FromResult(this._skills); - } - } - - /// - /// A source that throws when the token is cancelled. - /// - private sealed class CancellationAwareSource : AgentSkillsSource - { - public override Task> GetSkillsAsync(CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - return Task.FromResult>(new List()); - } - } - - /// - /// A source that delegates to a provided function, returning a fresh result each call. - /// - private sealed class DelegatingTestSource : AgentSkillsSource - { - private readonly Func>> _factory; - - public DelegatingTestSource(Func>> factory) - { - this._factory = factory; - } - - public override Task> GetSkillsAsync(CancellationToken cancellationToken = default) - { - return this._factory(); - } - } -} From 53efe89a8ad1120c2966c7947e81e8b49ab93e35 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Date: Thu, 26 Mar 2026 16:03:28 +0000 Subject: [PATCH 09/14] make script runner optional --- .../Skills/AgentSkillsProvider.cs | 10 ++++----- .../Skills/File/AgentFileSkillScript.cs | 15 +++++++++---- .../Skills/File/AgentFileSkillsSource.cs | 12 +++++----- .../AgentFileSkillsSourceScriptTests.cs | 22 +++++++++++++++++-- 4 files changed, 42 insertions(+), 17 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProvider.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProvider.cs index c66eea8118..f2f87851c0 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProvider.cs @@ -77,13 +77,13 @@ You have access to skills containing domain-specific knowledge and capabilities. /// Duplicate skill names are automatically deduplicated (first occurrence wins). /// /// Path to search for skills. - /// The delegate that runs file-based scripts. + /// Optional delegate that runs file-based scripts. Required only when skills contain scripts. /// Optional options that control skill discovery behavior. /// Optional provider configuration. /// Optional logger factory. public AgentSkillsProvider( string skillPath, - AgentFileSkillScriptRunner scriptRunner, + AgentFileSkillScriptRunner? scriptRunner = null, AgentFileSkillsSourceOptions? fileOptions = null, AgentSkillsProviderOptions? options = null, ILoggerFactory? loggerFactory = null) @@ -97,19 +97,19 @@ public AgentSkillsProvider( /// Duplicate skill names are automatically deduplicated (first occurrence wins). /// /// Paths to search for skills. - /// The delegate that runs file-based scripts. + /// Optional delegate that runs file-based scripts. Required only when skills contain scripts. /// Optional options that control skill discovery behavior. /// Optional provider configuration. /// Optional logger factory. public AgentSkillsProvider( IEnumerable skillPaths, - AgentFileSkillScriptRunner scriptRunner, + AgentFileSkillScriptRunner? scriptRunner = null, AgentFileSkillsSourceOptions? fileOptions = null, AgentSkillsProviderOptions? options = null, ILoggerFactory? loggerFactory = null) : this( new DeduplicatingAgentSkillsSource( - new AgentFileSkillsSource(skillPaths, Throw.IfNull(scriptRunner), fileOptions, loggerFactory), + new AgentFileSkillsSource(skillPaths, scriptRunner, fileOptions, loggerFactory), loggerFactory), options, loggerFactory) diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillScript.cs b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillScript.cs index 7e45cc87f2..116847126f 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillScript.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillScript.cs @@ -16,19 +16,19 @@ namespace Microsoft.Agents.AI; [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] public sealed class AgentFileSkillScript : AgentSkillScript { - private readonly AgentFileSkillScriptRunner _runner; + private readonly AgentFileSkillScriptRunner? _runner; /// /// Initializes a new instance of the class. /// /// The script name. /// The absolute file path to the script. - /// External runner for running the script. - internal AgentFileSkillScript(string name, string fullPath, AgentFileSkillScriptRunner runner) + /// Optional external runner for running the script. An is thrown from if no runner is provided. + internal AgentFileSkillScript(string name, string fullPath, AgentFileSkillScriptRunner? runner = null) : base(name) { this.FullPath = Throw.IfNullOrWhitespace(fullPath); - this._runner = Throw.IfNull(runner); + this._runner = runner; } /// @@ -44,6 +44,13 @@ internal AgentFileSkillScript(string name, string fullPath, AgentFileSkillScript throw new InvalidOperationException($"File-based script '{this.Name}' requires an {nameof(AgentFileSkill)} but received '{skill.GetType().Name}'."); } + if (this._runner is null) + { + throw new InvalidOperationException( + $"Script '{this.Name}' cannot be executed because no {nameof(AgentFileSkillScriptRunner)} was provided. " + + $"Supply a script runner when constructing {nameof(AgentFileSkillsSource)} to enable script execution."); + } + return await this._runner(fileSkill, this, arguments, cancellationToken).ConfigureAwait(false); } } diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSource.cs b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSource.cs index 0a75a897d0..c6dc3bc629 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSource.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSource.cs @@ -55,19 +55,19 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource private readonly IEnumerable _skillPaths; private readonly HashSet _allowedResourceExtensions; private readonly HashSet _allowedScriptExtensions; - private readonly AgentFileSkillScriptRunner _scriptRunner; + private readonly AgentFileSkillScriptRunner? _scriptRunner; private readonly ILogger _logger; /// /// Initializes a new instance of the class. /// /// Path to search for skills. - /// Runner for file-based scripts. + /// Optional runner for file-based scripts. Required only when skills contain scripts. /// Optional options that control skill discovery behavior. /// Optional logger factory. public AgentFileSkillsSource( string skillPath, - AgentFileSkillScriptRunner scriptRunner, + AgentFileSkillScriptRunner? scriptRunner = null, AgentFileSkillsSourceOptions? options = null, ILoggerFactory? loggerFactory = null) : this([skillPath], scriptRunner, options, loggerFactory) @@ -78,12 +78,12 @@ public AgentFileSkillsSource( /// Initializes a new instance of the class. /// /// Paths to search for skills. - /// Runner for file-based scripts. + /// Optional runner for file-based scripts. Required only when skills contain scripts. /// Optional options that control skill discovery behavior. /// Optional logger factory. public AgentFileSkillsSource( IEnumerable skillPaths, - AgentFileSkillScriptRunner scriptRunner, + AgentFileSkillScriptRunner? scriptRunner = null, AgentFileSkillsSourceOptions? options = null, ILoggerFactory? loggerFactory = null) { @@ -102,7 +102,7 @@ public AgentFileSkillsSource( resolvedOptions.AllowedScriptExtensions ?? s_defaultScriptExtensions, StringComparer.OrdinalIgnoreCase); - this._scriptRunner = Throw.IfNull(scriptRunner); + this._scriptRunner = scriptRunner; this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger(); } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillsSourceScriptTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillsSourceScriptTests.cs index 0be238b285..ef8f7780a6 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillsSourceScriptTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillsSourceScriptTests.cs @@ -160,9 +160,27 @@ public async Task GetSkillsAsync_WithRunner_ScriptsCanRunAsync() } [Fact] - public void Constructor_NullExecutor_ThrowsArgumentNullException() + public void Constructor_NullExecutor_DoesNotThrow() { - Assert.Throws(() => new AgentFileSkillsSource(this._testRoot, null!)); + // Arrange & Act & Assert — null runner is allowed when skills have no scripts + var source = new AgentFileSkillsSource(this._testRoot, null); + Assert.NotNull(source); + } + + [Fact] + public async Task GetSkillsAsync_ScriptsWithNoRunner_ThrowsOnRunAsync() + { + // Arrange + string skillDir = CreateSkillDir(this._testRoot, "no-runner-skill", "No runner", "Body."); + CreateFile(skillDir, "scripts/run.sh", "echo 'hello'"); + var source = new AgentFileSkillsSource(this._testRoot, scriptRunner: null); + + // Act — discovery succeeds even without a runner + var skills = await source.GetSkillsAsync(CancellationToken.None); + var script = skills[0].Scripts![0]; + + // Assert — running the script throws because no runner was provided + await Assert.ThrowsAsync(() => script.RunAsync(skills[0], new AIFunctionArguments(), CancellationToken.None)); } [Fact] From 9d703a6c9a249a6353d7755eb6bfa8523b1e49c8 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Date: Thu, 26 Mar 2026 16:29:46 +0000 Subject: [PATCH 10/14] remove unnecessary pragmas --- .../Skills/File/AgentFileSkillResource.cs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillResource.cs b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillResource.cs index 51e45a2a49..9ba5b7e24a 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillResource.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillResource.cs @@ -31,11 +31,7 @@ public AgentFileSkillResource(string name, string fullPath) public string FullPath { get; } /// -#pragma warning disable CA1725 // Parameter names should match base declaration -#pragma warning disable RCS1168 // Parameter name differs from base name - public override async Task ReadAsync(IServiceProvider? _, CancellationToken cancellationToken = default) -#pragma warning restore RCS1168 // Parameter name differs from base name -#pragma warning restore CA1725 // Parameter names should match base declaration + public override async Task ReadAsync(IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default) { #if NET8_0_OR_GREATER return await File.ReadAllTextAsync(this.FullPath, Encoding.UTF8, cancellationToken).ConfigureAwait(false); From 0d18a1be5f7d580f360655b755c2164eabe926df Mon Sep 17 00:00:00 2001 From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Date: Thu, 26 Mar 2026 17:12:40 +0000 Subject: [PATCH 11/14] make resources and scripts props virtual --- dotnet/src/Microsoft.Agents.AI/Skills/AgentSkill.cs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkill.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkill.cs index 15fd6550da..5f0a66808d 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkill.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkill.cs @@ -41,10 +41,18 @@ public abstract class AgentSkill /// /// Gets the resources associated with this skill, or if none. /// - public abstract IReadOnlyList? Resources { get; } + /// + /// The default implementation returns . + /// Override this property in derived classes to provide skill-specific resources. + /// + public virtual IReadOnlyList? Resources => null; /// /// Gets the scripts associated with this skill, or if none. /// - public abstract IReadOnlyList? Scripts { get; } + /// + /// The default implementation returns . + /// Override this property in derived classes to provide skill-specific scripts. + /// + public virtual IReadOnlyList? Scripts => null; } From 5f86e1543b8831694038fd7def645952fc659fa1 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Date: Thu, 26 Mar 2026 19:01:34 +0000 Subject: [PATCH 12/14] address comments --- .../AgentSkills/SubprocessScriptRunner.cs | 48 ++++++++++--------- 1 file changed, 25 insertions(+), 23 deletions(-) diff --git a/dotnet/samples/02-agents/AgentSkills/SubprocessScriptRunner.cs b/dotnet/samples/02-agents/AgentSkills/SubprocessScriptRunner.cs index f509c7f951..e95bde61df 100644 --- a/dotnet/samples/02-agents/AgentSkills/SubprocessScriptRunner.cs +++ b/dotnet/samples/02-agents/AgentSkills/SubprocessScriptRunner.cs @@ -80,42 +80,40 @@ internal static class SubprocessScriptRunner } } + Process? process = null; try { - using var process = Process.Start(startInfo); + process = Process.Start(startInfo); if (process is null) { return $"Error: Failed to start process for script '{script.Name}'."; } - try - { - Task outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken); - Task errorTask = process.StandardError.ReadToEndAsync(cancellationToken); - - await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); + Task outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken); + Task errorTask = process.StandardError.ReadToEndAsync(cancellationToken); - string output = await outputTask.ConfigureAwait(false); - string error = await errorTask.ConfigureAwait(false); - - if (!string.IsNullOrEmpty(error)) - { - output += $"\nStderr:\n{error}"; - } + await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); - if (process.ExitCode != 0) - { - output += $"\nScript exited with code {process.ExitCode}"; - } + string output = await outputTask.ConfigureAwait(false); + string error = await errorTask.ConfigureAwait(false); - return string.IsNullOrEmpty(output) ? "(no output)" : output.Trim(); + if (!string.IsNullOrEmpty(error)) + { + output += $"\nStderr:\n{error}"; } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + + if (process.ExitCode != 0) { - // Kill the process on cancellation to avoid leaving orphaned subprocesses. - process.Kill(entireProcessTree: true); - throw; + output += $"\nScript exited with code {process.ExitCode}"; } + + return string.IsNullOrEmpty(output) ? "(no output)" : output.Trim(); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // Kill the process on cancellation to avoid leaving orphaned subprocesses. + process?.Kill(entireProcessTree: true); + throw; } catch (OperationCanceledException) { @@ -125,6 +123,10 @@ internal static class SubprocessScriptRunner { return $"Error: Failed to execute script '{script.Name}': {ex.Message}"; } + finally + { + process?.Dispose(); + } } /// From 9270ee25ff78dc81df6ca24f324bc481535d3184 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Date: Thu, 26 Mar 2026 20:30:40 +0000 Subject: [PATCH 13/14] update comment for name validation regex --- .../src/Microsoft.Agents.AI/Skills/AgentSkillFrontmatter.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillFrontmatter.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillFrontmatter.cs index 5a3ad4d296..df087ff2bb 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillFrontmatter.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillFrontmatter.cs @@ -41,8 +41,8 @@ public sealed class AgentSkillFrontmatter /// internal const int MaxCompatibilityLength = 500; - // Validates skill names: lowercase letters, numbers, and hyphens only; - // must not start or end with a hyphen; must not contain consecutive hyphens. + // Validates skill names per the Agent Skills specification (https://agentskills.io/specification#frontmatter): + // lowercase letters, numbers, and hyphens only; must not start or end with a hyphen; must not contain consecutive hyphens. private static readonly Regex s_validNameRegex = new("^[a-z0-9]([a-z0-9]*-[a-z0-9])*[a-z0-9]*$", RegexOptions.Compiled); private string? _compatibility; From d7e571241fe4076ef26299067681e90f7b2448f3 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Date: Thu, 26 Mar 2026 20:58:30 +0000 Subject: [PATCH 14/14] address comments --- .../Skills/AgentSkillsProviderBuilder.cs | 15 +++++---------- .../Skills/AggregatingAgentSkillsSource.cs | 3 +-- 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProviderBuilder.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProviderBuilder.cs index 35b8285fad..17d7f2d6f3 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProviderBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProviderBuilder.cs @@ -86,7 +86,7 @@ public AgentSkillsProviderBuilder UseSource(AgentSkillsSource source) /// This builder instance for chaining. public AgentSkillsProviderBuilder UsePromptTemplate(string promptTemplate) { - this.EnsureOptions().SkillsInstructionPrompt = promptTemplate; + this.GetOrCreateOptions().SkillsInstructionPrompt = promptTemplate; return this; } @@ -97,7 +97,7 @@ public AgentSkillsProviderBuilder UsePromptTemplate(string promptTemplate) /// This builder instance for chaining. public AgentSkillsProviderBuilder UseScriptApproval(bool enabled = true) { - this.EnsureOptions().ScriptApproval = enabled; + this.GetOrCreateOptions().ScriptApproval = enabled; return this; } @@ -148,7 +148,7 @@ public AgentSkillsProviderBuilder UseFilter(Func predicate) public AgentSkillsProviderBuilder UseOptions(Action configure) { _ = Throw.IfNull(configure); - configure(this.EnsureOptions()); + configure(this.GetOrCreateOptions()); return this; } @@ -185,13 +185,8 @@ public AgentSkillsProvider Build() return new AgentSkillsProvider(source, this._options, this._loggerFactory); } - private AgentSkillsProviderOptions EnsureOptions() + private AgentSkillsProviderOptions GetOrCreateOptions() { - if (this._options == null) - { - this._options = new AgentSkillsProviderOptions(); - } - - return this._options; + return this._options ??= new AgentSkillsProviderOptions(); } } diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AggregatingAgentSkillsSource.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AggregatingAgentSkillsSource.cs index bd0a71e029..7dc468742f 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/AggregatingAgentSkillsSource.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/AggregatingAgentSkillsSource.cs @@ -27,8 +27,7 @@ internal sealed class AggregatingAgentSkillsSource : AgentSkillsSource /// The child sources to aggregate. public AggregatingAgentSkillsSource(IEnumerable sources) { - _ = Throw.IfNull(sources); - this._sources = sources; + this._sources = Throw.IfNull(sources); } ///