diff --git a/.github/workflows/dotnet-build-and-test.yml b/.github/workflows/dotnet-build-and-test.yml index 95842e703a..2d079f488e 100644 --- a/.github/workflows/dotnet-build-and-test.yml +++ b/.github/workflows/dotnet-build-and-test.yml @@ -7,6 +7,13 @@ name: dotnet-build-and-test on: workflow_dispatch: + workflow_call: + inputs: + checkout-ref: + description: "Git ref to checkout (e.g., a commit SHA from a PR)" + required: false + type: string + default: "" pull_request: branches: ["main", "feature*"] merge_group: @@ -39,6 +46,8 @@ jobs: cosmosDbChanges: ${{ steps.filter.outputs.cosmosdb }} steps: - uses: actions/checkout@v6 + with: + ref: ${{ inputs.checkout-ref }} - uses: dorny/paths-filter@v3 id: filter with: @@ -76,6 +85,7 @@ jobs: steps: - uses: actions/checkout@v6 with: + ref: ${{ inputs.checkout-ref }} persist-credentials: false sparse-checkout: | . diff --git a/.github/workflows/integration-tests-manual.yml b/.github/workflows/integration-tests-manual.yml new file mode 100644 index 0000000000..eb7c9859b4 --- /dev/null +++ b/.github/workflows/integration-tests-manual.yml @@ -0,0 +1,106 @@ +# +# This workflow allows manually running integration tests against an open PR or a branch. +# Go to Actions → "Integration Tests (Manual)" → Run workflow → enter a PR number or branch name. +# +# It reuses the existing dotnet-build-and-test and python-merge-tests workflows, +# passing a ref so they check out and test the correct code. +# + +name: Integration Tests (Manual) + +on: + workflow_dispatch: + inputs: + pr-number: + description: "PR number to run integration tests against (leave empty if using branch)" + required: false + type: string + default: "" + branch: + description: "Branch name to run integration tests against (leave empty if using PR number)" + required: false + type: string + default: "" + +permissions: + contents: read + pull-requests: read + id-token: write + +concurrency: + group: integration-tests-manual-${{ github.event.inputs.pr-number || github.event.inputs.branch }} + cancel-in-progress: true + +jobs: + resolve-ref: + name: Resolve ref + runs-on: ubuntu-latest + outputs: + checkout-ref: ${{ steps.resolve.outputs.checkout-ref }} + steps: + - name: Resolve checkout ref + id: resolve + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.inputs.pr-number }} + BRANCH: ${{ github.event.inputs.branch }} + REPO: ${{ github.repository }} + REPO_OWNER: ${{ github.repository_owner }} + run: | + if [ -n "$PR_NUMBER" ] && [ -n "$BRANCH" ]; then + echo "::error::Please provide either a PR number or a branch name, not both." + exit 1 + fi + + if [ -z "$PR_NUMBER" ] && [ -z "$BRANCH" ]; then + echo "::error::Please provide either a PR number or a branch name." + exit 1 + fi + + if [ -n "$PR_NUMBER" ]; then + if ! echo "$PR_NUMBER" | grep -Eq '^[0-9]+$'; then + echo "::error::Invalid PR number. Only numeric values are allowed." + exit 1 + fi + + PR_DATA=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json state,headRepository,headRepositoryOwner) + PR_STATE=$(echo "$PR_DATA" | jq -r '.state') + HEAD_OWNER=$(echo "$PR_DATA" | jq -r '.headRepositoryOwner.login') + + if [ "$PR_STATE" != "OPEN" ]; then + echo "::error::PR #$PR_NUMBER is not open (state: $PR_STATE)" + exit 1 + fi + + if [ "$HEAD_OWNER" != "$REPO_OWNER" ]; then + echo "::error::PR #$PR_NUMBER is from a fork ($HEAD_OWNER). Running integration tests against fork PRs is not allowed for security reasons." + exit 1 + fi + + echo "checkout-ref=refs/pull/$PR_NUMBER/head" >> "$GITHUB_OUTPUT" + echo "Running integration tests for PR #$PR_NUMBER" + else + if ! echo "$BRANCH" | grep -Eq '^[a-zA-Z0-9_./-]+$'; then + echo "::error::Invalid branch name. Only alphanumeric characters, hyphens, underscores, dots, and slashes are allowed." + exit 1 + fi + + echo "checkout-ref=$BRANCH" >> "$GITHUB_OUTPUT" + echo "Running integration tests for branch $BRANCH" + fi + + dotnet-integration-tests: + name: .NET Integration Tests + needs: resolve-ref + uses: ./.github/workflows/dotnet-build-and-test.yml + with: + checkout-ref: ${{ needs.resolve-ref.outputs.checkout-ref }} + secrets: inherit + + python-integration-tests: + name: Python Integration Tests + needs: resolve-ref + uses: ./.github/workflows/python-merge-tests.yml + with: + checkout-ref: ${{ needs.resolve-ref.outputs.checkout-ref }} + secrets: inherit diff --git a/.github/workflows/python-merge-tests.yml b/.github/workflows/python-merge-tests.yml index 7572b0379b..8704ec56c1 100644 --- a/.github/workflows/python-merge-tests.yml +++ b/.github/workflows/python-merge-tests.yml @@ -2,6 +2,13 @@ name: Python - Merge - Tests on: workflow_dispatch: + workflow_call: + inputs: + checkout-ref: + description: "Git ref to checkout (e.g., a commit SHA from a PR)" + required: false + type: string + default: "" pull_request: branches: ["main"] merge_group: @@ -29,6 +36,8 @@ jobs: pythonChanges: ${{ steps.filter.outputs.python}} steps: - uses: actions/checkout@v6 + with: + ref: ${{ inputs.checkout-ref }} - uses: dorny/paths-filter@v3 id: filter with: @@ -76,6 +85,8 @@ jobs: working-directory: python steps: - uses: actions/checkout@v6 + with: + ref: ${{ inputs.checkout-ref }} - name: Set up python and install the project id: python-setup uses: ./.github/actions/python-setup @@ -135,6 +146,8 @@ jobs: working-directory: python steps: - uses: actions/checkout@v6 + with: + ref: ${{ inputs.checkout-ref }} - name: Set up python and install the project id: python-setup uses: ./.github/actions/python-setup diff --git a/dotnet/AGENTS.md b/dotnet/AGENTS.md index 03a015f2f7..4cb4b67e5f 100644 --- a/dotnet/AGENTS.md +++ b/dotnet/AGENTS.md @@ -29,6 +29,7 @@ using types like `IChatClient`, `FunctionInvokingChatClient`, `AITool`, `AIFunct ## Key Conventions +- **Encoding**: All new files must be saved with UTF-8 encoding with BOM (Byte Order Mark). This is required for `dotnet format` to work correctly. - **Copyright header**: `// Copyright (c) Microsoft. All rights reserved.` at top of all `.cs` files - **XML docs**: Required for all public methods and classes - **Async**: Use `Async` suffix for methods returning `Task`/`ValueTask` diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 8064cd4460..601c6e88d5 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -77,6 +77,12 @@ + + + + + + @@ -145,6 +151,12 @@ + + + + + + @@ -424,6 +436,7 @@ + @@ -445,6 +458,7 @@ + @@ -467,6 +481,7 @@ + diff --git a/dotnet/samples/02-agents/AgentWithMemory/README.md b/dotnet/samples/02-agents/AgentWithMemory/README.md index 670ef564d3..8bbb3f4f6f 100644 --- a/dotnet/samples/02-agents/AgentWithMemory/README.md +++ b/dotnet/samples/02-agents/AgentWithMemory/README.md @@ -7,3 +7,5 @@ These samples show how to create an agent with the Agent Framework that uses Mem |[Chat History memory](./AgentWithMemory_Step01_ChatHistoryMemory/)|This sample demonstrates how to enable an agent to remember messages from previous conversations.| |[Memory with MemoryStore](./AgentWithMemory_Step02_MemoryUsingMem0/)|This sample demonstrates how to create and run an agent that uses the Mem0 service to extract and retrieve individual memories.| |[Custom Memory Implementation](../../01-get-started/04_memory/)|This sample demonstrates how to create a custom memory component and attach it to an agent.| +|[Custom Memory Implementation](./AgentWithMemory_Step03_CustomMemory/)|This sample demonstrates how to create a custom memory component and attach it to an agent.| +|[Memory with Azure AI Foundry](./AgentWithMemory_Step04_MemoryUsingFoundry/)|This sample demonstrates how to create and run an agent that uses Azure AI Foundry's managed memory service to extract and retrieve individual memories.| diff --git a/dotnet/samples/02-agents/README.md b/dotnet/samples/02-agents/README.md index 5fc1315870..ee960a09a4 100644 --- a/dotnet/samples/02-agents/README.md +++ b/dotnet/samples/02-agents/README.md @@ -17,3 +17,4 @@ of the agent framework. |[Agent With Anthropic](./AgentWithAnthropic/README.md)|Getting started with agents using Anthropic Claude| |[Workflow](../03-workflows/README.md)|Getting started with Workflow| |[Model Context Protocol](./ModelContextProtocol/README.md)|Getting started with Model Context Protocol| +|[Agent Skills](./AgentSkills/README.md)|Getting started with Agent Skills| diff --git a/dotnet/samples/GettingStarted/AgentSkills/Agent_Step01_BasicSkills/Agent_Step01_BasicSkills.csproj b/dotnet/samples/GettingStarted/AgentSkills/Agent_Step01_BasicSkills/Agent_Step01_BasicSkills.csproj new file mode 100644 index 0000000000..2a503bbfb2 --- /dev/null +++ b/dotnet/samples/GettingStarted/AgentSkills/Agent_Step01_BasicSkills/Agent_Step01_BasicSkills.csproj @@ -0,0 +1,28 @@ + + + + Exe + net10.0 + + enable + enable + $(NoWarn);MAAI001 + + + + + + + + + + + + + + + PreserveNewest + + + + diff --git a/dotnet/samples/GettingStarted/AgentSkills/Agent_Step01_BasicSkills/Program.cs b/dotnet/samples/GettingStarted/AgentSkills/Agent_Step01_BasicSkills/Program.cs new file mode 100644 index 0000000000..290c3f9b6b --- /dev/null +++ b/dotnet/samples/GettingStarted/AgentSkills/Agent_Step01_BasicSkills/Program.cs @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample demonstrates how to use Agent Skills with a ChatClientAgent. +// Agent Skills are modular packages of instructions and resources that extend an agent's capabilities. +// Skills follow the progressive disclosure pattern: advertise -> load -> read resources. +// +// This sample includes the expense-report skill: +// - Policy-based expense filing with references and assets + +using Azure.AI.OpenAI; +using Azure.Identity; +using Microsoft.Agents.AI; +using OpenAI.Responses; + +// --- Configuration --- +string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") + ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; + +// --- Skills Provider --- +// Discovers skills from the 'skills' directory and makes them available to the agent +var skillsProvider = new FileAgentSkillsProvider(skillPath: Path.Combine(AppContext.BaseDirectory, "skills")); + +// --- Agent Setup --- +AIAgent agent = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()) + .GetResponsesClient(deploymentName) + .AsAIAgent(new ChatClientAgentOptions + { + Name = "SkillsAgent", + ChatOptions = new() + { + Instructions = "You are a helpful assistant.", + }, + AIContextProviders = [skillsProvider], + }); + +// --- Example 1: Expense policy question (loads FAQ resource) --- +Console.WriteLine("Example 1: Checking expense policy FAQ"); +Console.WriteLine("---------------------------------------"); +AgentResponse response1 = await agent.RunAsync("Are tips reimbursable? I left a 25% tip on a taxi ride and want to know if that's covered."); +Console.WriteLine($"Agent: {response1.Text}\n"); + +// --- Example 2: Filing an expense report (multi-turn with template asset) --- +Console.WriteLine("Example 2: Filing an expense report"); +Console.WriteLine("---------------------------------------"); +AgentSession session = await agent.CreateSessionAsync(); +AgentResponse response2 = await agent.RunAsync("I had 3 client dinners and a $1,200 flight last week. Return a draft expense report and ask about any missing details.", + session); +Console.WriteLine($"Agent: {response2.Text}\n"); diff --git a/dotnet/samples/GettingStarted/AgentSkills/Agent_Step01_BasicSkills/README.md b/dotnet/samples/GettingStarted/AgentSkills/Agent_Step01_BasicSkills/README.md new file mode 100644 index 0000000000..78099fa8a5 --- /dev/null +++ b/dotnet/samples/GettingStarted/AgentSkills/Agent_Step01_BasicSkills/README.md @@ -0,0 +1,63 @@ +# Agent Skills Sample + +This sample demonstrates how to use **Agent Skills** with a `ChatClientAgent` in the Microsoft Agent Framework. + +## What are Agent Skills? + +Agent Skills are modular packages of instructions and resources that enable AI agents to perform specialized tasks. They follow the [Agent Skills specification](https://agentskills.io/) and implement the progressive disclosure pattern: + +1. **Advertise**: Skills are advertised with name + description (~100 tokens per skill) +2. **Load**: Full instructions are loaded on-demand via `load_skill` tool +3. **Resources**: References and other files loaded via `read_skill_resource` tool + +## Skills Included + +### expense-report +Policy-based expense filing with spending limits, receipt requirements, and approval workflows. +- `references/POLICY_FAQ.md` — Detailed expense policy Q&A +- `assets/expense-report-template.md` — Submission template + +## Project Structure + +``` +Agent_Step01_BasicSkills/ +├── Program.cs +├── Agent_Step01_BasicSkills.csproj +└── skills/ + └── expense-report/ + ├── SKILL.md + ├── references/ + │ └── POLICY_FAQ.md + └── assets/ + └── expense-report-template.md +``` + +## Running the Sample + +### Prerequisites +- .NET 10.0 SDK +- Azure OpenAI endpoint with a deployed model + +### Setup +1. Set environment variables: + ```bash + export AZURE_OPENAI_ENDPOINT="https://your-endpoint.openai.azure.com/" + export AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini" + ``` + +2. Run the sample: + ```bash + dotnet run + ``` + +### Examples + +The sample runs two examples: + +1. **Expense policy FAQ** — Asks about tip reimbursement; the agent loads the expense-report skill and reads the FAQ resource +2. **Filing an expense report** — Multi-turn conversation to draft an expense report using the template asset + +## Learn More + +- [Agent Skills Specification](https://agentskills.io/) +- [Microsoft Agent Framework Documentation](../../../../../docs/) diff --git a/dotnet/samples/GettingStarted/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/SKILL.md b/dotnet/samples/GettingStarted/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/SKILL.md new file mode 100644 index 0000000000..fc6c83cf30 --- /dev/null +++ b/dotnet/samples/GettingStarted/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/SKILL.md @@ -0,0 +1,40 @@ +--- +name: expense-report +description: File and validate employee expense reports according to Contoso company policy. Use when asked about expense submissions, reimbursement rules, receipt requirements, spending limits, or expense categories. +metadata: + author: contoso-finance + version: "2.1" +--- + +# Expense Report + +## Categories and Limits + +| Category | Limit | Receipt | Approval | +|---|---|---|---| +| Meals — solo | $50/day | >$25 | No | +| Meals — team/client | $75/person | Always | Manager if >$200 total | +| Lodging | $250/night | Always | Manager if >3 nights | +| Ground transport | $100/day | >$15 | No | +| Airfare | Economy | Always | Manager; VP if >$1,500 | +| Conference/training | $2,000/event | Always | Manager + L&D | +| Office supplies | $100 | Yes | No | +| Software/subscriptions | $50/month | Yes | Manager if >$200/year | + +## Filing Process + +1. Collect receipts — must show vendor, date, amount, payment method. +2. Categorize per table above. +3. Use template: [assets/expense-report-template.md](assets/expense-report-template.md). +4. For client/team meals: list attendee names and business purpose. +5. Submit — auto-approved if <$500; manager if $500–$2,000; VP if >$2,000. +6. Reimbursement: 10 business days via direct deposit. + +## Policy Rules + +- Submit within 30 days of transaction. +- Alcohol is never reimbursable. +- Foreign currency: convert to USD at transaction-date rate; note original currency and amount. +- Mixed personal/business travel: only business portion reimbursable; provide comparison quotes. +- Lost receipts (>$25): file Lost Receipt Affidavit from Finance. Max 2 per quarter. +- For policy questions not covered above, consult the FAQ: [references/POLICY_FAQ.md](references/POLICY_FAQ.md). Answers should be based on what this document and the FAQ state. diff --git a/dotnet/samples/GettingStarted/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/assets/expense-report-template.md b/dotnet/samples/GettingStarted/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/assets/expense-report-template.md new file mode 100644 index 0000000000..3f7c7dc36c --- /dev/null +++ b/dotnet/samples/GettingStarted/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/assets/expense-report-template.md @@ -0,0 +1,5 @@ +# Expense Report Template + +| Date | Category | Vendor | Description | Amount (USD) | Original Currency | Original Amount | Attendees | Business Purpose | Receipt Attached | +|------|----------|--------|-------------|--------------|-------------------|-----------------|-----------|------------------|------------------| +| | | | | | | | | | Yes or No | diff --git a/dotnet/samples/GettingStarted/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/references/POLICY_FAQ.md b/dotnet/samples/GettingStarted/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/references/POLICY_FAQ.md new file mode 100644 index 0000000000..8e971192f8 --- /dev/null +++ b/dotnet/samples/GettingStarted/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/references/POLICY_FAQ.md @@ -0,0 +1,55 @@ +# Expense Policy — Frequently Asked Questions + +## Meals + +**Q: Can I expense coffee or snacks during the workday?** +A: Daily coffee/snacks under $10 are not reimbursable (considered personal). Coffee purchased during a client meeting or team working session is reimbursable as a team meal. + +**Q: What if a team dinner exceeds the per-person limit?** +A: The $75/person limit applies as a guideline. Overages up to 20% are accepted with a written justification (e.g., "client dinner at venue chosen by client"). Overages beyond 20% require pre-approval from your VP. + +**Q: Do I need to list every attendee?** +A: Yes. For client meals, list the client's name and company. For team meals, list all employee names. For groups over 10, you may attach a separate attendee list. + +## Travel + +**Q: Can I book a premium economy or business class flight?** +A: Economy class is the standard. Premium economy is allowed for flights over 6 hours. Business class requires VP pre-approval and is generally reserved for flights over 10 hours or medical accommodation. + +**Q: What about ride-sharing (Uber/Lyft) vs. rental cars?** +A: Use ride-sharing for trips under 30 miles round-trip. Rent a car for multi-day travel or when ride-sharing would exceed $100/day. Always choose the compact/standard category unless traveling with 3+ people. + +**Q: Are tips reimbursable?** +A: Tips up to 20% are reimbursable for meals, taxi/ride-share, and hotel housekeeping. Tips above 20% require justification. + +## Lodging + +**Q: What if the $250/night limit isn't enough for the city I'm visiting?** +A: For high-cost cities (New York, San Francisco, London, Tokyo, Sydney), the limit is automatically increased to $350/night. No additional approval is needed. For other locations where rates are unusually high (e.g., during a major conference), request a per-trip exception from your manager before booking. + +**Q: Can I stay with friends/family instead and get a per-diem?** +A: No. Contoso reimburses actual lodging costs only, not per-diems. + +## Subscriptions and Software + +**Q: Can I expense a personal productivity tool?** +A: Software must be directly related to your job function. Tools like IDE licenses, design software, or project management apps are reimbursable. General productivity apps (note-taking, personal calendar) are not, unless your manager confirms a business need in writing. + +**Q: What about annual subscriptions?** +A: Annual subscriptions over $200 require manager approval before purchase. Submit the approval email with your expense report. + +## Receipts and Documentation + +**Q: My receipt is faded/damaged. What do I do?** +A: Try to obtain a duplicate from the vendor. If not possible, submit a Lost Receipt Affidavit (available from the Finance SharePoint site). You're limited to 2 affidavits per quarter. + +**Q: Do I need a receipt for parking meters or tolls?** +A: For amounts under $15, no receipt is required — just note the date, location, and amount. For $15 and above, a receipt or bank/credit card statement excerpt is required. + +## Approval and Reimbursement + +**Q: My manager is on leave. Who approves my report?** +A: Expense reports can be approved by your skip-level manager or any manager designated as an alternate approver in the expense system. + +**Q: Can I submit expenses from a previous quarter?** +A: The standard 30-day window applies. Expenses older than 30 days require a written explanation and VP approval. Expenses older than 90 days are not reimbursable except in extraordinary circumstances (extended leave, medical emergency) with CFO approval. diff --git a/dotnet/samples/GettingStarted/AgentSkills/README.md b/dotnet/samples/GettingStarted/AgentSkills/README.md new file mode 100644 index 0000000000..8488ec9eed --- /dev/null +++ b/dotnet/samples/GettingStarted/AgentSkills/README.md @@ -0,0 +1,7 @@ +# AgentSkills Samples + +Samples demonstrating Agent Skills capabilities. + +| Sample | Description | +|--------|-------------| +| [Agent_Step01_BasicSkills](Agent_Step01_BasicSkills/) | Using Agent Skills with a ChatClientAgent, including progressive disclosure and skill resources | diff --git a/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/AgentWithMemory_Step04_MemoryUsingFoundry.csproj b/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/AgentWithMemory_Step04_MemoryUsingFoundry.csproj new file mode 100644 index 0000000000..0b6c06a5a8 --- /dev/null +++ b/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/AgentWithMemory_Step04_MemoryUsingFoundry.csproj @@ -0,0 +1,21 @@ + + + + Exe + net10.0 + + enable + enable + + + + + + + + + + + + + diff --git a/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/Program.cs b/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/Program.cs new file mode 100644 index 0000000000..b3533e6d1d --- /dev/null +++ b/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/Program.cs @@ -0,0 +1,77 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample shows how to use the FoundryMemoryProvider to persist and recall memories for an agent. +// The sample stores conversation messages in an Azure AI Foundry memory store and retrieves relevant +// memories for subsequent invocations, even across new sessions. +// +// Note: Memory extraction in Azure AI Foundry is asynchronous and takes time. This sample demonstrates +// a simple polling approach to wait for memory updates to complete before querying. + +using System.Text.Json; +using Azure.AI.Projects; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.FoundryMemory; + +string foundryEndpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."); +string memoryStoreName = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_MEMORY_STORE_NAME") ?? "memory-store-sample"; +string deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_MODEL") ?? "gpt-4.1-mini"; +string embeddingModelName = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_EMBEDDING_MODEL") ?? "text-embedding-ada-002"; + +// Create an AIProjectClient for Foundry with Azure Identity authentication. +DefaultAzureCredential credential = new(); +AIProjectClient projectClient = new(new Uri(foundryEndpoint), credential); + +// Get the ChatClient from the AIProjectClient's OpenAI property using the deployment name. +// The stateInitializer can be used to customize the Foundry Memory scope per session and it will be called each time a session +// is encountered by the FoundryMemoryProvider that does not already have state stored on the session. +// If each session should have its own scope, you can create a new id per session via the stateInitializer, e.g.: +// new FoundryMemoryProvider(projectClient, memoryStoreName, stateInitializer: _ => new(new FoundryMemoryProviderScope(Guid.NewGuid().ToString())), ...) +// In our case we are storing memories scoped by user so that memories are retained across sessions. +FoundryMemoryProvider memoryProvider = new( + projectClient, + memoryStoreName, + stateInitializer: _ => new(new FoundryMemoryProviderScope("sample-user-123"))); + +AIAgent agent = await projectClient.CreateAIAgentAsync(deploymentName, + options: new ChatClientAgentOptions() + { + Name = "TravelAssistantWithFoundryMemory", + ChatOptions = new() { Instructions = "You are a friendly travel assistant. Use known memories about the user when responding, and do not invent details." }, + AIContextProviders = [memoryProvider] + }); + +AgentSession session = await agent.CreateSessionAsync(); + +Console.WriteLine("\n>> Setting up Foundry Memory Store\n"); + +// Ensure the memory store exists (creates it with the specified models if needed). +await memoryProvider.EnsureMemoryStoreCreatedAsync(deploymentName, embeddingModelName, "Sample memory store for travel assistant"); + +// Clear any existing memories for this scope to demonstrate fresh behavior. +await memoryProvider.EnsureStoredMemoriesDeletedAsync(session); + +Console.WriteLine(await agent.RunAsync("Hi there! My name is Taylor and I'm planning a hiking trip to Patagonia in November.", session)); +Console.WriteLine(await agent.RunAsync("I'm travelling with my sister and we love finding scenic viewpoints.", session)); + +// Memory extraction in Azure AI Foundry is asynchronous and takes time to process. +// WhenUpdatesCompletedAsync polls all pending updates and waits for them to complete. +Console.WriteLine("\nWaiting for Foundry Memory to process updates..."); +await memoryProvider.WhenUpdatesCompletedAsync(); + +Console.WriteLine("Updates completed.\n"); + +Console.WriteLine(await agent.RunAsync("What do you already know about my upcoming trip?", session)); + +Console.WriteLine("\n>> Serialize and deserialize the session to demonstrate persisted state\n"); +JsonElement serializedSession = await agent.SerializeSessionAsync(session); +AgentSession restoredSession = await agent.DeserializeSessionAsync(serializedSession); +Console.WriteLine(await agent.RunAsync("Can you recap the personal details you remember?", restoredSession)); + +Console.WriteLine("\n>> Start a new session that shares the same Foundry Memory scope\n"); + +Console.WriteLine("\nWaiting for Foundry Memory to process updates..."); +await memoryProvider.WhenUpdatesCompletedAsync(); + +AgentSession newSession = await agent.CreateSessionAsync(); +Console.WriteLine(await agent.RunAsync("Summarize what you already know about me.", newSession)); diff --git a/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/README.md b/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/README.md new file mode 100644 index 0000000000..dfea386d82 --- /dev/null +++ b/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/README.md @@ -0,0 +1,57 @@ +# Agent with Memory Using Azure AI Foundry + +This sample demonstrates how to create and run an agent that uses Azure AI Foundry's managed memory service to extract and retrieve individual memories across sessions. + +## Features Demonstrated + +- Creating a `FoundryMemoryProvider` with Azure Identity authentication +- Automatic memory store creation if it doesn't exist +- Multi-turn conversations with automatic memory extraction +- Memory retrieval to inform agent responses +- Session serialization and deserialization +- Memory persistence across completely new sessions + +## Prerequisites + +1. Azure subscription with Azure AI Foundry project +2. Azure OpenAI resource with a chat model deployment (e.g., gpt-4o-mini) and an embedding model deployment (e.g., text-embedding-ada-002) +3. .NET 10.0 SDK +4. Azure CLI logged in (`az login`) + +## Environment Variables + +```bash +# Azure AI Foundry project endpoint and memory store name +export FOUNDRY_PROJECT_ENDPOINT="https://your-account.services.ai.azure.com/api/projects/your-project" +export FOUNDRY_PROJECT_MEMORY_STORE_NAME="my_memory_store" + +# Model deployment names (models deployed in your Foundry project) +export FOUNDRY_PROJECT_MODEL="gpt-4o-mini" +export FOUNDRY_PROJECT_EMBEDDING_MODEL="text-embedding-ada-002" +``` + +## Run the Sample + +```bash +dotnet run +``` + +## Expected Output + +The agent will: +1. Create the memory store if it doesn't exist (using the specified chat and embedding models) +2. Learn your name (Taylor), travel destination (Patagonia), timing (November), companions (sister), and interests (scenic viewpoints) +3. Wait for Foundry Memory to index the memories +4. Recall those details when asked about the trip +5. Demonstrate memory persistence across session serialization/deserialization +6. Show that a brand new session can still access the same memories + +## Key Differences from Mem0 + +| Aspect | Mem0 | Azure AI Foundry Memory | +|--------|------|------------------------| +| Authentication | API Key | Azure Identity (DefaultAzureCredential) | +| Scope | ApplicationId, UserId, AgentId, ThreadId | Single `Scope` string | +| Memory Types | Single memory store | User Profile + Chat Summary | +| Hosting | Mem0 cloud or self-hosted | Azure AI Foundry managed service | +| Store Creation | N/A (automatic) | Explicit via `EnsureMemoryStoreCreatedAsync` | diff --git a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/AIProjectClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/AIProjectClientExtensions.cs new file mode 100644 index 0000000000..9e24703d92 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/AIProjectClientExtensions.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.ClientModel; +using System.Threading; +using System.Threading.Tasks; +using Azure.AI.Projects; + +namespace Microsoft.Agents.AI.FoundryMemory; + +/// +/// Internal extension methods for to provide MemoryStores helper operations. +/// +internal static class AIProjectClientExtensions +{ + /// + /// Creates a memory store if it doesn't already exist. + /// + internal static async Task CreateMemoryStoreIfNotExistsAsync( + this AIProjectClient client, + string memoryStoreName, + string? description, + string chatModel, + string embeddingModel, + CancellationToken cancellationToken) + { + try + { + await client.MemoryStores.GetMemoryStoreAsync(memoryStoreName, cancellationToken).ConfigureAwait(false); + return false; // Store already exists + } + catch (ClientResultException ex) when (ex.Status == 404) + { + // Store doesn't exist, create it + } + + MemoryStoreDefaultDefinition definition = new(chatModel, embeddingModel); + await client.MemoryStores.CreateMemoryStoreAsync(memoryStoreName, definition, description, cancellationToken: cancellationToken).ConfigureAwait(false); + return true; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryJsonUtilities.cs b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryJsonUtilities.cs new file mode 100644 index 0000000000..1a0dd4f4e2 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryJsonUtilities.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.FoundryMemory; + +/// +/// Provides JSON serialization utilities for the Foundry Memory provider. +/// +internal static class FoundryMemoryJsonUtilities +{ + /// + /// Gets the default JSON serializer options for Foundry Memory operations. + /// + public static JsonSerializerOptions DefaultOptions { get; } = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = false, + TypeInfoResolver = FoundryMemoryJsonContext.Default + }; +} + +/// +/// Source-generated JSON serialization context for Foundry Memory types. +/// +[JsonSourceGenerationOptions( + JsonSerializerDefaults.General, + UseStringEnumConverter = false, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + WriteIndented = false)] +[JsonSerializable(typeof(FoundryMemoryProviderScope))] +[JsonSerializable(typeof(FoundryMemoryProvider.State))] +internal partial class FoundryMemoryJsonContext : JsonSerializerContext; diff --git a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProvider.cs b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProvider.cs new file mode 100644 index 0000000000..9ffeda3fb5 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProvider.cs @@ -0,0 +1,440 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.ClientModel; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using Azure.AI.Projects; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; +using OpenAI.Responses; + +namespace Microsoft.Agents.AI.FoundryMemory; + +/// +/// Provides an Azure AI Foundry Memory backed that persists conversation messages as memories +/// and retrieves related memories to augment the agent invocation context. +/// +/// +/// The provider stores user, assistant and system messages as Foundry memories and retrieves relevant memories +/// for new invocations using the memory search endpoint. Retrieved memories are injected as user messages +/// to the model, prefixed by a configurable context prompt. +/// +[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)] +public sealed class FoundryMemoryProvider : AIContextProvider +{ + private const string DefaultContextPrompt = "## Memories\nConsider the following memories when answering user questions:"; + + private readonly ProviderSessionState _sessionState; + private readonly string _contextPrompt; + private readonly string _memoryStoreName; + private readonly int _maxMemories; + private readonly int _updateDelay; + private readonly bool _enableSensitiveTelemetryData; + + private readonly AIProjectClient _client; + private readonly ILogger? _logger; + + private string? _lastPendingUpdateId; + + /// + /// Initializes a new instance of the class. + /// + /// The Azure AI Project client configured for your Foundry project. + /// The name of the memory store in Azure AI Foundry. + /// A delegate that initializes the provider state on the first invocation, providing the scope for memory storage and retrieval. + /// Provider options. + /// Optional logger factory. + /// Thrown when or is . + /// Thrown when is null or whitespace. + public FoundryMemoryProvider( + AIProjectClient client, + string memoryStoreName, + Func stateInitializer, + FoundryMemoryProviderOptions? options = null, + ILoggerFactory? loggerFactory = null) + : base(options?.SearchInputMessageFilter, options?.StorageInputMessageFilter) + { + Throw.IfNull(client); + Throw.IfNullOrWhitespace(memoryStoreName); + + this._sessionState = new ProviderSessionState( + ValidateStateInitializer(Throw.IfNull(stateInitializer)), + options?.StateKey ?? this.GetType().Name, + FoundryMemoryJsonUtilities.DefaultOptions); + + FoundryMemoryProviderOptions effectiveOptions = options ?? new FoundryMemoryProviderOptions(); + + this._logger = loggerFactory?.CreateLogger(); + this._client = client; + + this._contextPrompt = effectiveOptions.ContextPrompt ?? DefaultContextPrompt; + this._memoryStoreName = memoryStoreName; + this._maxMemories = effectiveOptions.MaxMemories; + this._updateDelay = effectiveOptions.UpdateDelay; + this._enableSensitiveTelemetryData = effectiveOptions.EnableSensitiveTelemetryData; + } + + /// + public override string StateKey => this._sessionState.StateKey; + + private static Func ValidateStateInitializer(Func stateInitializer) => + session => + { + State state = stateInitializer(session); + + if (state is null) + { + throw new InvalidOperationException("State initializer must return a non-null state."); + } + + return state; + }; + + /// + protected override async ValueTask ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default) + { + Throw.IfNull(context); + + State state = this._sessionState.GetOrInitializeState(context.Session); + FoundryMemoryProviderScope scope = state.Scope; + + List messageItems = (context.AIContext.Messages ?? []) + .Where(m => !string.IsNullOrWhiteSpace(m.Text)) + .Select(m => (ResponseItem)ToResponseItem(m.Role, m.Text!)) + .ToList(); + + if (messageItems.Count == 0) + { + return new AIContext(); + } + + try + { + MemorySearchOptions searchOptions = new(scope.Scope) + { + ResultOptions = new MemorySearchResultOptions { MaxMemories = this._maxMemories } + }; + + foreach (ResponseItem item in messageItems) + { + searchOptions.Items.Add(item); + } + + ClientResult result = await this._client.MemoryStores.SearchMemoriesAsync( + this._memoryStoreName, + searchOptions, + cancellationToken).ConfigureAwait(false); + + MemoryStoreSearchResponse response = result.Value; + + List memories = response.Memories + .Select(m => m.MemoryItem?.Content ?? string.Empty) + .Where(c => !string.IsNullOrWhiteSpace(c)) + .ToList(); + + string? outputMessageText = memories.Count == 0 + ? null + : $"{this._contextPrompt}\n{string.Join(Environment.NewLine, memories)}"; + + if (this._logger?.IsEnabled(LogLevel.Information) is true) + { + this._logger.LogInformation( + "FoundryMemoryProvider: Retrieved {Count} memories. MemoryStore: '{MemoryStoreName}', Scope: '{Scope}'.", + memories.Count, + this._memoryStoreName, + this.SanitizeLogData(scope.Scope)); + + if (outputMessageText is not null && this._logger.IsEnabled(LogLevel.Trace)) + { + this._logger.LogTrace( + "FoundryMemoryProvider: Search Results\nOutput:{MessageText}\nMemoryStore: '{MemoryStoreName}', Scope: '{Scope}'.", + this.SanitizeLogData(outputMessageText), + this._memoryStoreName, + this.SanitizeLogData(scope.Scope)); + } + } + + return new AIContext + { + Messages = [new ChatMessage(ChatRole.User, outputMessageText)] + }; + } + catch (ArgumentException) + { + throw; + } + catch (Exception ex) + { + if (this._logger?.IsEnabled(LogLevel.Error) is true) + { + this._logger.LogError( + ex, + "FoundryMemoryProvider: Failed to search for memories due to error. MemoryStore: '{MemoryStoreName}', Scope: '{Scope}'.", + this._memoryStoreName, + this.SanitizeLogData(scope.Scope)); + } + + return new AIContext(); + } + } + + /// + protected override async ValueTask StoreAIContextAsync(InvokedContext context, CancellationToken cancellationToken = default) + { + State state = this._sessionState.GetOrInitializeState(context.Session); + FoundryMemoryProviderScope scope = state.Scope; + + try + { + List messageItems = context.RequestMessages + .Concat(context.ResponseMessages ?? []) + .Where(m => IsAllowedRole(m.Role) && !string.IsNullOrWhiteSpace(m.Text)) + .Select(m => (ResponseItem)ToResponseItem(m.Role, m.Text!)) + .ToList(); + + if (messageItems.Count == 0) + { + return; + } + + MemoryUpdateOptions updateOptions = new(scope.Scope) + { + UpdateDelay = this._updateDelay + }; + + foreach (ResponseItem item in messageItems) + { + updateOptions.Items.Add(item); + } + + ClientResult result = await this._client.MemoryStores.UpdateMemoriesAsync( + this._memoryStoreName, + updateOptions, + cancellationToken).ConfigureAwait(false); + + MemoryUpdateResult response = result.Value; + + if (response.UpdateId is not null) + { + Interlocked.Exchange(ref this._lastPendingUpdateId, response.UpdateId); + } + + if (this._logger?.IsEnabled(LogLevel.Information) is true) + { + this._logger.LogInformation( + "FoundryMemoryProvider: Sent {Count} messages to update memories. MemoryStore: '{MemoryStoreName}', Scope: '{Scope}', UpdateId: '{UpdateId}'.", + messageItems.Count, + this._memoryStoreName, + this.SanitizeLogData(scope.Scope), + response.UpdateId); + } + } + catch (Exception ex) + { + if (this._logger?.IsEnabled(LogLevel.Error) is true) + { + this._logger.LogError( + ex, + "FoundryMemoryProvider: Failed to send messages to update memories due to error. MemoryStore: '{MemoryStoreName}', Scope: '{Scope}'.", + this._memoryStoreName, + this.SanitizeLogData(scope.Scope)); + } + } + } + + /// + /// Ensures all stored memories for the configured scope are deleted. + /// This method handles cases where the scope doesn't exist (no memories stored yet). + /// + /// The session containing the scope state to clear memories for. + /// Cancellation token. + public async Task EnsureStoredMemoriesDeletedAsync(AgentSession session, CancellationToken cancellationToken = default) + { + Throw.IfNull(session); + State state = this._sessionState.GetOrInitializeState(session); + FoundryMemoryProviderScope scope = state.Scope; + + try + { + await this._client.MemoryStores.DeleteScopeAsync(this._memoryStoreName, scope.Scope, cancellationToken).ConfigureAwait(false); + + if (this._logger?.IsEnabled(LogLevel.Information) is true) + { + this._logger.LogInformation( + "FoundryMemoryProvider: Deleted stored memories for scope. MemoryStore: '{MemoryStoreName}', Scope: '{Scope}'.", + this._memoryStoreName, + this.SanitizeLogData(scope.Scope)); + } + } + catch (ClientResultException ex) when (ex.Status == 404) + { + // Scope doesn't exist (no memories stored yet), nothing to delete + if (this._logger?.IsEnabled(LogLevel.Debug) is true) + { + this._logger.LogDebug( + "FoundryMemoryProvider: No memories to delete for scope. MemoryStore: '{MemoryStoreName}', Scope: '{Scope}'.", + this._memoryStoreName, + this.SanitizeLogData(scope.Scope)); + } + } + } + + /// + /// Ensures the memory store exists, creating it if necessary. + /// + /// The deployment name of the chat model for memory processing. + /// The deployment name of the embedding model for memory search. + /// Optional description for the memory store. + /// Cancellation token. + public async Task EnsureMemoryStoreCreatedAsync( + string chatModel, + string embeddingModel, + string? description = null, + CancellationToken cancellationToken = default) + { + bool created = await this._client.CreateMemoryStoreIfNotExistsAsync( + this._memoryStoreName, + description, + chatModel, + embeddingModel, + cancellationToken).ConfigureAwait(false); + + if (created) + { + if (this._logger?.IsEnabled(LogLevel.Information) is true) + { + this._logger.LogInformation( + "FoundryMemoryProvider: Created memory store '{MemoryStoreName}'.", + this._memoryStoreName); + } + } + else + { + if (this._logger?.IsEnabled(LogLevel.Debug) is true) + { + this._logger.LogDebug( + "FoundryMemoryProvider: Memory store '{MemoryStoreName}' already exists.", + this._memoryStoreName); + } + } + } + + /// + /// Waits for all pending memory update operations to complete. + /// + /// + /// Memory extraction in Azure AI Foundry is asynchronous. This method polls the latest pending update + /// and returns when it has completed, failed, or been superseded. Since updates are processed in order, + /// completion of the latest update implies all prior updates have also been processed. + /// + /// The interval between status checks. Defaults to 5 seconds. + /// Cancellation token. + /// Thrown if the update operation failed. + public async Task WhenUpdatesCompletedAsync( + TimeSpan? pollingInterval = null, + CancellationToken cancellationToken = default) + { + string? updateId = Volatile.Read(ref this._lastPendingUpdateId); + if (updateId is null) + { + return; + } + + TimeSpan interval = pollingInterval ?? TimeSpan.FromSeconds(5); + await this.WaitForUpdateAsync(updateId, interval, cancellationToken).ConfigureAwait(false); + + // Only clear the pending update ID after successful completion + Interlocked.CompareExchange(ref this._lastPendingUpdateId, null, updateId); + } + + private async Task WaitForUpdateAsync(string updateId, TimeSpan interval, CancellationToken cancellationToken) + { + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + + ClientResult result = await this._client.MemoryStores.GetUpdateResultAsync( + this._memoryStoreName, + updateId, + cancellationToken).ConfigureAwait(false); + + MemoryUpdateResult response = result.Value; + MemoryStoreUpdateStatus status = response.Status; + + if (this._logger?.IsEnabled(LogLevel.Debug) is true) + { + this._logger.LogDebug( + "FoundryMemoryProvider: Update status for '{UpdateId}': {Status}", + updateId, + status); + } + + if (status == MemoryStoreUpdateStatus.Completed || status == MemoryStoreUpdateStatus.Superseded) + { + return; + } + + if (status == MemoryStoreUpdateStatus.Failed) + { + throw new InvalidOperationException($"Memory update operation '{updateId}' failed: {response.ErrorDetails}"); + } + + if (status == MemoryStoreUpdateStatus.Queued || status == MemoryStoreUpdateStatus.InProgress) + { + await Task.Delay(interval, cancellationToken).ConfigureAwait(false); + } + else + { + throw new InvalidOperationException($"Unknown update status '{status}' for update '{updateId}'."); + } + } + } + + private static MessageResponseItem ToResponseItem(ChatRole role, string text) + { + if (role == ChatRole.Assistant) + { + return ResponseItem.CreateAssistantMessageItem(text); + } + + if (role == ChatRole.System) + { + return ResponseItem.CreateSystemMessageItem(text); + } + + return ResponseItem.CreateUserMessageItem(text); + } + + private static bool IsAllowedRole(ChatRole role) => + role == ChatRole.User || role == ChatRole.Assistant || role == ChatRole.System; + + private string? SanitizeLogData(string? data) => this._enableSensitiveTelemetryData ? data : ""; + + /// + /// Represents the state of a stored in the . + /// + public sealed class State + { + /// + /// Initializes a new instance of the class with the specified scope. + /// + /// The scope to use for memory storage and retrieval. + [JsonConstructor] + public State(FoundryMemoryProviderScope scope) + { + this.Scope = Throw.IfNull(scope); + } + + /// + /// Gets the scope used for memory storage and retrieval. + /// + public FoundryMemoryProviderScope Scope { get; } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProviderOptions.cs b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProviderOptions.cs new file mode 100644 index 0000000000..482e14db82 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProviderOptions.cs @@ -0,0 +1,67 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.FoundryMemory; + +/// +/// Options for configuring the . +/// +public sealed class FoundryMemoryProviderOptions +{ + /// + /// When providing memories to the model, this string is prefixed to the retrieved memories to supply context. + /// + /// Defaults to "## Memories\nConsider the following memories when answering user questions:". + public string? ContextPrompt { get; set; } + + /// + /// Gets or sets the maximum number of memories to retrieve during search. + /// + /// Defaults to 5. + public int MaxMemories { get; set; } = 5; + + /// + /// Gets or sets the delay in seconds before memory updates are processed. + /// + /// + /// Setting to 0 triggers updates immediately without waiting for inactivity. + /// Higher values allow the service to batch multiple updates together. + /// + /// Defaults to 0 (immediate). + public int UpdateDelay { get; set; } + + /// + /// Gets or sets a value indicating whether sensitive data such as user ids and user messages may appear in logs. + /// + /// Defaults to . + public bool EnableSensitiveTelemetryData { get; set; } + + /// + /// Gets or sets the key used to store the provider state in the session's . + /// + /// Defaults to the provider's type name. + public string? StateKey { get; set; } + + /// + /// Gets or sets an optional filter function applied to request messages when building the search text to use when + /// searching for relevant memories during . + /// + /// + /// When , the provider defaults to including only + /// messages. + /// + public Func, IEnumerable>? SearchInputMessageFilter { get; set; } + + /// + /// Gets or sets an optional filter function applied to request messages when determining which messages to + /// extract memories from during . + /// + /// + /// When , the provider defaults to including only + /// messages. + /// + public Func, IEnumerable>? StorageInputMessageFilter { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProviderScope.cs b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProviderScope.cs new file mode 100644 index 0000000000..717df1d12b --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProviderScope.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.FoundryMemory; + +/// +/// Allows scoping of memories for the . +/// +/// +/// Azure AI Foundry memories are scoped by a single string identifier that you control. +/// Common patterns include using a user ID, team ID, or other unique identifier +/// to partition memories across different contexts. +/// +public sealed class FoundryMemoryProviderScope +{ + /// + /// Initializes a new instance of the class with the specified scope identifier. + /// + /// The scope identifier used to partition memories. Must not be null or whitespace. + /// Thrown when is null or whitespace. + public FoundryMemoryProviderScope(string scope) + { + Throw.IfNullOrWhitespace(scope); + this.Scope = scope; + } + + /// + /// Gets the scope identifier used to partition memories. + /// + /// + /// This value controls how memory is partitioned in the memory store. + /// Each unique scope maintains its own isolated collection of memory items. + /// For example, use a user ID to ensure each user has their own individual memory. + /// + public string Scope { get; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/Microsoft.Agents.AI.FoundryMemory.csproj b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/Microsoft.Agents.AI.FoundryMemory.csproj new file mode 100644 index 0000000000..75da2bccc5 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/Microsoft.Agents.AI.FoundryMemory.csproj @@ -0,0 +1,41 @@ + + + + preview + $(NoWarn);OPENAI001 + + + + true + true + true + true + + + + + + false + + + + + + + + + + + + + + Microsoft Agent Framework - Azure AI Foundry Memory integration + Provides Azure AI Foundry Memory integration for Microsoft Agent Framework. + + + + + + + + diff --git a/dotnet/src/Microsoft.Agents.AI/Memory/ChatHistoryMemoryProvider.cs b/dotnet/src/Microsoft.Agents.AI/Memory/ChatHistoryMemoryProvider.cs index c6ca35951e..7905db74b8 100644 --- a/dotnet/src/Microsoft.Agents.AI/Memory/ChatHistoryMemoryProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Memory/ChatHistoryMemoryProvider.cs @@ -41,6 +41,18 @@ public sealed class ChatHistoryMemoryProvider : MessageAIContextProvider, IDispo private const string DefaultFunctionToolName = "Search"; private const string DefaultFunctionToolDescription = "Allows searching for related previous chat history to help answer the user question."; + private const string KeyField = "Key"; + private const string RoleField = "Role"; + private const string MessageIdField = "MessageId"; + private const string AuthorNameField = "AuthorName"; + private const string ApplicationIdField = "ApplicationId"; + private const string AgentIdField = "AgentId"; + private const string UserIdField = "UserId"; + private const string SessionIdField = "SessionId"; + private const string ContentField = "Content"; + private const string CreatedAtField = "CreatedAt"; + private const string ContentEmbeddingField = "ContentEmbedding"; + private readonly ProviderSessionState _sessionState; #pragma warning disable CA2213 // VectorStore is not owned by this class - caller is responsible for disposal @@ -98,17 +110,17 @@ public sealed class ChatHistoryMemoryProvider : MessageAIContextProvider, IDispo { Properties = [ - new VectorStoreKeyProperty("Key", typeof(Guid)), - new VectorStoreDataProperty("Role", typeof(string)) { IsIndexed = true }, - new VectorStoreDataProperty("MessageId", typeof(string)) { IsIndexed = true }, - new VectorStoreDataProperty("AuthorName", typeof(string)), - new VectorStoreDataProperty("ApplicationId", typeof(string)) { IsIndexed = true }, - new VectorStoreDataProperty("AgentId", typeof(string)) { IsIndexed = true }, - new VectorStoreDataProperty("UserId", typeof(string)) { IsIndexed = true }, - new VectorStoreDataProperty("SessionId", typeof(string)) { IsIndexed = true }, - new VectorStoreDataProperty("Content", typeof(string)) { IsFullTextIndexed = true }, - new VectorStoreDataProperty("CreatedAt", typeof(string)) { IsIndexed = true }, - new VectorStoreVectorProperty("ContentEmbedding", typeof(string), Throw.IfLessThan(vectorDimensions, 1)) + new VectorStoreKeyProperty(KeyField, typeof(Guid)), + new VectorStoreDataProperty(RoleField, typeof(string)) { IsIndexed = true }, + new VectorStoreDataProperty(MessageIdField, typeof(string)) { IsIndexed = true }, + new VectorStoreDataProperty(AuthorNameField, typeof(string)), + new VectorStoreDataProperty(ApplicationIdField, typeof(string)) { IsIndexed = true }, + new VectorStoreDataProperty(AgentIdField, typeof(string)) { IsIndexed = true }, + new VectorStoreDataProperty(UserIdField, typeof(string)) { IsIndexed = true }, + new VectorStoreDataProperty(SessionIdField, typeof(string)) { IsIndexed = true }, + new VectorStoreDataProperty(ContentField, typeof(string)) { IsFullTextIndexed = true }, + new VectorStoreDataProperty(CreatedAtField, typeof(string)) { IsIndexed = true }, + new VectorStoreVectorProperty(ContentEmbeddingField, typeof(string), Throw.IfLessThan(vectorDimensions, 1)) ] }; @@ -233,17 +245,17 @@ public sealed class ChatHistoryMemoryProvider : MessageAIContextProvider, IDispo .Concat(context.ResponseMessages ?? []) .Select(message => new Dictionary { - ["Key"] = Guid.NewGuid(), - ["Role"] = message.Role.ToString(), - ["MessageId"] = message.MessageId, - ["AuthorName"] = message.AuthorName, - ["ApplicationId"] = storageScope.ApplicationId, - ["AgentId"] = storageScope.AgentId, - ["UserId"] = storageScope.UserId, - ["SessionId"] = storageScope.SessionId, - ["Content"] = message.Text, - ["CreatedAt"] = message.CreatedAt?.ToString("O") ?? DateTimeOffset.UtcNow.ToString("O"), - ["ContentEmbedding"] = message.Text, + [KeyField] = Guid.NewGuid(), + [RoleField] = message.Role.ToString(), + [MessageIdField] = message.MessageId, + [AuthorNameField] = message.AuthorName, + [ApplicationIdField] = storageScope.ApplicationId, + [AgentIdField] = storageScope.AgentId, + [UserIdField] = storageScope.UserId, + [SessionIdField] = storageScope.SessionId, + [ContentField] = message.Text, + [CreatedAtField] = message.CreatedAt?.ToString("O") ?? DateTimeOffset.UtcNow.ToString("O"), + [ContentEmbeddingField] = message.Text, }) .ToList(); @@ -288,7 +300,7 @@ public sealed class ChatHistoryMemoryProvider : MessageAIContextProvider, IDispo } // Format the results as a single context message - var outputResultsText = string.Join("\n", results.Select(x => (string?)x["Content"]).Where(c => !string.IsNullOrWhiteSpace(c))); + var outputResultsText = string.Join("\n", results.Select(x => (string?)x[ContentField]).Where(c => !string.IsNullOrWhiteSpace(c))); if (string.IsNullOrWhiteSpace(outputResultsText)) { return string.Empty; @@ -340,12 +352,12 @@ public sealed class ChatHistoryMemoryProvider : MessageAIContextProvider, IDispo Expression, bool>>? filter = null; if (applicationId != null) { - filter = x => (string?)x["ApplicationId"] == applicationId; + filter = x => (string?)x[ApplicationIdField] == applicationId; } if (agentId != null) { - Expression, bool>> agentIdFilter = x => (string?)x["AgentId"] == agentId; + Expression, bool>> agentIdFilter = x => (string?)x[AgentIdField] == agentId; filter = filter == null ? agentIdFilter : Expression.Lambda, bool>>( Expression.AndAlso(filter.Body, agentIdFilter.Body), filter.Parameters); @@ -353,7 +365,7 @@ public sealed class ChatHistoryMemoryProvider : MessageAIContextProvider, IDispo if (userId != null) { - Expression, bool>> userIdFilter = x => (string?)x["UserId"] == userId; + Expression, bool>> userIdFilter = x => (string?)x[UserIdField] == userId; filter = filter == null ? userIdFilter : Expression.Lambda, bool>>( Expression.AndAlso(filter.Body, userIdFilter.Body), filter.Parameters); @@ -361,7 +373,7 @@ public sealed class ChatHistoryMemoryProvider : MessageAIContextProvider, IDispo if (sessionId != null) { - Expression, bool>> sessionIdFilter = x => (string?)x["SessionId"] == sessionId; + Expression, bool>> sessionIdFilter = x => (string?)x[SessionIdField] == sessionId; filter = filter == null ? sessionIdFilter : Expression.Lambda, bool>>( Expression.AndAlso(filter.Body, sessionIdFilter.Body), filter.Parameters); diff --git a/dotnet/src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj b/dotnet/src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj index a994afe75c..f036812900 100644 --- a/dotnet/src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj +++ b/dotnet/src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj @@ -2,12 +2,14 @@ true - $(NoWarn);MEAI001 + $(NoWarn);MEAI001;MAAI001 true + true true + true true true diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkill.cs b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkill.cs new file mode 100644 index 0000000000..f28bad3ab0 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkill.cs @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// Represents a loaded Agent Skill discovered from a filesystem directory. +/// +/// +/// Each skill is backed by a SKILL.md file containing YAML frontmatter (name and description) +/// and a markdown body with instructions. Resource files referenced in the body are validated at +/// discovery time and read from disk on demand. +/// +internal sealed class FileAgentSkill +{ + /// + /// Initializes a new instance of the class. + /// + /// Parsed YAML frontmatter (name and description). + /// The SKILL.md content after the closing --- delimiter. + /// Absolute path to the directory containing this skill. + /// Relative paths of resource files referenced in the skill body. + public FileAgentSkill( + SkillFrontmatter frontmatter, + string body, + string sourcePath, + IReadOnlyList? resourceNames = null) + { + this.Frontmatter = Throw.IfNull(frontmatter); + this.Body = Throw.IfNull(body); + this.SourcePath = Throw.IfNullOrWhitespace(sourcePath); + this.ResourceNames = resourceNames ?? []; + } + + /// + /// Gets the parsed YAML frontmatter (name and description). + /// + public SkillFrontmatter Frontmatter { get; } + + /// + /// Gets the SKILL.md body content (without the YAML frontmatter). + /// + public string Body { get; } + + /// + /// Gets the directory path where the skill was discovered. + /// + public string SourcePath { get; } + + /// + /// Gets the relative paths of resource files referenced in the skill body (e.g., "references/FAQ.md"). + /// + public IReadOnlyList ResourceNames { get; } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillLoader.cs b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillLoader.cs new file mode 100644 index 0000000000..8c034b3122 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillLoader.cs @@ -0,0 +1,407 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Agents.AI; + +/// +/// Discovers, parses, and validates SKILL.md files from filesystem directories. +/// +/// +/// Searches directories recursively (up to levels) for SKILL.md files. +/// Each file is validated for YAML frontmatter and resource integrity. Invalid skills are excluded +/// with logged warnings. Resource paths are checked against path traversal and symlink escape attacks. +/// +internal sealed partial class FileAgentSkillLoader +{ + private const string SkillFileName = "SKILL.md"; + private const int MaxSearchDepth = 2; + private const int MaxNameLength = 64; + private const int MaxDescriptionLength = 1024; + + // Matches YAML frontmatter delimited by "---" lines. Group 1 = content between delimiters. + // Multiline makes ^/$ match line boundaries; Singleline makes . match newlines across the block. + // The \uFEFF? prefix allows an optional UTF-8 BOM that some editors prepend. + // Example: "---\nname: foo\n---\nBody" → Group 1: "name: foo\n" + private static readonly Regex s_frontmatterRegex = new(@"\A\uFEFF?^---\s*$(.+?)^---\s*$", RegexOptions.Multiline | RegexOptions.Singleline | RegexOptions.Compiled, TimeSpan.FromSeconds(5)); + + // Matches markdown links to local resource files. Group 1 = relative file path. + // Supports optional ./ or ../ prefixes; excludes URLs (no ":" in the path character class). + // Intentionally conservative: only matches paths with word characters, hyphens, dots, + // and forward slashes. Paths with spaces or special characters are not supported. + // Examples: [doc](refs/FAQ.md) → "refs/FAQ.md", [s](./s.json) → "./s.json", + // [p](../shared/doc.txt) → "../shared/doc.txt" + private static readonly Regex s_resourceLinkRegex = new(@"\[.*?\]\((\.?\.?/?[\w][\w\-./]*\.\w+)\)", RegexOptions.Compiled, TimeSpan.FromSeconds(5)); + + // Matches YAML "key: value" lines. Group 1 = key, Group 2 = quoted value, Group 3 = unquoted value. + // Accepts single or double quotes; the lazy quantifier trims trailing whitespace on unquoted values. + // Examples: "name: foo" → (name, _, foo), "name: 'foo bar'" → (name, foo bar, _), + // "description: \"A skill\"" → (description, A skill, _) + private static readonly Regex s_yamlKeyValueRegex = new(@"^\s*(\w+)\s*:\s*(?:[""'](.+?)[""']|(.+?))\s*$", RegexOptions.Multiline | RegexOptions.Compiled, TimeSpan.FromSeconds(5)); + + // Validates skill names: lowercase letters, numbers, and hyphens only; must not start or end with a hyphen. + // Examples: "my-skill" ✓, "skill123" ✓, "-bad" ✗, "bad-" ✗, "Bad" ✗ + private static readonly Regex s_validNameRegex = new(@"^[a-z0-9]([a-z0-9\-]*[a-z0-9])?$", RegexOptions.Compiled); + + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The logger instance. + internal FileAgentSkillLoader(ILogger logger) + { + this._logger = logger; + } + + /// + /// Discovers skill directories and loads valid skills from them. + /// + /// Paths to search for skills. Each path can point to an individual skill folder or a parent folder. + /// A dictionary of loaded skills keyed by skill name. + internal Dictionary DiscoverAndLoadSkills(IEnumerable skillPaths) + { + var skills = new Dictionary(StringComparer.OrdinalIgnoreCase); + + var discoveredPaths = DiscoverSkillDirectories(skillPaths); + + LogSkillsDiscovered(this._logger, discoveredPaths.Count); + + foreach (string skillPath in discoveredPaths) + { + FileAgentSkill? skill = this.ParseSkillFile(skillPath); + if (skill is null) + { + continue; + } + + if (skills.TryGetValue(skill.Frontmatter.Name, out FileAgentSkill? existing)) + { + LogDuplicateSkillName(this._logger, skill.Frontmatter.Name, skillPath, existing.SourcePath); + + // Skip duplicate skill names, keeping the first one found. + continue; + } + + skills[skill.Frontmatter.Name] = skill; + + LogSkillLoaded(this._logger, skill.Frontmatter.Name); + } + + LogSkillsLoadedTotal(this._logger, skills.Count); + + return skills; + } + + /// + /// Reads a resource file from disk with path traversal and symlink guards. + /// + /// The skill that owns the resource. + /// Relative path of the resource within the skill directory. + /// Cancellation token. + /// The UTF-8 text content of the resource file. + /// + /// The resource is not registered, resolves outside the skill directory, or does not exist. + /// + internal async Task ReadSkillResourceAsync(FileAgentSkill skill, string resourceName, CancellationToken cancellationToken = default) + { + resourceName = NormalizeResourcePath(resourceName); + + if (!skill.ResourceNames.Any(r => r.Equals(resourceName, StringComparison.OrdinalIgnoreCase))) + { + throw new InvalidOperationException($"Resource '{resourceName}' not found in skill '{skill.Frontmatter.Name}'."); + } + + string fullPath = Path.GetFullPath(Path.Combine(skill.SourcePath, resourceName)); + string normalizedSourcePath = Path.GetFullPath(skill.SourcePath) + Path.DirectorySeparatorChar; + + if (!IsPathWithinDirectory(fullPath, normalizedSourcePath)) + { + throw new InvalidOperationException($"Resource file '{resourceName}' references a path outside the skill directory."); + } + + if (!File.Exists(fullPath)) + { + throw new InvalidOperationException($"Resource file '{resourceName}' not found in skill '{skill.Frontmatter.Name}'."); + } + + if (HasSymlinkInPath(fullPath, normalizedSourcePath)) + { + throw new InvalidOperationException($"Resource file '{resourceName}' is a symlink that resolves outside the skill directory."); + } + + LogResourceReading(this._logger, resourceName, skill.Frontmatter.Name); + +#if NET + return await File.ReadAllTextAsync(fullPath, Encoding.UTF8, cancellationToken).ConfigureAwait(false); +#else + return await Task.FromResult(File.ReadAllText(fullPath, Encoding.UTF8)).ConfigureAwait(false); +#endif + } + + private static List DiscoverSkillDirectories(IEnumerable skillPaths) + { + var discoveredPaths = new List(); + + foreach (string rootDirectory in skillPaths) + { + if (string.IsNullOrWhiteSpace(rootDirectory) || !Directory.Exists(rootDirectory)) + { + continue; + } + + SearchDirectoriesForSkills(rootDirectory, discoveredPaths, currentDepth: 0); + } + + return discoveredPaths; + } + + private static void SearchDirectoriesForSkills(string directory, List results, int currentDepth) + { + string skillFilePath = Path.Combine(directory, SkillFileName); + if (File.Exists(skillFilePath)) + { + results.Add(Path.GetFullPath(directory)); + } + + if (currentDepth >= MaxSearchDepth) + { + return; + } + + foreach (string subdirectory in Directory.EnumerateDirectories(directory)) + { + SearchDirectoriesForSkills(subdirectory, results, currentDepth + 1); + } + } + + private FileAgentSkill? ParseSkillFile(string skillDirectoryPath) + { + string skillFilePath = Path.Combine(skillDirectoryPath, SkillFileName); + + string content = File.ReadAllText(skillFilePath, Encoding.UTF8); + + if (!this.TryParseSkillDocument(content, skillFilePath, out SkillFrontmatter frontmatter, out string body)) + { + return null; + } + + List resourceNames = ExtractResourcePaths(body); + + if (!this.ValidateResources(skillDirectoryPath, resourceNames, frontmatter.Name)) + { + return null; + } + + return new FileAgentSkill( + frontmatter: frontmatter, + body: body, + sourcePath: skillDirectoryPath, + resourceNames: resourceNames); + } + + private bool TryParseSkillDocument(string content, string skillFilePath, out SkillFrontmatter frontmatter, out string body) + { + frontmatter = null!; + body = null!; + + Match match = s_frontmatterRegex.Match(content); + if (!match.Success) + { + LogInvalidFrontmatter(this._logger, skillFilePath); + return false; + } + + string? name = null; + string? description = null; + + string yamlContent = match.Groups[1].Value.Trim(); + + foreach (Match kvMatch in s_yamlKeyValueRegex.Matches(yamlContent)) + { + string key = kvMatch.Groups[1].Value; + string value = kvMatch.Groups[2].Success ? kvMatch.Groups[2].Value : kvMatch.Groups[3].Value; + + if (string.Equals(key, "name", StringComparison.OrdinalIgnoreCase)) + { + name = value; + } + else if (string.Equals(key, "description", StringComparison.OrdinalIgnoreCase)) + { + description = value; + } + } + + if (string.IsNullOrWhiteSpace(name)) + { + LogMissingFrontmatterField(this._logger, skillFilePath, "name"); + return false; + } + + if (name.Length > MaxNameLength || !s_validNameRegex.IsMatch(name)) + { + LogInvalidFieldValue(this._logger, skillFilePath, "name", $"Must be {MaxNameLength} characters or fewer, using only lowercase letters, numbers, and hyphens, and must not start or end with a hyphen."); + return false; + } + + if (string.IsNullOrWhiteSpace(description)) + { + LogMissingFrontmatterField(this._logger, skillFilePath, "description"); + return false; + } + + if (description.Length > MaxDescriptionLength) + { + LogInvalidFieldValue(this._logger, skillFilePath, "description", $"Must be {MaxDescriptionLength} characters or fewer."); + return false; + } + + frontmatter = new SkillFrontmatter(name, description); + body = content.Substring(match.Index + match.Length).TrimStart(); + + return true; + } + + private bool ValidateResources(string skillDirectoryPath, List resourceNames, string skillName) + { + string normalizedSkillPath = Path.GetFullPath(skillDirectoryPath) + Path.DirectorySeparatorChar; + + foreach (string resourceName in resourceNames) + { + string fullPath = Path.GetFullPath(Path.Combine(skillDirectoryPath, resourceName)); + + if (!IsPathWithinDirectory(fullPath, normalizedSkillPath)) + { + LogResourcePathTraversal(this._logger, skillName, resourceName); + return false; + } + + if (!File.Exists(fullPath)) + { + LogMissingResource(this._logger, skillName, resourceName); + return false; + } + + if (HasSymlinkInPath(fullPath, normalizedSkillPath)) + { + LogResourceSymlinkEscape(this._logger, skillName, resourceName); + return false; + } + } + + return true; + } + + /// + /// Checks that is under , + /// guarding against path traversal attacks. + /// + private static bool IsPathWithinDirectory(string fullPath, string normalizedDirectoryPath) + { + return fullPath.StartsWith(normalizedDirectoryPath, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Checks whether any segment in (relative to + /// ) is a symlink (reparse point). + /// Uses which is available on all target frameworks. + /// + private static bool HasSymlinkInPath(string fullPath, string normalizedDirectoryPath) + { + string relativePath = fullPath.Substring(normalizedDirectoryPath.Length); + string[] segments = relativePath.Split( + new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }, + StringSplitOptions.RemoveEmptyEntries); + + string currentPath = normalizedDirectoryPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + + foreach (string segment in segments) + { + currentPath = Path.Combine(currentPath, segment); + + if ((File.GetAttributes(currentPath) & FileAttributes.ReparsePoint) != 0) + { + return true; + } + } + + return false; + } + + private static List ExtractResourcePaths(string content) + { + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + var paths = new List(); + foreach (Match m in s_resourceLinkRegex.Matches(content)) + { + string path = NormalizeResourcePath(m.Groups[1].Value); + if (seen.Add(path)) + { + paths.Add(path); + } + } + + return paths; + } + + /// + /// Normalizes a relative resource path by trimming a leading ./ prefix and replacing + /// backslashes with forward slashes so that ./refs/doc.md and refs/doc.md are + /// treated as the same resource. + /// + private static string NormalizeResourcePath(string path) + { + if (path.IndexOf('\\') >= 0) + { + path = path.Replace('\\', '/'); + } + + if (path.StartsWith("./", StringComparison.Ordinal)) + { + path = path.Substring(2); + } + + return path; + } + + [LoggerMessage(LogLevel.Information, "Discovered {Count} potential skills")] + private static partial void LogSkillsDiscovered(ILogger logger, int count); + + [LoggerMessage(LogLevel.Information, "Loaded skill: {SkillName}")] + private static partial void LogSkillLoaded(ILogger logger, string skillName); + + [LoggerMessage(LogLevel.Information, "Successfully loaded {Count} skills")] + private static partial void LogSkillsLoadedTotal(ILogger logger, int count); + + [LoggerMessage(LogLevel.Error, "SKILL.md at '{SkillFilePath}' does not contain valid YAML frontmatter delimited by '---'")] + private static partial void LogInvalidFrontmatter(ILogger logger, string skillFilePath); + + [LoggerMessage(LogLevel.Error, "SKILL.md at '{SkillFilePath}' is missing a '{FieldName}' field in frontmatter")] + private static partial void LogMissingFrontmatterField(ILogger logger, string skillFilePath, string fieldName); + + [LoggerMessage(LogLevel.Error, "SKILL.md at '{SkillFilePath}' has an invalid '{FieldName}' value: {Reason}")] + private static partial void LogInvalidFieldValue(ILogger logger, string skillFilePath, string fieldName, string reason); + + [LoggerMessage(LogLevel.Warning, "Excluding skill '{SkillName}': referenced resource '{ResourceName}' does not exist")] + private static partial void LogMissingResource(ILogger logger, string skillName, string resourceName); + + [LoggerMessage(LogLevel.Warning, "Excluding skill '{SkillName}': resource '{ResourceName}' references a path outside the skill directory")] + private static partial void LogResourcePathTraversal(ILogger logger, string skillName, string resourceName); + + [LoggerMessage(LogLevel.Warning, "Duplicate skill name '{SkillName}': skill from '{NewPath}' skipped in favor of existing skill from '{ExistingPath}'")] + private static partial void LogDuplicateSkillName(ILogger logger, string skillName, string newPath, string existingPath); + + [LoggerMessage(LogLevel.Warning, "Excluding skill '{SkillName}': resource '{ResourceName}' is a symlink that resolves outside the skill directory")] + private static partial void LogResourceSymlinkEscape(ILogger logger, string skillName, string resourceName); + + [LoggerMessage(LogLevel.Information, "Reading resource '{FileName}' from skill '{SkillName}'")] + private static partial void LogResourceReading(ILogger logger, string fileName, string skillName); +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProvider.cs b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProvider.cs new file mode 100644 index 0000000000..847bf36a52 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProvider.cs @@ -0,0 +1,213 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Security; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// An that discovers and exposes Agent Skills from filesystem directories. +/// +/// +/// +/// This provider implements the progressive disclosure pattern from the +/// Agent Skills specification: +/// +/// +/// Advertise — skill names and descriptions are injected into the system prompt (~100 tokens per skill). +/// Load — the full SKILL.md body is returned via the load_skill tool. +/// Read resources — supplementary files are read from disk on demand via the read_skill_resource tool. +/// +/// +/// Skills are discovered by searching the configured directories for SKILL.md files. +/// Referenced resources are validated at initialization; invalid skills are excluded and logged. +/// +/// +/// Security: this provider only reads static content. Skill metadata is XML-escaped +/// before prompt embedding, and resource reads are guarded against path traversal and symlink escape. +/// Only use skills from trusted sources. +/// +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed partial class FileAgentSkillsProvider : AIContextProvider +{ + private const string DefaultSkillsInstructionPrompt = + """ + You have access to skills containing domain-specific knowledge and capabilities. + Each skill provides specialized instructions, reference documents, and assets for specific tasks. + + + {0} + + + When a task aligns with a skill's domain: + 1. Use `load_skill` to retrieve the skill's instructions + 2. Follow the provided guidance + 3. Use `read_skill_resource` to read any references or other files mentioned by the skill + + Only load what is needed, when it is needed. + """; + + private readonly Dictionary _skills; + private readonly ILogger _logger; + private readonly FileAgentSkillLoader _loader; + private readonly AITool[] _tools; + private readonly string? _skillsInstructionPrompt; + + /// + /// Initializes a new instance of the class that searches a single directory for skills. + /// + /// Path to an individual skill folder (containing a SKILL.md file) or a parent folder with skill subdirectories. + /// Optional configuration for prompt customization. + /// Optional logger factory. + public FileAgentSkillsProvider(string skillPath, FileAgentSkillsProviderOptions? options = null, ILoggerFactory? loggerFactory = null) + : this([skillPath], options, loggerFactory) + { + } + + /// + /// Initializes a new instance of the class that searches multiple directories for skills. + /// + /// Paths to search. Each can be an individual skill folder or a parent folder with skill subdirectories. + /// Optional configuration for prompt customization. + /// Optional logger factory. + public FileAgentSkillsProvider(IEnumerable skillPaths, FileAgentSkillsProviderOptions? options = null, ILoggerFactory? loggerFactory = null) + { + _ = Throw.IfNull(skillPaths); + + this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger(); + + this._loader = new FileAgentSkillLoader(this._logger); + this._skills = this._loader.DiscoverAndLoadSkills(skillPaths); + + this._skillsInstructionPrompt = BuildSkillsInstructionPrompt(options, this._skills); + + this._tools = + [ + AIFunctionFactory.Create( + this.LoadSkill, + name: "load_skill", + description: "Loads the full instructions for a specific skill."), + AIFunctionFactory.Create( + this.ReadSkillResourceAsync, + name: "read_skill_resource", + description: "Reads a file associated with a skill, such as references or assets."), + ]; + } + + /// + protected override ValueTask ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default) + { + if (this._skills.Count == 0) + { + return base.ProvideAIContextAsync(context, cancellationToken); + } + + return new ValueTask(new AIContext + { + Instructions = this._skillsInstructionPrompt, + Tools = this._tools + }); + } + + private string LoadSkill(string skillName) + { + if (string.IsNullOrWhiteSpace(skillName)) + { + return "Error: Skill name cannot be empty."; + } + + if (!this._skills.TryGetValue(skillName, out FileAgentSkill? skill)) + { + return $"Error: Skill '{skillName}' not found."; + } + + LogSkillLoading(this._logger, skillName); + + return skill.Body; + } + + private async Task ReadSkillResourceAsync(string skillName, string resourceName, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(skillName)) + { + return "Error: Skill name cannot be empty."; + } + + if (string.IsNullOrWhiteSpace(resourceName)) + { + return "Error: Resource name cannot be empty."; + } + + if (!this._skills.TryGetValue(skillName, out FileAgentSkill? skill)) + { + return $"Error: Skill '{skillName}' not found."; + } + + try + { + return await this._loader.ReadSkillResourceAsync(skill, resourceName, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + LogResourceReadError(this._logger, skillName, resourceName, ex); + return $"Error: Failed to read resource '{resourceName}' from skill '{skillName}'."; + } + } + + private static string? BuildSkillsInstructionPrompt(FileAgentSkillsProviderOptions? options, Dictionary skills) + { + string promptTemplate = DefaultSkillsInstructionPrompt; + + if (options?.SkillsInstructionPrompt is { } optionsInstructions) + { + try + { + promptTemplate = string.Format(optionsInstructions, string.Empty); + } + catch (FormatException ex) + { + throw new ArgumentException( + "The provided SkillsInstructionPrompt is not a valid format string. It must contain a '{0}' placeholder and escape any literal '{' or '}' by doubling them ('{{' or '}}').", + nameof(options), + ex); + } + } + + if (skills.Count == 0) + { + return null; + } + + var sb = new StringBuilder(); + + // Order by name for deterministic prompt output across process restarts + // (Dictionary enumeration order is not guaranteed and varies with hash randomization). + foreach (var skill in skills.Values.OrderBy(s => s.Frontmatter.Name, StringComparer.Ordinal)) + { + sb.AppendLine(" "); + sb.AppendLine($" {SecurityElement.Escape(skill.Frontmatter.Name)}"); + sb.AppendLine($" {SecurityElement.Escape(skill.Frontmatter.Description)}"); + sb.AppendLine(" "); + } + + return string.Format(promptTemplate, sb.ToString().TrimEnd()); + } + + [LoggerMessage(LogLevel.Information, "Loading skill: {SkillName}")] + private static partial void LogSkillLoading(ILogger logger, string skillName); + + [LoggerMessage(LogLevel.Error, "Failed to read resource '{ResourceName}' from skill '{SkillName}'")] + private static partial void LogResourceReadError(ILogger logger, string skillName, string resourceName, Exception exception); +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProviderOptions.cs b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProviderOptions.cs new file mode 100644 index 0000000000..a47841c260 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProviderOptions.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI; + +/// +/// Configuration options for . +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class FileAgentSkillsProviderOptions +{ + /// + /// Gets or sets a custom system prompt template for advertising skills. + /// Use {0} as the placeholder for the generated skills list. + /// When , a default template is used. + /// + public string? SkillsInstructionPrompt { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/SkillFrontmatter.cs b/dotnet/src/Microsoft.Agents.AI/Skills/SkillFrontmatter.cs new file mode 100644 index 0000000000..123a6c43f4 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/SkillFrontmatter.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// Parsed YAML frontmatter from a SKILL.md file, containing the skill's name and description. +/// +internal sealed class SkillFrontmatter +{ + /// + /// Initializes a new instance of the class. + /// + /// Skill name. + /// Skill description. + public SkillFrontmatter(string name, string description) + { + this.Name = Throw.IfNullOrWhitespace(name); + this.Description = Throw.IfNullOrWhitespace(description); + } + + /// + /// Gets the skill name. Lowercase letters, numbers, and hyphens only. + /// + public string Name { get; } + + /// + /// Gets the skill description. Used for discovery in the system prompt. + /// + public string Description { get; } +} diff --git a/dotnet/src/Shared/IntegrationTests/FoundryMemoryConfiguration.cs b/dotnet/src/Shared/IntegrationTests/FoundryMemoryConfiguration.cs new file mode 100644 index 0000000000..957f1bfa4c --- /dev/null +++ b/dotnet/src/Shared/IntegrationTests/FoundryMemoryConfiguration.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Shared.IntegrationTests; + +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. +#pragma warning disable CA1812 // Internal class that is apparently never instantiated. + +internal sealed class FoundryMemoryConfiguration +{ + public string Endpoint { get; set; } + public string MemoryStoreName { get; set; } + public string? DeploymentName { get; set; } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.IntegrationTests/FoundryMemoryProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.IntegrationTests/FoundryMemoryProviderTests.cs new file mode 100644 index 0000000000..d89001d3b9 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.IntegrationTests/FoundryMemoryProviderTests.cs @@ -0,0 +1,132 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading.Tasks; +using Azure.AI.Projects; +using Azure.Identity; +using Microsoft.Extensions.Configuration; +using Shared.IntegrationTests; + +namespace Microsoft.Agents.AI.FoundryMemory.IntegrationTests; + +/// +/// Integration tests for against a configured Azure AI Foundry Memory service. +/// +/// +/// These integration tests are skipped by default and require a live Azure AI Foundry Memory service. +/// The tests need to be updated to use the new AIAgent-based API pattern. +/// Set to null to enable them after configuring the service. +/// +public sealed class FoundryMemoryProviderTests : IDisposable +{ + private const string SkipReason = "Requires an Azure AI Foundry Memory service configured"; // Set to null to enable. + + private readonly AIProjectClient? _client; + private readonly string? _memoryStoreName; + private readonly string? _deploymentName; + private bool _disposed; + + public FoundryMemoryProviderTests() + { + IConfigurationRoot configuration = new ConfigurationBuilder() + .AddJsonFile(path: "testsettings.json", optional: true, reloadOnChange: true) + .AddJsonFile(path: "testsettings.development.json", optional: true, reloadOnChange: true) + .AddEnvironmentVariables() + .AddUserSecrets(optional: true) + .Build(); + + var foundrySettings = configuration.GetSection("FoundryMemory").Get(); + + if (foundrySettings is not null && + !string.IsNullOrWhiteSpace(foundrySettings.Endpoint) && + !string.IsNullOrWhiteSpace(foundrySettings.MemoryStoreName)) + { + this._client = new AIProjectClient(new Uri(foundrySettings.Endpoint), new AzureCliCredential()); + this._memoryStoreName = foundrySettings.MemoryStoreName; + this._deploymentName = foundrySettings.DeploymentName ?? "gpt-4.1-mini"; + } + } + + [Fact(Skip = SkipReason)] + public async Task CanAddAndRetrieveUserMemoriesAsync() + { + // Arrange + FoundryMemoryProvider memoryProvider = new( + this._client!, + this._memoryStoreName!, + stateInitializer: _ => new(new FoundryMemoryProviderScope("it-user-1"))); + + AIAgent agent = await this._client!.CreateAIAgentAsync(this._deploymentName!, + options: new ChatClientAgentOptions { AIContextProviders = [memoryProvider] }); + + AgentSession session = await agent.CreateSessionAsync(); + + await memoryProvider.EnsureStoredMemoriesDeletedAsync(session); + + // Act + AgentResponse resultBefore = await agent.RunAsync("What is my name?", session); + Assert.DoesNotContain("Caoimhe", resultBefore.Text); + + await agent.RunAsync("Hello, my name is Caoimhe.", session); + await memoryProvider.WhenUpdatesCompletedAsync(); + await Task.Delay(2000); + + AgentResponse resultAfter = await agent.RunAsync("What is my name?", session); + + // Cleanup + await memoryProvider.EnsureStoredMemoriesDeletedAsync(session); + + // Assert + Assert.Contains("Caoimhe", resultAfter.Text); + } + + [Fact(Skip = SkipReason)] + public async Task DoesNotLeakMemoriesAcrossScopesAsync() + { + // Arrange + FoundryMemoryProvider memoryProvider1 = new( + this._client!, + this._memoryStoreName!, + stateInitializer: _ => new(new FoundryMemoryProviderScope("it-scope-a"))); + + FoundryMemoryProvider memoryProvider2 = new( + this._client!, + this._memoryStoreName!, + stateInitializer: _ => new(new FoundryMemoryProviderScope("it-scope-b"))); + + AIAgent agent1 = await this._client!.CreateAIAgentAsync(this._deploymentName!, + options: new ChatClientAgentOptions { AIContextProviders = [memoryProvider1] }); + AIAgent agent2 = await this._client!.CreateAIAgentAsync(this._deploymentName!, + options: new ChatClientAgentOptions { AIContextProviders = [memoryProvider2] }); + + AgentSession session1 = await agent1.CreateSessionAsync(); + AgentSession session2 = await agent2.CreateSessionAsync(); + + await memoryProvider1.EnsureStoredMemoriesDeletedAsync(session1); + await memoryProvider2.EnsureStoredMemoriesDeletedAsync(session2); + + // Act - add memory only to scope A + await agent1.RunAsync("Hello, I'm an AI tutor and my name is Caoimhe.", session1); + await memoryProvider1.WhenUpdatesCompletedAsync(); + await Task.Delay(2000); + + AgentResponse result1 = await agent1.RunAsync("What is your name?", session1); + AgentResponse result2 = await agent2.RunAsync("What is your name?", session2); + + // Assert + Assert.Contains("Caoimhe", result1.Text); + Assert.DoesNotContain("Caoimhe", result2.Text); + + // Cleanup + await memoryProvider1.EnsureStoredMemoriesDeletedAsync(session1); + await memoryProvider2.EnsureStoredMemoriesDeletedAsync(session2); + } + + public void Dispose() + { + if (!this._disposed) + { + this._disposed = true; + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.IntegrationTests/Microsoft.Agents.AI.FoundryMemory.IntegrationTests.csproj b/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.IntegrationTests/Microsoft.Agents.AI.FoundryMemory.IntegrationTests.csproj new file mode 100644 index 0000000000..a28fea3490 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.IntegrationTests/Microsoft.Agents.AI.FoundryMemory.IntegrationTests.csproj @@ -0,0 +1,21 @@ + + + + True + + + + + + + + + + + + + + + + + diff --git a/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.UnitTests/FoundryMemoryProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.UnitTests/FoundryMemoryProviderTests.cs new file mode 100644 index 0000000000..226596a374 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.UnitTests/FoundryMemoryProviderTests.cs @@ -0,0 +1,130 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.Agents.AI.FoundryMemory.UnitTests; + +/// +/// Tests for constructor validation. +/// +/// +/// Since directly uses , +/// integration tests are used to verify the memory operations. These unit tests focus on: +/// - Constructor parameter validation +/// - State initializer validation +/// +public sealed class FoundryMemoryProviderTests +{ + [Fact] + public void Constructor_Throws_WhenClientIsNull() + { + // Act & Assert + ArgumentNullException ex = Assert.Throws(() => new FoundryMemoryProvider( + null!, + "store", + stateInitializer: _ => new(new FoundryMemoryProviderScope("test")))); + Assert.Equal("client", ex.ParamName); + } + + [Fact] + public void Constructor_Throws_WhenStateInitializerIsNull() + { + // Arrange + using TestableAIProjectClient testClient = new(); + + // Act & Assert + ArgumentNullException ex = Assert.Throws(() => new FoundryMemoryProvider( + testClient.Client, + "store", + stateInitializer: null!)); + Assert.Equal("stateInitializer", ex.ParamName); + } + + [Fact] + public void Constructor_Throws_WhenMemoryStoreNameIsEmpty() + { + // Arrange + using TestableAIProjectClient testClient = new(); + + // Act & Assert + ArgumentException ex = Assert.Throws(() => new FoundryMemoryProvider( + testClient.Client, + "", + stateInitializer: _ => new(new FoundryMemoryProviderScope("test")))); + Assert.Equal("memoryStoreName", ex.ParamName); + } + + [Fact] + public void Constructor_Throws_WhenMemoryStoreNameIsNull() + { + // Arrange + using TestableAIProjectClient testClient = new(); + + // Act & Assert + ArgumentNullException ex = Assert.Throws(() => new FoundryMemoryProvider( + testClient.Client, + null!, + stateInitializer: _ => new(new FoundryMemoryProviderScope("test")))); + Assert.Equal("memoryStoreName", ex.ParamName); + } + + [Fact] + public void Scope_Throws_WhenScopeIsNull() + { + // Act & Assert + Assert.Throws(() => new FoundryMemoryProviderScope(null!)); + } + + [Fact] + public void Scope_Throws_WhenScopeIsEmpty() + { + // Act & Assert + Assert.Throws(() => new FoundryMemoryProviderScope("")); + } + + [Fact] + public void StateInitializer_Throws_WhenScopeIsNull() + { + // Arrange + using TestableAIProjectClient testClient = new(); + FoundryMemoryProvider sut = new( + testClient.Client, + "store", + stateInitializer: _ => new(null!)); + + // Act & Assert - state initializer validation is deferred to first use + Assert.Throws(() => + { + // Force state initialization by creating a session-like scenario + // The validation happens inside the ValidateStateInitializer wrapper + try + { + // The stateInitializer wraps with validation, so calling it will throw + var field = typeof(FoundryMemoryProvider).GetField("_sessionState", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + var sessionState = field!.GetValue(sut); + var method = sessionState!.GetType().GetMethod("GetOrInitializeState"); + method!.Invoke(sessionState, [null]); + } + catch (System.Reflection.TargetInvocationException tie) when (tie.InnerException is not null) + { + throw tie.InnerException; + } + }); + } + + [Fact] + public void Constructor_Succeeds_WithValidParameters() + { + // Arrange + using TestableAIProjectClient testClient = new(); + + // Act + FoundryMemoryProvider sut = new( + testClient.Client, + "my-store", + stateInitializer: _ => new(new FoundryMemoryProviderScope("user-456"))); + + // Assert + Assert.NotNull(sut); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.UnitTests/Microsoft.Agents.AI.FoundryMemory.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.UnitTests/Microsoft.Agents.AI.FoundryMemory.UnitTests.csproj new file mode 100644 index 0000000000..1fe8dc57bd --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.UnitTests/Microsoft.Agents.AI.FoundryMemory.UnitTests.csproj @@ -0,0 +1,16 @@ + + + + false + + + + + + + + + + + + diff --git a/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.UnitTests/TestableAIProjectClient.cs b/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.UnitTests/TestableAIProjectClient.cs new file mode 100644 index 0000000000..25c041f754 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.UnitTests/TestableAIProjectClient.cs @@ -0,0 +1,196 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.ClientModel.Primitives; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using Azure.AI.Projects; +using Azure.Core; + +namespace Microsoft.Agents.AI.FoundryMemory.UnitTests; + +/// +/// Creates a testable AIProjectClient with a mock HTTP handler. +/// +internal sealed class TestableAIProjectClient : IDisposable +{ + private readonly HttpClient _httpClient; + + public TestableAIProjectClient( + string? searchMemoriesResponse = null, + string? updateMemoriesResponse = null, + HttpStatusCode? searchStatusCode = null, + HttpStatusCode? updateStatusCode = null, + HttpStatusCode? deleteStatusCode = null, + HttpStatusCode? createStoreStatusCode = null, + HttpStatusCode? getStoreStatusCode = null) + { + this.Handler = new MockHttpMessageHandler( + searchMemoriesResponse, + updateMemoriesResponse, + searchStatusCode, + updateStatusCode, + deleteStatusCode, + createStoreStatusCode, + getStoreStatusCode); + + this._httpClient = new HttpClient(this.Handler); + + AIProjectClientOptions options = new() + { + Transport = new HttpClientPipelineTransport(this._httpClient) + }; + + // Using a valid format endpoint + this.Client = new AIProjectClient( + new Uri("https://test.services.ai.azure.com/api/projects/test-project"), + new MockTokenCredential(), + options); + } + + public AIProjectClient Client { get; } + + public MockHttpMessageHandler Handler { get; } + + public void Dispose() + { + this._httpClient.Dispose(); + this.Handler.Dispose(); + } +} + +/// +/// Mock HTTP message handler for testing. +/// +internal sealed class MockHttpMessageHandler : HttpMessageHandler +{ + private readonly string? _searchMemoriesResponse; + private readonly string? _updateMemoriesResponse; + private readonly HttpStatusCode _searchStatusCode; + private readonly HttpStatusCode _updateStatusCode; + private readonly HttpStatusCode _deleteStatusCode; + private readonly HttpStatusCode _createStoreStatusCode; + private readonly HttpStatusCode _getStoreStatusCode; + + public MockHttpMessageHandler( + string? searchMemoriesResponse = null, + string? updateMemoriesResponse = null, + HttpStatusCode? searchStatusCode = null, + HttpStatusCode? updateStatusCode = null, + HttpStatusCode? deleteStatusCode = null, + HttpStatusCode? createStoreStatusCode = null, + HttpStatusCode? getStoreStatusCode = null) + { + this._searchMemoriesResponse = searchMemoriesResponse ?? """{"memories":[]}"""; + this._updateMemoriesResponse = updateMemoriesResponse ?? """{"update_id":"test-update-id","status":"queued"}"""; + this._searchStatusCode = searchStatusCode ?? HttpStatusCode.OK; + this._updateStatusCode = updateStatusCode ?? HttpStatusCode.OK; + this._deleteStatusCode = deleteStatusCode ?? HttpStatusCode.NoContent; + this._createStoreStatusCode = createStoreStatusCode ?? HttpStatusCode.Created; + this._getStoreStatusCode = getStoreStatusCode ?? HttpStatusCode.NotFound; + } + + public string? LastRequestUri { get; private set; } + public string? LastRequestBody { get; private set; } + public HttpMethod? LastRequestMethod { get; private set; } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + this.LastRequestUri = request.RequestUri?.ToString(); + this.LastRequestMethod = request.Method; + + if (request.Content != null) + { +#if NET472 + this.LastRequestBody = await request.Content.ReadAsStringAsync().ConfigureAwait(false); +#else + this.LastRequestBody = await request.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); +#endif + } + + string path = request.RequestUri?.AbsolutePath ?? ""; + + // Route based on path and method + if (path.Contains("/memory-stores/") && path.Contains("/search") && request.Method == HttpMethod.Post) + { + return CreateResponse(this._searchStatusCode, this._searchMemoriesResponse); + } + + if (path.Contains("/memory-stores/") && path.Contains("/memories") && request.Method == HttpMethod.Post) + { + return CreateResponse(this._updateStatusCode, this._updateMemoriesResponse); + } + + if (path.Contains("/memory-stores/") && path.Contains("/scopes") && request.Method == HttpMethod.Delete) + { + return CreateResponse(this._deleteStatusCode, ""); + } + + if (path.Contains("/memory-stores") && request.Method == HttpMethod.Post) + { + return CreateResponse(this._createStoreStatusCode, """{"name":"test-store","status":"active"}"""); + } + + if (path.Contains("/memory-stores/") && request.Method == HttpMethod.Get) + { + return CreateResponse(this._getStoreStatusCode, """{"name":"test-store","status":"active"}"""); + } + + // Default response + return CreateResponse(HttpStatusCode.NotFound, "{}"); + } + + private static HttpResponseMessage CreateResponse(HttpStatusCode statusCode, string? content) + { + return new HttpResponseMessage(statusCode) + { + Content = new StringContent(content ?? "{}", Encoding.UTF8, "application/json") + }; + } +} + +/// +/// Mock token credential for testing. +/// +internal sealed class MockTokenCredential : TokenCredential +{ + public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken) + { + return new AccessToken("mock-token", DateTimeOffset.UtcNow.AddHours(1)); + } + + public override ValueTask GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken) + { + return new ValueTask(new AccessToken("mock-token", DateTimeOffset.UtcNow.AddHours(1))); + } +} + +/// +/// Source-generated JSON serializer context for unit test types. +/// +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +[JsonSerializable(typeof(TestState))] +[JsonSerializable(typeof(TestScope))] +internal sealed partial class TestJsonContext : JsonSerializerContext +{ +} + +/// +/// Test state class for deserialization tests. +/// +internal sealed class TestState +{ + public TestScope? Scope { get; set; } +} + +/// +/// Test scope class for deserialization tests. +/// +internal sealed class TestScope +{ + public string? Scope { get; set; } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs new file mode 100644 index 0000000000..c34eb6d7f2 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs @@ -0,0 +1,561 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Microsoft.Agents.AI.UnitTests.AgentSkills; + +/// +/// Unit tests for the class. +/// +public sealed class FileAgentSkillLoaderTests : IDisposable +{ + private static readonly string[] s_traversalResource = new[] { "../secret.txt" }; + + private readonly string _testRoot; + private readonly FileAgentSkillLoader _loader; + + public FileAgentSkillLoaderTests() + { + this._testRoot = Path.Combine(Path.GetTempPath(), "agent-skills-tests-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(this._testRoot); + this._loader = new FileAgentSkillLoader(NullLogger.Instance); + } + + public void Dispose() + { + if (Directory.Exists(this._testRoot)) + { + Directory.Delete(this._testRoot, recursive: true); + } + } + + [Fact] + public void DiscoverAndLoadSkills_ValidSkill_ReturnsSkill() + { + // Arrange + _ = this.CreateSkillDirectory("my-skill", "A test skill", "Use this skill to do things."); + + // Act + var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + + // Assert + Assert.Single(skills); + Assert.True(skills.ContainsKey("my-skill")); + Assert.Equal("A test skill", skills["my-skill"].Frontmatter.Description); + Assert.Equal("Use this skill to do things.", skills["my-skill"].Body); + } + + [Fact] + public void DiscoverAndLoadSkills_QuotedFrontmatterValues_ParsesCorrectly() + { + // Arrange + string skillDir = Path.Combine(this._testRoot, "quoted-skill"); + Directory.CreateDirectory(skillDir); + File.WriteAllText( + Path.Combine(skillDir, "SKILL.md"), + "---\nname: 'quoted-skill'\ndescription: \"A quoted description\"\n---\nBody text."); + + // Act + var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + + // Assert + Assert.Single(skills); + Assert.Equal("quoted-skill", skills["quoted-skill"].Frontmatter.Name); + Assert.Equal("A quoted description", skills["quoted-skill"].Frontmatter.Description); + } + + [Fact] + public void DiscoverAndLoadSkills_MissingFrontmatter_ExcludesSkill() + { + // Arrange + string skillDir = Path.Combine(this._testRoot, "bad-skill"); + Directory.CreateDirectory(skillDir); + File.WriteAllText(Path.Combine(skillDir, "SKILL.md"), "No frontmatter here."); + + // Act + var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + + // Assert + Assert.Empty(skills); + } + + [Fact] + public void DiscoverAndLoadSkills_MissingNameField_ExcludesSkill() + { + // Arrange + string skillDir = Path.Combine(this._testRoot, "no-name"); + Directory.CreateDirectory(skillDir); + File.WriteAllText( + Path.Combine(skillDir, "SKILL.md"), + "---\ndescription: A skill without a name\n---\nBody."); + + // Act + var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + + // Assert + Assert.Empty(skills); + } + + [Fact] + public void DiscoverAndLoadSkills_MissingDescriptionField_ExcludesSkill() + { + // Arrange + string skillDir = Path.Combine(this._testRoot, "no-desc"); + Directory.CreateDirectory(skillDir); + File.WriteAllText( + Path.Combine(skillDir, "SKILL.md"), + "---\nname: no-desc\n---\nBody."); + + // Act + var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + + // Assert + Assert.Empty(skills); + } + + [Theory] + [InlineData("BadName")] + [InlineData("-leading-hyphen")] + [InlineData("trailing-hyphen-")] + [InlineData("has spaces")] + public void DiscoverAndLoadSkills_InvalidName_ExcludesSkill(string invalidName) + { + // Arrange + string skillDir = Path.Combine(this._testRoot, "invalid-name-test"); + if (Directory.Exists(skillDir)) + { + Directory.Delete(skillDir, recursive: true); + } + + Directory.CreateDirectory(skillDir); + File.WriteAllText( + Path.Combine(skillDir, "SKILL.md"), + $"---\nname: {invalidName}\ndescription: A skill\n---\nBody."); + + // Act + var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + + // Assert + Assert.Empty(skills); + } + + [Fact] + public void DiscoverAndLoadSkills_DuplicateNames_KeepsFirstOnly() + { + // Arrange + string dir1 = Path.Combine(this._testRoot, "skill-a"); + string dir2 = Path.Combine(this._testRoot, "skill-b"); + Directory.CreateDirectory(dir1); + Directory.CreateDirectory(dir2); + File.WriteAllText( + Path.Combine(dir1, "SKILL.md"), + "---\nname: dupe\ndescription: First\n---\nFirst body."); + File.WriteAllText( + Path.Combine(dir2, "SKILL.md"), + "---\nname: dupe\ndescription: Second\n---\nSecond body."); + + // Act + var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + + // Assert – filesystem enumeration order is not guaranteed, so we only + // verify that exactly one of the two duplicates was kept. + Assert.Single(skills); + string desc = skills["dupe"].Frontmatter.Description; + Assert.True(desc == "First" || desc == "Second", $"Unexpected description: {desc}"); + } + + [Fact] + public void DiscoverAndLoadSkills_WithValidResourceLinks_ExtractsResourceNames() + { + // Arrange + string skillDir = Path.Combine(this._testRoot, "resource-skill"); + string refsDir = Path.Combine(skillDir, "refs"); + Directory.CreateDirectory(refsDir); + File.WriteAllText(Path.Combine(refsDir, "FAQ.md"), "FAQ content"); + File.WriteAllText( + Path.Combine(skillDir, "SKILL.md"), + "---\nname: resource-skill\ndescription: Has resources\n---\nSee [FAQ](refs/FAQ.md) for details."); + + // Act + var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + + // Assert + Assert.Single(skills); + var skill = skills["resource-skill"]; + Assert.Single(skill.ResourceNames); + Assert.Equal("refs/FAQ.md", skill.ResourceNames[0]); + } + + [Fact] + public void DiscoverAndLoadSkills_PathTraversal_ExcludesSkill() + { + // Arrange — resource links outside the skill directory + string skillDir = Path.Combine(this._testRoot, "traversal-skill"); + Directory.CreateDirectory(skillDir); + + // Create a file outside the skill dir that the traversal would resolve to + File.WriteAllText(Path.Combine(this._testRoot, "secret.txt"), "secret"); + + File.WriteAllText( + Path.Combine(skillDir, "SKILL.md"), + "---\nname: traversal-skill\ndescription: Traversal attempt\n---\nSee [doc](../secret.txt)."); + + // Act + var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + + // Assert + Assert.Empty(skills); + } + + [Fact] + public void DiscoverAndLoadSkills_EmptyPaths_ReturnsEmptyDictionary() + { + // Act + var skills = this._loader.DiscoverAndLoadSkills(Enumerable.Empty()); + + // Assert + Assert.Empty(skills); + } + + [Fact] + public void DiscoverAndLoadSkills_NonExistentPath_ReturnsEmptyDictionary() + { + // Act + var skills = this._loader.DiscoverAndLoadSkills(new[] { Path.Combine(this._testRoot, "does-not-exist") }); + + // Assert + Assert.Empty(skills); + } + + [Fact] + public void DiscoverAndLoadSkills_NestedSkillDirectory_DiscoveredWithinDepthLimit() + { + // Arrange — nested 1 level deep (MaxSearchDepth = 2, so depth 0 = testRoot, depth 1 = level1) + string nestedDir = Path.Combine(this._testRoot, "level1", "nested-skill"); + Directory.CreateDirectory(nestedDir); + File.WriteAllText( + Path.Combine(nestedDir, "SKILL.md"), + "---\nname: nested-skill\ndescription: Nested\n---\nNested body."); + + // Act + var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + + // Assert + Assert.Single(skills); + Assert.True(skills.ContainsKey("nested-skill")); + } + + [Fact] + public async Task ReadSkillResourceAsync_ValidResource_ReturnsContentAsync() + { + // Arrange + _ = this.CreateSkillDirectoryWithResource("read-skill", "A skill", "See [doc](refs/doc.md).", "refs/doc.md", "Document content here."); + var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skill = skills["read-skill"]; + + // Act + string content = await this._loader.ReadSkillResourceAsync(skill, "refs/doc.md"); + + // Assert + Assert.Equal("Document content here.", content); + } + + [Fact] + public async Task ReadSkillResourceAsync_UnregisteredResource_ThrowsInvalidOperationExceptionAsync() + { + // Arrange + string skillDir = this.CreateSkillDirectory("simple-skill", "A skill", "No resources."); + var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skill = skills["simple-skill"]; + + // Act & Assert + await Assert.ThrowsAsync( + () => this._loader.ReadSkillResourceAsync(skill, "unknown.md")); + } + + [Fact] + public async Task ReadSkillResourceAsync_PathTraversal_ThrowsInvalidOperationExceptionAsync() + { + // Arrange — skill with a legitimate resource, then try to read a traversal path at read time + _ = this.CreateSkillDirectoryWithResource("traverse-read", "A skill", "See [doc](refs/doc.md).", "refs/doc.md", "legit"); + var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skill = skills["traverse-read"]; + + // Manually construct a skill with the traversal resource in its list to bypass discovery validation + var tampered = new FileAgentSkill( + skill.Frontmatter, + skill.Body, + skill.SourcePath, + s_traversalResource); + + // Act & Assert + await Assert.ThrowsAsync( + () => this._loader.ReadSkillResourceAsync(tampered, "../secret.txt")); + } + + [Fact] + public void DiscoverAndLoadSkills_NameExceedsMaxLength_ExcludesSkill() + { + // Arrange — name longer than 64 characters + string longName = new('a', 65); + string skillDir = Path.Combine(this._testRoot, "long-name"); + Directory.CreateDirectory(skillDir); + File.WriteAllText( + Path.Combine(skillDir, "SKILL.md"), + $"---\nname: {longName}\ndescription: A skill\n---\nBody."); + + // Act + var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + + // Assert + Assert.Empty(skills); + } + + [Fact] + public void DiscoverAndLoadSkills_DescriptionExceedsMaxLength_ExcludesSkill() + { + // Arrange — description longer than 1024 characters + string longDesc = new('x', 1025); + string skillDir = Path.Combine(this._testRoot, "long-desc"); + Directory.CreateDirectory(skillDir); + File.WriteAllText( + Path.Combine(skillDir, "SKILL.md"), + $"---\nname: long-desc\ndescription: {longDesc}\n---\nBody."); + + // Act + var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + + // Assert + Assert.Empty(skills); + } + + [Fact] + public void DiscoverAndLoadSkills_DuplicateResourceLinks_DeduplicatesResources() + { + // Arrange — body references the same resource twice + string skillDir = Path.Combine(this._testRoot, "dedup-skill"); + string refsDir = Path.Combine(skillDir, "refs"); + Directory.CreateDirectory(refsDir); + File.WriteAllText(Path.Combine(refsDir, "doc.md"), "content"); + File.WriteAllText( + Path.Combine(skillDir, "SKILL.md"), + "---\nname: dedup-skill\ndescription: Dedup test\n---\nSee [doc](refs/doc.md) and [again](refs/doc.md)."); + + // Act + var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + + // Assert + Assert.Single(skills); + Assert.Single(skills["dedup-skill"].ResourceNames); + } + + [Fact] + public void DiscoverAndLoadSkills_DotSlashPrefix_NormalizesToBarePath() + { + // Arrange — body references a resource with ./ prefix + string skillDir = Path.Combine(this._testRoot, "dotslash-skill"); + string refsDir = Path.Combine(skillDir, "refs"); + Directory.CreateDirectory(refsDir); + File.WriteAllText(Path.Combine(refsDir, "doc.md"), "content"); + File.WriteAllText( + Path.Combine(skillDir, "SKILL.md"), + "---\nname: dotslash-skill\ndescription: Dot-slash test\n---\nSee [doc](./refs/doc.md)."); + + // Act + var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + + // Assert + Assert.Single(skills); + var skill = skills["dotslash-skill"]; + Assert.Single(skill.ResourceNames); + Assert.Equal("refs/doc.md", skill.ResourceNames[0]); + } + + [Fact] + public void DiscoverAndLoadSkills_DotSlashAndBarePath_DeduplicatesResources() + { + // Arrange — body references the same resource with and without ./ prefix + string skillDir = Path.Combine(this._testRoot, "mixed-prefix-skill"); + string refsDir = Path.Combine(skillDir, "refs"); + Directory.CreateDirectory(refsDir); + File.WriteAllText(Path.Combine(refsDir, "doc.md"), "content"); + File.WriteAllText( + Path.Combine(skillDir, "SKILL.md"), + "---\nname: mixed-prefix-skill\ndescription: Mixed prefix test\n---\nSee [a](./refs/doc.md) and [b](refs/doc.md)."); + + // Act + var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + + // Assert + Assert.Single(skills); + var skill = skills["mixed-prefix-skill"]; + Assert.Single(skill.ResourceNames); + Assert.Equal("refs/doc.md", skill.ResourceNames[0]); + } + + [Fact] + public async Task ReadSkillResourceAsync_DotSlashPrefix_MatchesNormalizedResourceAsync() + { + // Arrange — skill loaded with bare path, caller uses ./ prefix + _ = this.CreateSkillDirectoryWithResource("dotslash-read", "A skill", "See [doc](refs/doc.md).", "refs/doc.md", "Document content."); + var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skill = skills["dotslash-read"]; + + // Act — caller passes ./refs/doc.md which should match refs/doc.md + string content = await this._loader.ReadSkillResourceAsync(skill, "./refs/doc.md"); + + // Assert + Assert.Equal("Document content.", content); + } + + [Fact] + public async Task ReadSkillResourceAsync_BackslashSeparator_MatchesNormalizedResourceAsync() + { + // Arrange — skill loaded with forward-slash path, caller uses backslashes + _ = this.CreateSkillDirectoryWithResource("backslash-read", "A skill", "See [doc](refs/doc.md).", "refs/doc.md", "Backslash content."); + var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skill = skills["backslash-read"]; + + // Act — caller passes refs\doc.md which should match refs/doc.md + string content = await this._loader.ReadSkillResourceAsync(skill, "refs\\doc.md"); + + // Assert + Assert.Equal("Backslash content.", content); + } + + [Fact] + public async Task ReadSkillResourceAsync_DotSlashWithBackslash_MatchesNormalizedResourceAsync() + { + // Arrange — skill loaded with forward-slash path, caller uses .\ prefix with backslashes + _ = this.CreateSkillDirectoryWithResource("mixed-sep-read", "A skill", "See [doc](refs/doc.md).", "refs/doc.md", "Mixed separator content."); + var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skill = skills["mixed-sep-read"]; + + // Act — caller passes .\refs\doc.md which should match refs/doc.md + string content = await this._loader.ReadSkillResourceAsync(skill, ".\\refs\\doc.md"); + + // Assert + Assert.Equal("Mixed separator content.", content); + } + +#if NET + private static readonly string[] s_symlinkResource = ["refs/data.md"]; + + [Fact] + public void DiscoverAndLoadSkills_SymlinkInPath_ExcludesSkill() + { + // Arrange — a "refs" subdirectory is a symlink pointing outside the skill directory + string skillDir = Path.Combine(this._testRoot, "symlink-escape-skill"); + Directory.CreateDirectory(skillDir); + + string outsideDir = Path.Combine(this._testRoot, "outside"); + Directory.CreateDirectory(outsideDir); + File.WriteAllText(Path.Combine(outsideDir, "secret.md"), "secret content"); + + string refsLink = Path.Combine(skillDir, "refs"); + try + { + Directory.CreateSymbolicLink(refsLink, outsideDir); + } + catch (IOException) + { + // Symlink creation requires elevation on some platforms; skip gracefully. + return; + } + + File.WriteAllText( + Path.Combine(skillDir, "SKILL.md"), + "---\nname: symlink-escape-skill\ndescription: Symlinked directory escape\n---\nSee [doc](refs/secret.md)."); + + // Act + var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + + // Assert — skill should be excluded because refs/ is a symlink (reparse point) + Assert.False(skills.ContainsKey("symlink-escape-skill")); + } + + [Fact] + public async Task ReadSkillResourceAsync_SymlinkInPath_ThrowsInvalidOperationExceptionAsync() + { + // Arrange — build a skill with a symlinked subdirectory + string skillDir = Path.Combine(this._testRoot, "symlink-read-skill"); + string refsDir = Path.Combine(skillDir, "refs"); + Directory.CreateDirectory(skillDir); + + string outsideDir = Path.Combine(this._testRoot, "outside-read"); + Directory.CreateDirectory(outsideDir); + File.WriteAllText(Path.Combine(outsideDir, "data.md"), "external data"); + + try + { + Directory.CreateSymbolicLink(refsDir, outsideDir); + } + catch (IOException) + { + // Symlink creation requires elevation on some platforms; skip gracefully. + return; + } + + // Manually construct a skill that bypasses discovery validation + var frontmatter = new SkillFrontmatter("symlink-read-skill", "A skill"); + var skill = new FileAgentSkill( + frontmatter: frontmatter, + body: "See [doc](refs/data.md).", + sourcePath: skillDir, + resourceNames: s_symlinkResource); + + // Act & Assert + await Assert.ThrowsAsync( + () => this._loader.ReadSkillResourceAsync(skill, "refs/data.md")); + } +#endif + + [Fact] + public void DiscoverAndLoadSkills_FileWithUtf8Bom_ParsesSuccessfully() + { + // Arrange — prepend a UTF-8 BOM (\uFEFF) before the frontmatter + _ = this.CreateSkillDirectoryWithRawContent( + "bom-skill", + "\uFEFF---\nname: bom-skill\ndescription: Skill with BOM\n---\nBody content."); + + // Act + var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + + // Assert + Assert.Single(skills); + Assert.True(skills.ContainsKey("bom-skill")); + Assert.Equal("Skill with BOM", skills["bom-skill"].Frontmatter.Description); + Assert.Equal("Body content.", skills["bom-skill"].Body); + } + + private string CreateSkillDirectory(string name, string description, string body) + { + string skillDir = Path.Combine(this._testRoot, name); + Directory.CreateDirectory(skillDir); + File.WriteAllText( + Path.Combine(skillDir, "SKILL.md"), + $"---\nname: {name}\ndescription: {description}\n---\n{body}"); + return skillDir; + } + + private string CreateSkillDirectoryWithRawContent(string directoryName, string rawContent) + { + string skillDir = Path.Combine(this._testRoot, directoryName); + Directory.CreateDirectory(skillDir); + File.WriteAllText(Path.Combine(skillDir, "SKILL.md"), rawContent); + return skillDir; + } + + private string CreateSkillDirectoryWithResource(string name, string description, string body, string resourceRelativePath, string resourceContent) + { + string skillDir = this.CreateSkillDirectory(name, description, body); + string resourcePath = Path.Combine(skillDir, resourceRelativePath); + Directory.CreateDirectory(Path.GetDirectoryName(resourcePath)!); + File.WriteAllText(resourcePath, resourceContent); + return skillDir; + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillsProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillsProviderTests.cs new file mode 100644 index 0000000000..6bfaf1b546 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillsProviderTests.cs @@ -0,0 +1,228 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.UnitTests.AgentSkills; + +/// +/// Unit tests for the class. +/// +public sealed class FileAgentSkillsProviderTests : IDisposable +{ + private readonly string _testRoot; + private readonly TestAIAgent _agent = new(); + + public FileAgentSkillsProviderTests() + { + this._testRoot = Path.Combine(Path.GetTempPath(), "skills-provider-tests-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(this._testRoot); + } + + public void Dispose() + { + if (Directory.Exists(this._testRoot)) + { + Directory.Delete(this._testRoot, recursive: true); + } + } + + [Fact] + public async Task InvokingCoreAsync_NoSkills_ReturnsInputContextUnchangedAsync() + { + // Arrange + var provider = new FileAgentSkillsProvider(this._testRoot); + var inputContext = new AIContext { Instructions = "Original instructions" }; + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert + Assert.Equal("Original instructions", result.Instructions); + Assert.Null(result.Tools); + } + + [Fact] + public async Task InvokingCoreAsync_WithSkills_AppendsInstructionsAndToolsAsync() + { + // Arrange + this.CreateSkill("provider-skill", "Provider skill test", "Skill instructions body."); + var provider = new FileAgentSkillsProvider(this._testRoot); + var inputContext = new AIContext { Instructions = "Base instructions" }; + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert + Assert.NotNull(result.Instructions); + Assert.Contains("Base instructions", result.Instructions); + Assert.Contains("provider-skill", result.Instructions); + Assert.Contains("Provider skill test", result.Instructions); + + // Should have load_skill and read_skill_resource tools + Assert.NotNull(result.Tools); + var toolNames = result.Tools!.Select(t => t.Name).ToList(); + Assert.Contains("load_skill", toolNames); + Assert.Contains("read_skill_resource", toolNames); + } + + [Fact] + public async Task InvokingCoreAsync_NullInputInstructions_SetsInstructionsAsync() + { + // Arrange + this.CreateSkill("null-instr-skill", "Null instruction test", "Body."); + var provider = new FileAgentSkillsProvider(this._testRoot); + var inputContext = new AIContext(); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert + Assert.NotNull(result.Instructions); + Assert.Contains("null-instr-skill", result.Instructions); + } + + [Fact] + public async Task InvokingCoreAsync_CustomPromptTemplate_UsesCustomTemplateAsync() + { + // Arrange + this.CreateSkill("custom-prompt-skill", "Custom prompt", "Body."); + var options = new FileAgentSkillsProviderOptions + { + SkillsInstructionPrompt = "Custom template: {0}" + }; + var provider = new FileAgentSkillsProvider(this._testRoot, options); + var inputContext = new AIContext(); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert + Assert.NotNull(result.Instructions); + Assert.StartsWith("Custom template:", result.Instructions); + } + + [Fact] + public void Constructor_InvalidPromptTemplate_ThrowsArgumentException() + { + // Arrange — template with unescaped braces and no valid {0} placeholder + var options = new FileAgentSkillsProviderOptions + { + SkillsInstructionPrompt = "Bad template with {unescaped} braces" + }; + + // Act & Assert + var ex = Assert.Throws(() => new FileAgentSkillsProvider(this._testRoot, options)); + Assert.Contains("SkillsInstructionPrompt", ex.Message); + Assert.Equal("options", ex.ParamName); + } + + [Fact] + public async Task InvokingCoreAsync_SkillNamesAreXmlEscapedAsync() + { + // Arrange — description with XML-sensitive characters + string skillDir = Path.Combine(this._testRoot, "xml-skill"); + Directory.CreateDirectory(skillDir); + File.WriteAllText( + Path.Combine(skillDir, "SKILL.md"), + "---\nname: xml-skill\ndescription: Uses & \"quotes\"\n---\nBody."); + var provider = new FileAgentSkillsProvider(this._testRoot); + var inputContext = new AIContext(); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert + Assert.NotNull(result.Instructions); + Assert.Contains("<tags>", result.Instructions); + Assert.Contains("&", result.Instructions); + } + + [Fact] + public async Task Constructor_WithMultiplePaths_LoadsFromAllAsync() + { + // Arrange + string dir1 = Path.Combine(this._testRoot, "dir1"); + string dir2 = Path.Combine(this._testRoot, "dir2"); + CreateSkillIn(dir1, "skill-a", "Skill A", "Body A."); + CreateSkillIn(dir2, "skill-b", "Skill B", "Body B."); + + // Act + var provider = new FileAgentSkillsProvider(new[] { dir1, dir2 }); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + + // Assert + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + Assert.NotNull(result.Instructions); + Assert.Contains("skill-a", result.Instructions); + Assert.Contains("skill-b", result.Instructions); + } + + [Fact] + public async Task InvokingCoreAsync_PreservesExistingInputToolsAsync() + { + // Arrange + this.CreateSkill("tools-skill", "Tools test", "Body."); + var provider = new FileAgentSkillsProvider(this._testRoot); + + var existingTool = AIFunctionFactory.Create(() => "test", name: "existing_tool", description: "An existing tool."); + var inputContext = new AIContext { Tools = new[] { existingTool } }; + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert — existing tool should be preserved alongside the new skill tools + Assert.NotNull(result.Tools); + var toolNames = result.Tools!.Select(t => t.Name).ToList(); + Assert.Contains("existing_tool", toolNames); + Assert.Contains("load_skill", toolNames); + Assert.Contains("read_skill_resource", toolNames); + } + + [Fact] + public async Task InvokingCoreAsync_SkillsListIsSortedByNameAsync() + { + // Arrange — create skills in reverse alphabetical order + this.CreateSkill("zulu-skill", "Zulu skill", "Body Z."); + this.CreateSkill("alpha-skill", "Alpha skill", "Body A."); + this.CreateSkill("mike-skill", "Mike skill", "Body M."); + var provider = new FileAgentSkillsProvider(this._testRoot); + var inputContext = new AIContext(); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert — skills should appear in alphabetical order in the prompt + Assert.NotNull(result.Instructions); + int alphaIndex = result.Instructions!.IndexOf("alpha-skill", StringComparison.Ordinal); + int mikeIndex = result.Instructions.IndexOf("mike-skill", StringComparison.Ordinal); + int zuluIndex = result.Instructions.IndexOf("zulu-skill", StringComparison.Ordinal); + Assert.True(alphaIndex < mikeIndex, "alpha-skill should appear before mike-skill"); + Assert.True(mikeIndex < zuluIndex, "mike-skill should appear before zulu-skill"); + } + + private void CreateSkill(string name, string description, string body) + { + CreateSkillIn(this._testRoot, name, description, body); + } + + private static void CreateSkillIn(string root, string name, string description, string body) + { + string skillDir = Path.Combine(root, name); + Directory.CreateDirectory(skillDir); + File.WriteAllText( + Path.Combine(skillDir, "SKILL.md"), + $"---\nname: {name}\ndescription: {description}\n---\n{body}"); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Microsoft.Agents.AI.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Microsoft.Agents.AI.UnitTests.csproj index cf16b00b34..7fa417b184 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Microsoft.Agents.AI.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Microsoft.Agents.AI.UnitTests.csproj @@ -1,5 +1,9 @@ + + $(NoWarn);MAAI001 + + false diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/MediaInputTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/MediaInputTest.cs index 5400628ba3..da30db6f98 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/MediaInputTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/MediaInputTest.cs @@ -2,7 +2,6 @@ using System; using System.IO; -using System.Net.Http; using System.Threading.Tasks; using Azure.AI.Projects; using Azure.Identity; @@ -21,12 +20,13 @@ public sealed class MediaInputTest(ITestOutputHelper output) : IntegrationTest(o { private const string WorkflowWithConversationFileName = "MediaInputConversation.yaml"; private const string WorkflowWithAutoSendFileName = "MediaInputAutoSend.yaml"; - private const string ImageReference = "https://sample-files.com/downloads/images/jpg/web_optimized_1200x800_97kb.jpg"; - private const string PdfReference = "https://sample-files.com/downloads/documents/pdf/basic-text.pdf"; + private const string ImageReferenceUrl = "https://sample-files.com/downloads/images/jpg/web_optimized_1200x800_97kb.jpg"; + private const string PdfLocalFile = "TestFiles/basic-text.pdf"; + private const string ImageLocalFile = "TestFiles/test-image.jpg"; [Theory] - [InlineData(ImageReference, "image/jpeg", true)] - [InlineData(ImageReference, "image/jpeg", false)] + [InlineData(ImageReferenceUrl, "image/jpeg", true)] + [InlineData(ImageReferenceUrl, "image/jpeg", false)] public async Task ValidateFileUrlAsync(string fileSource, string mediaType, bool useConversation) { // Arrange @@ -39,12 +39,12 @@ public sealed class MediaInputTest(ITestOutputHelper output) : IntegrationTest(o // Temporarily disabled [Theory] [Trait("Category", "IntegrationDisabled")] - [InlineData(ImageReference, "image/jpeg", true)] - [InlineData(ImageReference, "image/jpeg", false)] + [InlineData(ImageLocalFile, "image/jpeg", true)] + [InlineData(ImageLocalFile, "image/jpeg", false)] public async Task ValidateImageFileDataAsync(string fileSource, string mediaType, bool useConversation) { // Arrange - byte[] fileData = await DownloadFileAsync(fileSource); + byte[] fileData = ReadLocalFile(fileSource); string encodedData = Convert.ToBase64String(fileData); string fileUrl = $"data:{mediaType};base64,{encodedData}"; this.Output.WriteLine($"Content: {fileUrl.Substring(0, Math.Min(112, fileUrl.Length))}..."); @@ -54,12 +54,12 @@ public sealed class MediaInputTest(ITestOutputHelper output) : IntegrationTest(o } [Theory] - [InlineData(PdfReference, "application/pdf", true)] - [InlineData(PdfReference, "application/pdf", false)] + [InlineData(PdfLocalFile, "application/pdf", true)] + [InlineData(PdfLocalFile, "application/pdf", false)] public async Task ValidateFileDataAsync(string fileSource, string mediaType, bool useConversation) { // Arrange - byte[] fileData = await DownloadFileAsync(fileSource); + byte[] fileData = ReadLocalFile(fileSource); string encodedData = Convert.ToBase64String(fileData); string fileUrl = $"data:{mediaType};base64,{encodedData}"; this.Output.WriteLine($"Content: {fileUrl.Substring(0, Math.Min(112, fileUrl.Length))}..."); @@ -71,12 +71,12 @@ public sealed class MediaInputTest(ITestOutputHelper output) : IntegrationTest(o // Temporarily disabled [Theory] [Trait("Category", "IntegrationDisabled")] - [InlineData(PdfReference, "doc.pdf", true)] - [InlineData(PdfReference, "doc.pdf", false)] + [InlineData(PdfLocalFile, "doc.pdf", true)] + [InlineData(PdfLocalFile, "doc.pdf", false)] public async Task ValidateFileUploadAsync(string fileSource, string documentName, bool useConversation) { // Arrange - byte[] fileData = await DownloadFileAsync(fileSource); + byte[] fileData = ReadLocalFile(fileSource); AIProjectClient client = new(this.TestEndpoint, new AzureCliCredential()); using MemoryStream contentStream = new(fileData); OpenAIFileClient fileClient = client.GetProjectOpenAIClient().GetOpenAIFileClient(); @@ -94,11 +94,10 @@ public sealed class MediaInputTest(ITestOutputHelper output) : IntegrationTest(o } } - private static async Task DownloadFileAsync(string uri) + private static byte[] ReadLocalFile(string relativePath) { - using HttpClient client = new(); - client.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/110.0"); - return await client.GetByteArrayAsync(new Uri(uri)); + string fullPath = Path.Combine(AppContext.BaseDirectory, relativePath); + return File.ReadAllBytes(fullPath); } private async Task ValidateFileAsync(AIContent fileContent, bool useConversation) diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.csproj index 985086a56e..309a590b83 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.csproj @@ -36,6 +36,9 @@ Always + + Always + diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/TestFiles/basic-text.pdf b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/TestFiles/basic-text.pdf new file mode 100644 index 0000000000..a4fb8a4509 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/TestFiles/basic-text.pdf @@ -0,0 +1,39 @@ +%PDF-1.4 +%âãÏÓ +1 0 obj +<< /Type /Catalog /Pages 2 0 R >> +endobj + +2 0 obj +<< /Type /Pages /Kids [3 0 R] /Count 1 >> +endobj + +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> +endobj + +4 0 obj +<< /Length 44 >> +stream +BT /F1 12 Tf 100 700 Td (Hello World) Tj ET + +endstream +endobj + +5 0 obj +<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> +endobj + +xref +0 6 +0000000000 65535 f +0000000015 00000 n +0000000065 00000 n +0000000123 00000 n +0000000250 00000 n +0000000345 00000 n +trailer +<< /Size 6 /Root 1 0 R >> +startxref +416 +%%EOF diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/TestFiles/test-image.jpg b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/TestFiles/test-image.jpg new file mode 100644 index 0000000000..8bb5fc31c4 Binary files /dev/null and b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/TestFiles/test-image.jpg differ diff --git a/python/uv.lock b/python/uv.lock index 8f65b2b4c6..9189368833 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -1962,7 +1962,7 @@ wheels = [ [[package]] name = "flask" -version = "3.1.2" +version = "3.1.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "blinker", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -1972,9 +1972,9 @@ dependencies = [ { name = "markupsafe", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "werkzeug", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/dc/6d/cfe3c0fcc5e477df242b98bfe186a4c34357b4847e87ecaef04507332dab/flask-3.1.2.tar.gz", hash = "sha256:bf656c15c80190ed628ad08cdfd3aaa35beb087855e2f494910aa3774cc4fd87", size = 720160, upload-time = "2025-08-19T21:03:21.205Z" } +sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/f9/7f9263c5695f4bd0023734af91bedb2ff8209e8de6ead162f35d8dc762fd/flask-3.1.2-py3-none-any.whl", hash = "sha256:ca1d8112ec8a6158cc29ea4858963350011b5c846a414cdb7a954aa9e967d03c", size = 103308, upload-time = "2025-08-19T21:03:19.499Z" }, + { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" }, ] [[package]]