mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Resolve merge from main (new samples)
This commit is contained in:
@@ -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: |
|
||||
.
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -77,6 +77,12 @@
|
||||
<Folder Name="/Samples/02-agents/AGUI/Step04_HumanInLoop/">
|
||||
<Project Path="samples/02-agents/AGUI/Step04_HumanInLoop/Client/Client.csproj" />
|
||||
<Project Path="samples/02-agents/AGUI/Step04_HumanInLoop/Server/Server.csproj" />
|
||||
<Folder Name="/Samples/GettingStarted/AgentSkills/">
|
||||
<File Path="samples/GettingStarted/AgentSkills/README.md" />
|
||||
<Project Path="samples/GettingStarted/AgentSkills/Agent_Step01_BasicSkills/Agent_Step01_BasicSkills.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/GettingStarted/DeclarativeAgents/">
|
||||
<Project Path="samples/GettingStarted/DeclarativeAgents/ChatClient/DeclarativeChatClientAgents.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/02-agents/AGUI/Step05_StateManagement/">
|
||||
<Project Path="samples/02-agents/AGUI/Step05_StateManagement/Client/Client.csproj" />
|
||||
@@ -145,6 +151,12 @@
|
||||
</Folder>
|
||||
<Folder Name="/Samples/02-agents/Observability/">
|
||||
<Project Path="samples/02-agents/AgentOpenTelemetry/AgentOpenTelemetry.csproj" />
|
||||
<Folder Name="/Samples/GettingStarted/AgentWithMemory/">
|
||||
<File Path="samples/GettingStarted/AgentWithMemory/README.md" />
|
||||
<Project Path="samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step01_ChatHistoryMemory/AgentWithMemory_Step01_ChatHistoryMemory.csproj" />
|
||||
<Project Path="samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step02_MemoryUsingMem0/AgentWithMemory_Step02_MemoryUsingMem0.csproj" />
|
||||
<Project Path="samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step03_CustomMemory/AgentWithMemory_Step03_CustomMemory.csproj" />
|
||||
<Project Path="samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/AgentWithMemory_Step04_MemoryUsingFoundry.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/03-workflows/">
|
||||
<File Path="samples/03-workflows/README.md" />
|
||||
@@ -424,6 +436,7 @@
|
||||
<Project Path="src/Microsoft.Agents.AI.Hosting.AzureFunctions/Microsoft.Agents.AI.Hosting.AzureFunctions.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Hosting.OpenAI/Microsoft.Agents.AI.Hosting.OpenAI.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Hosting/Microsoft.Agents.AI.Hosting.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.FoundryMemory/Microsoft.Agents.AI.FoundryMemory.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Mem0/Microsoft.Agents.AI.Mem0.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.OpenAI/Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Purview/Microsoft.Agents.AI.Purview.csproj" />
|
||||
@@ -445,6 +458,7 @@
|
||||
<Project Path="tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Mem0.IntegrationTests/Microsoft.Agents.AI.Mem0.IntegrationTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.FoundryMemory.IntegrationTests/Microsoft.Agents.AI.FoundryMemory.IntegrationTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.csproj" />
|
||||
<Project Path="tests/OpenAIAssistant.IntegrationTests/OpenAIAssistant.IntegrationTests.csproj" />
|
||||
<Project Path="tests/OpenAIChatCompletion.IntegrationTests/OpenAIChatCompletion.IntegrationTests.csproj" />
|
||||
@@ -467,6 +481,7 @@
|
||||
<Project Path="tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Hosting.UnitTests/Microsoft.Agents.AI.Hosting.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.FoundryMemory.UnitTests/Microsoft.Agents.AI.FoundryMemory.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Mem0.UnitTests/Microsoft.Agents.AI.Mem0.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.OpenAI.UnitTests/Microsoft.Agents.AI.OpenAI.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Purview.UnitTests/Microsoft.Agents.AI.Purview.UnitTests.csproj" />
|
||||
|
||||
@@ -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.|
|
||||
|
||||
@@ -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|
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<NoWarn>$(NoWarn);MAAI001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Copy skills directory to output -->
|
||||
<ItemGroup>
|
||||
<None Include="skills\**\*.*">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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");
|
||||
@@ -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/)
|
||||
+40
@@ -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.
|
||||
+5
@@ -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 |
|
||||
+55
@@ -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.
|
||||
@@ -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 |
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.FoundryMemory\Microsoft.Agents.AI.FoundryMemory.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+77
@@ -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));
|
||||
+57
@@ -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` |
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Internal extension methods for <see cref="AIProjectClient"/> to provide MemoryStores helper operations.
|
||||
/// </summary>
|
||||
internal static class AIProjectClientExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a memory store if it doesn't already exist.
|
||||
/// </summary>
|
||||
internal static async Task<bool> 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Microsoft.Agents.AI.FoundryMemory;
|
||||
|
||||
/// <summary>
|
||||
/// Provides JSON serialization utilities for the Foundry Memory provider.
|
||||
/// </summary>
|
||||
internal static class FoundryMemoryJsonUtilities
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the default JSON serializer options for Foundry Memory operations.
|
||||
/// </summary>
|
||||
public static JsonSerializerOptions DefaultOptions { get; } = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false,
|
||||
TypeInfoResolver = FoundryMemoryJsonContext.Default
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Source-generated JSON serialization context for Foundry Memory types.
|
||||
/// </summary>
|
||||
[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;
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Provides an Azure AI Foundry Memory backed <see cref="AIContextProvider"/> that persists conversation messages as memories
|
||||
/// and retrieves related memories to augment the agent invocation context.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
[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<State> _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<FoundryMemoryProvider>? _logger;
|
||||
|
||||
private string? _lastPendingUpdateId;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FoundryMemoryProvider"/> class.
|
||||
/// </summary>
|
||||
/// <param name="client">The Azure AI Project client configured for your Foundry project.</param>
|
||||
/// <param name="memoryStoreName">The name of the memory store in Azure AI Foundry.</param>
|
||||
/// <param name="stateInitializer">A delegate that initializes the provider state on the first invocation, providing the scope for memory storage and retrieval.</param>
|
||||
/// <param name="options">Provider options.</param>
|
||||
/// <param name="loggerFactory">Optional logger factory.</param>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="client"/> or <paramref name="stateInitializer"/> is <see langword="null"/>.</exception>
|
||||
/// <exception cref="ArgumentException">Thrown when <paramref name="memoryStoreName"/> is null or whitespace.</exception>
|
||||
public FoundryMemoryProvider(
|
||||
AIProjectClient client,
|
||||
string memoryStoreName,
|
||||
Func<AgentSession?, State> stateInitializer,
|
||||
FoundryMemoryProviderOptions? options = null,
|
||||
ILoggerFactory? loggerFactory = null)
|
||||
: base(options?.SearchInputMessageFilter, options?.StorageInputMessageFilter)
|
||||
{
|
||||
Throw.IfNull(client);
|
||||
Throw.IfNullOrWhitespace(memoryStoreName);
|
||||
|
||||
this._sessionState = new ProviderSessionState<State>(
|
||||
ValidateStateInitializer(Throw.IfNull(stateInitializer)),
|
||||
options?.StateKey ?? this.GetType().Name,
|
||||
FoundryMemoryJsonUtilities.DefaultOptions);
|
||||
|
||||
FoundryMemoryProviderOptions effectiveOptions = options ?? new FoundryMemoryProviderOptions();
|
||||
|
||||
this._logger = loggerFactory?.CreateLogger<FoundryMemoryProvider>();
|
||||
this._client = client;
|
||||
|
||||
this._contextPrompt = effectiveOptions.ContextPrompt ?? DefaultContextPrompt;
|
||||
this._memoryStoreName = memoryStoreName;
|
||||
this._maxMemories = effectiveOptions.MaxMemories;
|
||||
this._updateDelay = effectiveOptions.UpdateDelay;
|
||||
this._enableSensitiveTelemetryData = effectiveOptions.EnableSensitiveTelemetryData;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string StateKey => this._sessionState.StateKey;
|
||||
|
||||
private static Func<AgentSession?, State> ValidateStateInitializer(Func<AgentSession?, State> stateInitializer) =>
|
||||
session =>
|
||||
{
|
||||
State state = stateInitializer(session);
|
||||
|
||||
if (state is null)
|
||||
{
|
||||
throw new InvalidOperationException("State initializer must return a non-null state.");
|
||||
}
|
||||
|
||||
return state;
|
||||
};
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async ValueTask<AIContext> ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Throw.IfNull(context);
|
||||
|
||||
State state = this._sessionState.GetOrInitializeState(context.Session);
|
||||
FoundryMemoryProviderScope scope = state.Scope;
|
||||
|
||||
List<ResponseItem> 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<MemoryStoreSearchResponse> result = await this._client.MemoryStores.SearchMemoriesAsync(
|
||||
this._memoryStoreName,
|
||||
searchOptions,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
MemoryStoreSearchResponse response = result.Value;
|
||||
|
||||
List<string> 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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async ValueTask StoreAIContextAsync(InvokedContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
State state = this._sessionState.GetOrInitializeState(context.Session);
|
||||
FoundryMemoryProviderScope scope = state.Scope;
|
||||
|
||||
try
|
||||
{
|
||||
List<ResponseItem> 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<MemoryUpdateResult> 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ensures all stored memories for the configured scope are deleted.
|
||||
/// This method handles cases where the scope doesn't exist (no memories stored yet).
|
||||
/// </summary>
|
||||
/// <param name="session">The session containing the scope state to clear memories for.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ensures the memory store exists, creating it if necessary.
|
||||
/// </summary>
|
||||
/// <param name="chatModel">The deployment name of the chat model for memory processing.</param>
|
||||
/// <param name="embeddingModel">The deployment name of the embedding model for memory search.</param>
|
||||
/// <param name="description">Optional description for the memory store.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits for all pending memory update operations to complete.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
/// <param name="pollingInterval">The interval between status checks. Defaults to 5 seconds.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <exception cref="InvalidOperationException">Thrown if the update operation failed.</exception>
|
||||
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<MemoryUpdateResult> 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 : "<redacted>";
|
||||
|
||||
/// <summary>
|
||||
/// Represents the state of a <see cref="FoundryMemoryProvider"/> stored in the <see cref="AgentSession.StateBag"/>.
|
||||
/// </summary>
|
||||
public sealed class State
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="State"/> class with the specified scope.
|
||||
/// </summary>
|
||||
/// <param name="scope">The scope to use for memory storage and retrieval.</param>
|
||||
[JsonConstructor]
|
||||
public State(FoundryMemoryProviderScope scope)
|
||||
{
|
||||
this.Scope = Throw.IfNull(scope);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the scope used for memory storage and retrieval.
|
||||
/// </summary>
|
||||
public FoundryMemoryProviderScope Scope { get; }
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Options for configuring the <see cref="FoundryMemoryProvider"/>.
|
||||
/// </summary>
|
||||
public sealed class FoundryMemoryProviderOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// When providing memories to the model, this string is prefixed to the retrieved memories to supply context.
|
||||
/// </summary>
|
||||
/// <value>Defaults to "## Memories\nConsider the following memories when answering user questions:".</value>
|
||||
public string? ContextPrompt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum number of memories to retrieve during search.
|
||||
/// </summary>
|
||||
/// <value>Defaults to 5.</value>
|
||||
public int MaxMemories { get; set; } = 5;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the delay in seconds before memory updates are processed.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Setting to 0 triggers updates immediately without waiting for inactivity.
|
||||
/// Higher values allow the service to batch multiple updates together.
|
||||
/// </remarks>
|
||||
/// <value>Defaults to 0 (immediate).</value>
|
||||
public int UpdateDelay { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether sensitive data such as user ids and user messages may appear in logs.
|
||||
/// </summary>
|
||||
/// <value>Defaults to <see langword="false"/>.</value>
|
||||
public bool EnableSensitiveTelemetryData { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the key used to store the provider state in the session's <see cref="AgentSessionStateBag"/>.
|
||||
/// </summary>
|
||||
/// <value>Defaults to the provider's type name.</value>
|
||||
public string? StateKey { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets an optional filter function applied to request messages when building the search text to use when
|
||||
/// searching for relevant memories during <see cref="AIContextProvider.InvokingAsync"/>.
|
||||
/// </summary>
|
||||
/// <value>
|
||||
/// When <see langword="null"/>, the provider defaults to including only
|
||||
/// <see cref="AgentRequestMessageSourceType.External"/> messages.
|
||||
/// </value>
|
||||
public Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? SearchInputMessageFilter { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets an optional filter function applied to request messages when determining which messages to
|
||||
/// extract memories from during <see cref="AIContextProvider.InvokedAsync"/>.
|
||||
/// </summary>
|
||||
/// <value>
|
||||
/// When <see langword="null"/>, the provider defaults to including only
|
||||
/// <see cref="AgentRequestMessageSourceType.External"/> messages.
|
||||
/// </value>
|
||||
public Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? StorageInputMessageFilter { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.FoundryMemory;
|
||||
|
||||
/// <summary>
|
||||
/// Allows scoping of memories for the <see cref="FoundryMemoryProvider"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
public sealed class FoundryMemoryProviderScope
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FoundryMemoryProviderScope"/> class with the specified scope identifier.
|
||||
/// </summary>
|
||||
/// <param name="scope">The scope identifier used to partition memories. Must not be null or whitespace.</param>
|
||||
/// <exception cref="ArgumentException">Thrown when <paramref name="scope"/> is null or whitespace.</exception>
|
||||
public FoundryMemoryProviderScope(string scope)
|
||||
{
|
||||
Throw.IfNullOrWhitespace(scope);
|
||||
this.Scope = scope;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the scope identifier used to partition memories.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
public string Scope { get; }
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<VersionSuffix>preview</VersionSuffix>
|
||||
<NoWarn>$(NoWarn);OPENAI001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<InjectSharedThrow>true</InjectSharedThrow>
|
||||
<InjectSharedDiagnosticIds>true</InjectSharedDiagnosticIds>
|
||||
<InjectExperimentalAttributeOnLegacy>true</InjectExperimentalAttributeOnLegacy>
|
||||
<InjectTrimAttributesOnLegacy>true</InjectTrimAttributesOnLegacy>
|
||||
</PropertyGroup>
|
||||
|
||||
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
|
||||
<PropertyGroup>
|
||||
<!-- Disable packing until we are ready to release this as a nuget -->
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI.Abstractions\Microsoft.Agents.AI.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
<PackageReference Include="OpenAI" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<!-- NuGet Package Settings -->
|
||||
<Title>Microsoft Agent Framework - Azure AI Foundry Memory integration</Title>
|
||||
<Description>Provides Azure AI Foundry Memory integration for Microsoft Agent Framework.</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="Microsoft.Agents.AI.FoundryMemory.UnitTests" />
|
||||
<InternalsVisibleTo Include="DynamicProxyGenAssembly2" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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<State> _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<string, object?>
|
||||
{
|
||||
["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<Func<Dictionary<string, object?>, bool>>? filter = null;
|
||||
if (applicationId != null)
|
||||
{
|
||||
filter = x => (string?)x["ApplicationId"] == applicationId;
|
||||
filter = x => (string?)x[ApplicationIdField] == applicationId;
|
||||
}
|
||||
|
||||
if (agentId != null)
|
||||
{
|
||||
Expression<Func<Dictionary<string, object?>, bool>> agentIdFilter = x => (string?)x["AgentId"] == agentId;
|
||||
Expression<Func<Dictionary<string, object?>, bool>> agentIdFilter = x => (string?)x[AgentIdField] == agentId;
|
||||
filter = filter == null ? agentIdFilter : Expression.Lambda<Func<Dictionary<string, object?>, bool>>(
|
||||
Expression.AndAlso(filter.Body, agentIdFilter.Body),
|
||||
filter.Parameters);
|
||||
@@ -353,7 +365,7 @@ public sealed class ChatHistoryMemoryProvider : MessageAIContextProvider, IDispo
|
||||
|
||||
if (userId != null)
|
||||
{
|
||||
Expression<Func<Dictionary<string, object?>, bool>> userIdFilter = x => (string?)x["UserId"] == userId;
|
||||
Expression<Func<Dictionary<string, object?>, bool>> userIdFilter = x => (string?)x[UserIdField] == userId;
|
||||
filter = filter == null ? userIdFilter : Expression.Lambda<Func<Dictionary<string, object?>, bool>>(
|
||||
Expression.AndAlso(filter.Body, userIdFilter.Body),
|
||||
filter.Parameters);
|
||||
@@ -361,7 +373,7 @@ public sealed class ChatHistoryMemoryProvider : MessageAIContextProvider, IDispo
|
||||
|
||||
if (sessionId != null)
|
||||
{
|
||||
Expression<Func<Dictionary<string, object?>, bool>> sessionIdFilter = x => (string?)x["SessionId"] == sessionId;
|
||||
Expression<Func<Dictionary<string, object?>, bool>> sessionIdFilter = x => (string?)x[SessionIdField] == sessionId;
|
||||
filter = filter == null ? sessionIdFilter : Expression.Lambda<Func<Dictionary<string, object?>, bool>>(
|
||||
Expression.AndAlso(filter.Body, sessionIdFilter.Body),
|
||||
filter.Parameters);
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<IsReleaseCandidate>true</IsReleaseCandidate>
|
||||
<NoWarn>$(NoWarn);MEAI001</NoWarn>
|
||||
<NoWarn>$(NoWarn);MEAI001;MAAI001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<InjectSharedThrow>true</InjectSharedThrow>
|
||||
<InjectSharedDiagnosticIds>true</InjectSharedDiagnosticIds>
|
||||
<InjectDiagnosticClassesOnLegacy>true</InjectDiagnosticClassesOnLegacy>
|
||||
<InjectExperimentalAttributeOnLegacy>true</InjectExperimentalAttributeOnLegacy>
|
||||
<InjectTrimAttributesOnLegacy>true</InjectTrimAttributesOnLegacy>
|
||||
<InjectIsExternalInitOnLegacy>true</InjectIsExternalInitOnLegacy>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a loaded Agent Skill discovered from a filesystem directory.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Each skill is backed by a <c>SKILL.md</c> 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.
|
||||
/// </remarks>
|
||||
internal sealed class FileAgentSkill
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FileAgentSkill"/> class.
|
||||
/// </summary>
|
||||
/// <param name="frontmatter">Parsed YAML frontmatter (name and description).</param>
|
||||
/// <param name="body">The SKILL.md content after the closing <c>---</c> delimiter.</param>
|
||||
/// <param name="sourcePath">Absolute path to the directory containing this skill.</param>
|
||||
/// <param name="resourceNames">Relative paths of resource files referenced in the skill body.</param>
|
||||
public FileAgentSkill(
|
||||
SkillFrontmatter frontmatter,
|
||||
string body,
|
||||
string sourcePath,
|
||||
IReadOnlyList<string>? resourceNames = null)
|
||||
{
|
||||
this.Frontmatter = Throw.IfNull(frontmatter);
|
||||
this.Body = Throw.IfNull(body);
|
||||
this.SourcePath = Throw.IfNullOrWhitespace(sourcePath);
|
||||
this.ResourceNames = resourceNames ?? [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the parsed YAML frontmatter (name and description).
|
||||
/// </summary>
|
||||
public SkillFrontmatter Frontmatter { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the SKILL.md body content (without the YAML frontmatter).
|
||||
/// </summary>
|
||||
public string Body { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the directory path where the skill was discovered.
|
||||
/// </summary>
|
||||
public string SourcePath { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the relative paths of resource files referenced in the skill body (e.g., "references/FAQ.md").
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> ResourceNames { get; }
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Discovers, parses, and validates SKILL.md files from filesystem directories.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Searches directories recursively (up to <see cref="MaxSearchDepth"/> 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.
|
||||
/// </remarks>
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FileAgentSkillLoader"/> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">The logger instance.</param>
|
||||
internal FileAgentSkillLoader(ILogger logger)
|
||||
{
|
||||
this._logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Discovers skill directories and loads valid skills from them.
|
||||
/// </summary>
|
||||
/// <param name="skillPaths">Paths to search for skills. Each path can point to an individual skill folder or a parent folder.</param>
|
||||
/// <returns>A dictionary of loaded skills keyed by skill name.</returns>
|
||||
internal Dictionary<string, FileAgentSkill> DiscoverAndLoadSkills(IEnumerable<string> skillPaths)
|
||||
{
|
||||
var skills = new Dictionary<string, FileAgentSkill>(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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads a resource file from disk with path traversal and symlink guards.
|
||||
/// </summary>
|
||||
/// <param name="skill">The skill that owns the resource.</param>
|
||||
/// <param name="resourceName">Relative path of the resource within the skill directory.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The UTF-8 text content of the resource file.</returns>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// The resource is not registered, resolves outside the skill directory, or does not exist.
|
||||
/// </exception>
|
||||
internal async Task<string> 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<string> DiscoverSkillDirectories(IEnumerable<string> skillPaths)
|
||||
{
|
||||
var discoveredPaths = new List<string>();
|
||||
|
||||
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<string> 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<string> 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<string> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks that <paramref name="fullPath"/> is under <paramref name="normalizedDirectoryPath"/>,
|
||||
/// guarding against path traversal attacks.
|
||||
/// </summary>
|
||||
private static bool IsPathWithinDirectory(string fullPath, string normalizedDirectoryPath)
|
||||
{
|
||||
return fullPath.StartsWith(normalizedDirectoryPath, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether any segment in <paramref name="fullPath"/> (relative to
|
||||
/// <paramref name="normalizedDirectoryPath"/>) is a symlink (reparse point).
|
||||
/// Uses <see cref="FileAttributes.ReparsePoint"/> which is available on all target frameworks.
|
||||
/// </summary>
|
||||
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<string> ExtractResourcePaths(string content)
|
||||
{
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var paths = new List<string>();
|
||||
foreach (Match m in s_resourceLinkRegex.Matches(content))
|
||||
{
|
||||
string path = NormalizeResourcePath(m.Groups[1].Value);
|
||||
if (seen.Add(path))
|
||||
{
|
||||
paths.Add(path);
|
||||
}
|
||||
}
|
||||
|
||||
return paths;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes a relative resource path by trimming a leading <c>./</c> prefix and replacing
|
||||
/// backslashes with forward slashes so that <c>./refs/doc.md</c> and <c>refs/doc.md</c> are
|
||||
/// treated as the same resource.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// An <see cref="AIContextProvider"/> that discovers and exposes Agent Skills from filesystem directories.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This provider implements the progressive disclosure pattern from the
|
||||
/// <see href="https://agentskills.io/">Agent Skills specification</see>:
|
||||
/// </para>
|
||||
/// <list type="number">
|
||||
/// <item><description><strong>Advertise</strong> — skill names and descriptions are injected into the system prompt (~100 tokens per skill).</description></item>
|
||||
/// <item><description><strong>Load</strong> — the full SKILL.md body is returned via the <c>load_skill</c> tool.</description></item>
|
||||
/// <item><description><strong>Read resources</strong> — supplementary files are read from disk on demand via the <c>read_skill_resource</c> tool.</description></item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// Skills are discovered by searching the configured directories for <c>SKILL.md</c> files.
|
||||
/// Referenced resources are validated at initialization; invalid skills are excluded and logged.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Security:</strong> 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[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.
|
||||
|
||||
<available_skills>
|
||||
{0}
|
||||
</available_skills>
|
||||
|
||||
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<string, FileAgentSkill> _skills;
|
||||
private readonly ILogger<FileAgentSkillsProvider> _logger;
|
||||
private readonly FileAgentSkillLoader _loader;
|
||||
private readonly AITool[] _tools;
|
||||
private readonly string? _skillsInstructionPrompt;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FileAgentSkillsProvider"/> class that searches a single directory for skills.
|
||||
/// </summary>
|
||||
/// <param name="skillPath">Path to an individual skill folder (containing a SKILL.md file) or a parent folder with skill subdirectories.</param>
|
||||
/// <param name="options">Optional configuration for prompt customization.</param>
|
||||
/// <param name="loggerFactory">Optional logger factory.</param>
|
||||
public FileAgentSkillsProvider(string skillPath, FileAgentSkillsProviderOptions? options = null, ILoggerFactory? loggerFactory = null)
|
||||
: this([skillPath], options, loggerFactory)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FileAgentSkillsProvider"/> class that searches multiple directories for skills.
|
||||
/// </summary>
|
||||
/// <param name="skillPaths">Paths to search. Each can be an individual skill folder or a parent folder with skill subdirectories.</param>
|
||||
/// <param name="options">Optional configuration for prompt customization.</param>
|
||||
/// <param name="loggerFactory">Optional logger factory.</param>
|
||||
public FileAgentSkillsProvider(IEnumerable<string> skillPaths, FileAgentSkillsProviderOptions? options = null, ILoggerFactory? loggerFactory = null)
|
||||
{
|
||||
_ = Throw.IfNull(skillPaths);
|
||||
|
||||
this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger<FileAgentSkillsProvider>();
|
||||
|
||||
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."),
|
||||
];
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override ValueTask<AIContext> ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (this._skills.Count == 0)
|
||||
{
|
||||
return base.ProvideAIContextAsync(context, cancellationToken);
|
||||
}
|
||||
|
||||
return new ValueTask<AIContext>(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<string> 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<string, FileAgentSkill> 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(" <skill>");
|
||||
sb.AppendLine($" <name>{SecurityElement.Escape(skill.Frontmatter.Name)}</name>");
|
||||
sb.AppendLine($" <description>{SecurityElement.Escape(skill.Frontmatter.Description)}</description>");
|
||||
sb.AppendLine(" </skill>");
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for <see cref="FileAgentSkillsProvider"/>.
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public sealed class FileAgentSkillsProviderOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets a custom system prompt template for advertising skills.
|
||||
/// Use <c>{0}</c> as the placeholder for the generated skills list.
|
||||
/// When <see langword="null"/>, a default template is used.
|
||||
/// </summary>
|
||||
public string? SkillsInstructionPrompt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Parsed YAML frontmatter from a SKILL.md file, containing the skill's name and description.
|
||||
/// </summary>
|
||||
internal sealed class SkillFrontmatter
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SkillFrontmatter"/> class.
|
||||
/// </summary>
|
||||
/// <param name="name">Skill name.</param>
|
||||
/// <param name="description">Skill description.</param>
|
||||
public SkillFrontmatter(string name, string description)
|
||||
{
|
||||
this.Name = Throw.IfNullOrWhitespace(name);
|
||||
this.Description = Throw.IfNullOrWhitespace(description);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the skill name. Lowercase letters, numbers, and hyphens only.
|
||||
/// </summary>
|
||||
public string Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the skill description. Used for discovery in the system prompt.
|
||||
/// </summary>
|
||||
public string Description { get; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
+132
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for <see cref="FoundryMemoryProvider"/> against a configured Azure AI Foundry Memory service.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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 <see cref="SkipReason"/> to null to enable them after configuring the service.
|
||||
/// </remarks>
|
||||
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<FoundryMemoryProviderTests>(optional: true)
|
||||
.Build();
|
||||
|
||||
var foundrySettings = configuration.GetSection("FoundryMemory").Get<FoundryMemoryConfiguration>();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<InjectSharedIntegrationTestCode>True</InjectSharedIntegrationTestCode>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.FoundryMemory\Microsoft.Agents.AI.FoundryMemory.csproj" />
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
|
||||
namespace Microsoft.Agents.AI.FoundryMemory.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="FoundryMemoryProvider"/> constructor validation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Since <see cref="FoundryMemoryProvider"/> directly uses <see cref="Azure.AI.Projects.AIProjectClient"/>,
|
||||
/// integration tests are used to verify the memory operations. These unit tests focus on:
|
||||
/// - Constructor parameter validation
|
||||
/// - State initializer validation
|
||||
/// </remarks>
|
||||
public sealed class FoundryMemoryProviderTests
|
||||
{
|
||||
[Fact]
|
||||
public void Constructor_Throws_WhenClientIsNull()
|
||||
{
|
||||
// Act & Assert
|
||||
ArgumentNullException ex = Assert.Throws<ArgumentNullException>(() => 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<ArgumentNullException>(() => 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<ArgumentException>(() => 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<ArgumentNullException>(() => 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<ArgumentNullException>(() => new FoundryMemoryProviderScope(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Scope_Throws_WhenScopeIsEmpty()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentException>(() => 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<ArgumentNullException>(() =>
|
||||
{
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup Condition="$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0'))">
|
||||
<JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.FoundryMemory\Microsoft.Agents.AI.FoundryMemory.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a testable AIProjectClient with a mock HTTP handler.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mock HTTP message handler for testing.
|
||||
/// </summary>
|
||||
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<HttpResponseMessage> 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")
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mock token credential for testing.
|
||||
/// </summary>
|
||||
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<AccessToken> GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken)
|
||||
{
|
||||
return new ValueTask<AccessToken>(new AccessToken("mock-token", DateTimeOffset.UtcNow.AddHours(1)));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Source-generated JSON serializer context for unit test types.
|
||||
/// </summary>
|
||||
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
|
||||
[JsonSerializable(typeof(TestState))]
|
||||
[JsonSerializable(typeof(TestScope))]
|
||||
internal sealed partial class TestJsonContext : JsonSerializerContext
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test state class for deserialization tests.
|
||||
/// </summary>
|
||||
internal sealed class TestState
|
||||
{
|
||||
public TestScope? Scope { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test scope class for deserialization tests.
|
||||
/// </summary>
|
||||
internal sealed class TestScope
|
||||
{
|
||||
public string? Scope { get; set; }
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="FileAgentSkillLoader"/> class.
|
||||
/// </summary>
|
||||
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<string>());
|
||||
|
||||
// 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<InvalidOperationException>(
|
||||
() => 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<InvalidOperationException>(
|
||||
() => 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<InvalidOperationException>(
|
||||
() => 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;
|
||||
}
|
||||
}
|
||||
+228
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="FileAgentSkillsProvider"/> class.
|
||||
/// </summary>
|
||||
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<ArgumentException>(() => 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 <tags> & \"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}");
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<NoWarn>$(NoWarn);MAAI001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0'))">
|
||||
<JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
|
||||
</PropertyGroup>
|
||||
|
||||
+17
-18
@@ -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<byte[]> 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)
|
||||
|
||||
+3
@@ -36,6 +36,9 @@
|
||||
<None Update="Workflows\*.yaml">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="TestFiles\*">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
+39
@@ -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
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 148 B |
Generated
+3
-3
@@ -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]]
|
||||
|
||||
Reference in New Issue
Block a user