.NET: Add Hosted-AgentSkills sample with Foundry Skills integration (#6013)

* .NET: Add Hosted-AgentSkills sample for Foundry Skills integration

Add a new hosted agent sample that demonstrates how to load behavioral
guidelines from Foundry Skills at startup using AgentSkillsProvider and
the progressive disclosure pattern (advertise -> load on demand).

The sample:
- Downloads SKILL.md files from Foundry via ProjectAgentSkills SDK
- Extracts ZIP archives with zip-slip protection
- Wires skills into AgentSkillsProvider as an AIContextProvider
- Hosts the agent via the Responses protocol

Ships two Contoso Outdoors skills matching the Python sample (PR #5822):
- support-style: tone, formatting, signature guidelines
- escalation-policy: when and how to escalate tickets

Includes convenience provisioning gated behind PROVISION_SAMPLE_SKILLS
env var, clearly documented as NOT a production pattern.

Closes #5776

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* .NET: Add unit tests and integration test for Hosted-AgentSkills

Unit tests (14 tests, all passing):
- ZIP extraction with zip-slip guard (valid archive, traversal attack,
  sibling-prefix attack, directory entries)
- Skill name validation (rejects dots, separators, traversal patterns)
- AgentSkillsProvider with downloaded skills (advertises both skills,
  load_skill returns canary tokens, unknown skill returns error)

Container integration test:
- New 'agent-skills' scenario in the test container that creates
  Contoso Outdoors skills on disk and wires AgentSkillsProvider
- AgentSkillsHostedAgentFixture + 4 integration tests verifying:
  - Routine questions load support-style skill (STYLE-CANARY-3318)
  - Escalation triggers load escalation-policy (ESC-CANARY-7742)
  - Skills are advertised in system prompt
  - load_skill tool is invoked via FunctionCallContent

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* .NET: Add smoke test, bootstrap, and docs for agent-skills integration

- Add scripts/smoke.ps1 for local Docker smoke testing: builds the
  contributor image, runs the container, verifies both skills are loaded
  via canary tokens (STYLE-CANARY-3318, ESC-CANARY-7742)
- Add 'agent-skills' to the bootstrap script scenario list
- Add agent-skills row to the integration test README scenarios table
- Exclude HostedAgentSkillsPatternTests from net472 (uses net8.0+ APIs)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* .NET: Update commented-out package versions to latest across all hosted samples

Update the end-user PackageReference versions (in the commented-out
sections) from 1.0.0 to the current latest NuGet versions:

- Microsoft.Agents.AI: 1.6.1
- Microsoft.Agents.AI.Foundry: 1.6.1-preview.260514.1
- Microsoft.Agents.AI.Foundry.Hosting: 1.6.1-preview.260514.1
- Microsoft.Agents.AI.Hosting: 1.6.1-preview.260514.1
- Microsoft.Agents.AI.OpenAI: 1.6.1
- Microsoft.Agents.AI.Workflows: 1.6.1

Also adds explicit versions to Hosted-Workflow-Handoff which had bare
PackageReference entries without Version attributes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* .NET: Fix broken markdown links in Hosted-AgentSkills README

Remove references to non-existent ../../README.md. Replace with
inline instructions matching other hosted samples that don't have
a parent README.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* .NET: Use OS-appropriate string comparison in zip-slip guard

Use Ordinal on Unix (case-sensitive FS) and OrdinalIgnoreCase on
Windows to prevent case-based path bypass on Linux containers.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Roger Barreto
2026-05-25 10:32:04 +01:00
committed by GitHub
Unverified
parent de6d0267f2
commit dc4bafbc1e
32 changed files with 1148 additions and 32 deletions
+3
View File
@@ -359,6 +359,9 @@
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/">
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/HostedWorkflowHandoff.csproj" />
</Folder>
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/">
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/HostedAgentSkills.csproj" />
</Folder>
<Folder Name="/Samples/04-hosting/DurableAgents/" />
<Folder Name="/Samples/04-hosting/DurableAgents/AzureFunctions/">
<File Path="samples/04-hosting/DurableAgents/AzureFunctions/.editorconfig" />
@@ -0,0 +1,14 @@
AZURE_AI_PROJECT_ENDPOINT=<your-azure-ai-project-endpoint>
ASPNETCORE_URLS=http://+:8088
ASPNETCORE_ENVIRONMENT=Development
AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o
AGENT_NAME=hosted-agent-skills
SKILL_NAMES=support-style,escalation-policy
# Set to true to provision sample skills to Foundry on startup (first-run convenience).
# In production, skills are provisioned externally — leave this unset or false.
PROVISION_SAMPLE_SKILLS=true
AZURE_BEARER_TOKEN=DefaultAzureCredential
# When running outside the Foundry platform the platform-injected isolation keys are absent.
# These two variables provide fallback values for local Docker debugging only.
HOSTED_USER_ISOLATION_KEY=local-dev-user
HOSTED_CHAT_ISOLATION_KEY=local-dev-chat
@@ -0,0 +1,26 @@
# Dockerfile for end-users consuming the Agent Framework via NuGet packages.
#
# This Dockerfile performs a full `dotnet restore` and `dotnet publish` inside the container,
# which only succeeds when the project references its dependencies via PackageReference (see the
# commented-out section in HostedAgentSkills.csproj). Contributors building from the
# agent-framework repository source must use Dockerfile.contributor instead because
# ProjectReference dependencies live outside this folder and cannot be restored from inside
# this build context.
#
# Use the official .NET 10.0 ASP.NET runtime as a parent image
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
WORKDIR /app
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY . .
RUN dotnet restore
RUN dotnet publish -c Release -o /app/publish
# Final stage
FROM base AS final
WORKDIR /app
COPY --from=build /app/publish .
EXPOSE 8088
ENV ASPNETCORE_URLS=http://+:8088
ENTRYPOINT ["dotnet", "HostedAgentSkills.dll"]
@@ -0,0 +1,23 @@
# Dockerfile for contributors building from the agent-framework repository source.
#
# This project uses ProjectReference to the local Microsoft.Agents.AI source,
# which means a standard multi-stage Docker build cannot resolve dependencies outside
# this folder. Instead, pre-publish the app targeting the container runtime and copy
# the output into the container:
#
# dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out
# docker build -f Dockerfile.contributor -t hosted-agent-skills .
# docker run --rm -p 8088:8088 \
# -e AGENT_NAME=hosted-agent-skills \
# -e HOSTED_USER_ISOLATION_KEY=alice \
# -e HOSTED_CHAT_ISOLATION_KEY=alice-chat-1 \
# --env-file .env hosted-agent-skills
#
# For end-users consuming the NuGet package (not ProjectReference), use the standard
# Dockerfile which performs a full dotnet restore + publish inside the container.
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final
WORKDIR /app
COPY out/ .
EXPOSE 8088
ENV ASPNETCORE_URLS=http://+:8088
ENTRYPOINT ["dotnet", "HostedAgentSkills.dll"]
@@ -0,0 +1,40 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
<RootNamespace>HostedAgentSkills</RootNamespace>
<AssemblyName>HostedAgentSkills</AssemblyName>
<NoWarn>$(NoWarn);MEAI001;OPENAI001;AAIP001</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.Projects" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="DotNetEnv" />
</ItemGroup>
<!-- For contributors: uses ProjectReference to build against local source -->
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
<ProjectReference Include="..\Hosted_Shared_Contributor_Setup\Hosted_Shared_Contributor_Setup.csproj" />
</ItemGroup>
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReferences above
<ItemGroup>
<PackageReference Include="Microsoft.Agents.AI" Version="1.6.1" />
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.6.1-preview.260514.1" />
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.6.1-preview.260514.1" />
</ItemGroup>
-->
<!-- Include the skills/ directory in the publish output so the sample can provision them -->
<ItemGroup>
<None Include="skills\**\*" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
@@ -0,0 +1,215 @@
// Copyright (c) Microsoft. All rights reserved.
// Hosted-AgentSkills
//
// Demonstrates how to host an agent that loads its behavioral guidelines from Foundry Skills at
// startup. Skills are authored as SKILL.md files, uploaded to Foundry via the Skills REST API,
// and downloaded by the agent on boot so guideline updates ship without code changes.
//
// The agent uses AgentSkillsProvider from the Agent Framework which implements the progressive
// disclosure pattern from the Agent Skills specification (https://agentskills.io/):
// 1. Advertise — skill names and descriptions are injected into the system prompt.
// 2. Load — the model calls load_skill to retrieve the full SKILL.md body on demand.
//
// IMPORTANT: In production, skill provisioning (uploading SKILL.md files to Foundry) is an
// external concern — it is NOT the hosted agent's responsibility. The provisioning helper below
// is included for sample convenience only, so the sample is self-contained and runnable without
// a separate setup step. A real deployment pipeline would provision skills separately (e.g., via
// a CI/CD step, a CLI script, or a management portal).
#pragma warning disable AAIP001 // ProjectAgentSkills is experimental
using System.ClientModel;
using System.IO.Compression;
using Azure.AI.Projects;
using Azure.AI.Projects.Agents;
using Azure.Core;
using Azure.Identity;
using DotNetEnv;
using Hosted_Shared_Contributor_Setup;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Foundry.Hosting;
using Microsoft.Extensions.AI;
// Load .env file if present (for local development)
Env.TraversePath().Load();
string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o";
string skillNames = Environment.GetEnvironmentVariable("SKILL_NAMES")
?? throw new InvalidOperationException("SKILL_NAMES is not set. Provide a comma-separated list of skill names (e.g., support-style,escalation-policy).");
string[] requestedSkills = skillNames.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (requestedSkills.Length == 0)
{
throw new InvalidOperationException("SKILL_NAMES must list at least one skill name.");
}
// Validate skill names to prevent path traversal.
foreach (string name in requestedSkills)
{
if (name.Contains('.') || name.Contains('/') || name.Contains('\\') || Path.IsPathRooted(name))
{
throw new InvalidOperationException(
$"Invalid skill name '{name}': skill names must not contain path separators or dots.");
}
}
// Use a chained credential: try a temporary dev token first (for local Docker debugging),
// then fall back to DefaultAzureCredential (for local dev via dotnet run / managed identity in production).
TokenCredential credential = new ChainedTokenCredential(
new DevTemporaryTokenCredential(),
new DefaultAzureCredential());
AIProjectClient projectClient = new(new Uri(endpoint), credential);
ProjectAgentSkills skillsClient = projectClient.AgentAdministrationClient.GetAgentSkills();
// ── Provision skills (sample convenience only — NOT a production pattern) ─────
// In production, skills are provisioned externally (e.g., via CI/CD or a management script).
// This helper ensures the sample's SKILL.md files exist in Foundry so the sample is runnable
// out of the box without a separate setup step. Set PROVISION_SAMPLE_SKILLS=true to enable.
string sourceSkillsDir = Path.Combine(AppContext.BaseDirectory, "skills");
bool provisionEnabled = string.Equals(
Environment.GetEnvironmentVariable("PROVISION_SAMPLE_SKILLS"), "true", StringComparison.OrdinalIgnoreCase);
if (provisionEnabled && Directory.Exists(sourceSkillsDir))
{
await EnsureSkillsProvisionedAsync(skillsClient, sourceSkillsDir, requestedSkills);
}
// ── Download skills from Foundry ─────────────────────────────────────────────
// Pull the latest copy of each skill from Foundry into a runtime-only folder.
// This directory is recreated on every startup so the agent always picks up
// the latest version of each skill.
string downloadedSkillsDir = Path.Combine(AppContext.BaseDirectory, "downloaded_skills");
await DownloadSkillsAsync(skillsClient, requestedSkills, downloadedSkillsDir);
// ── Wire skills into the agent ───────────────────────────────────────────────
// AgentSkillsProvider implements progressive disclosure: skill names and descriptions
// are advertised in the system prompt (~100 tokens per skill), and the full SKILL.md
// body is loaded on demand when the model calls the load_skill tool.
AgentSkillsProvider skillsProvider = new(downloadedSkillsDir);
ChatClientAgent agent = projectClient.AsAIAgent(new ChatClientAgentOptions
{
Name = Environment.GetEnvironmentVariable("AGENT_NAME") ?? "hosted-agent-skills",
ChatOptions = new ChatOptions
{
ModelId = deploymentName,
Instructions = "You are a customer-support assistant for Contoso Outdoors.",
},
AIContextProviders = [skillsProvider]
});
// Host the agent as a Foundry Hosted Agent using the Responses API.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddFoundryResponses(agent);
builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debugging only - must not be used in production.
var app = builder.Build();
app.MapFoundryResponses();
// Contributor-only: in Development, also map the per-agent OpenAI route shape that live Foundry uses
// so a local REPL client can target this server via AIProjectClient.AsAIAgent(Uri agentEndpoint).
// Do not use this in production. Hosted Foundry agents only support the agent-endpoint path.
app.MapDevTemporaryLocalAgentEndpoint();
app.Run();
// ── Helpers ──────────────────────────────────────────────────────────────────
// Downloads each named skill from Foundry and extracts the ZIP archive into a
// separate subdirectory under the target directory.
static async Task DownloadSkillsAsync(ProjectAgentSkills skillsClient, string[] skillNames, string targetDir)
{
if (Directory.Exists(targetDir))
{
Directory.Delete(targetDir, recursive: true);
}
Directory.CreateDirectory(targetDir);
foreach (string name in skillNames)
{
Console.WriteLine($"Downloading skill '{name}' from Foundry...");
BinaryData zipData = await skillsClient.DownloadSkillAsync(name);
string skillDir = Path.Combine(targetDir, name);
Directory.CreateDirectory(skillDir);
using var zipStream = zipData.ToStream();
using var archive = new ZipArchive(zipStream, ZipArchiveMode.Read);
SafeExtractZip(archive, skillDir);
if (!File.Exists(Path.Combine(skillDir, "SKILL.md")))
{
throw new InvalidOperationException(
$"Downloaded archive for '{name}' did not contain a SKILL.md at the root.");
}
}
}
// Extracts a ZIP archive into a destination directory, rejecting entries that would
// escape the target path (zip-slip guard).
static void SafeExtractZip(ZipArchive archive, string destinationDir)
{
string destRoot = Path.GetFullPath(destinationDir);
string destRootWithSep = Path.EndsInDirectorySeparator(destRoot)
? destRoot
: destRoot + Path.DirectorySeparatorChar;
// Use ordinal comparison on Unix (case-sensitive FS) and ordinal-ignore-case on Windows.
var comparison = OperatingSystem.IsWindows()
? StringComparison.OrdinalIgnoreCase
: StringComparison.Ordinal;
foreach (ZipArchiveEntry entry in archive.Entries)
{
string entryPath = Path.GetFullPath(Path.Combine(destRoot, entry.FullName));
if (!entryPath.StartsWith(destRootWithSep, comparison)
&& !string.Equals(entryPath, destRoot, comparison))
{
throw new InvalidOperationException(
$"Refusing to extract unsafe path '{entry.FullName}' outside of '{destRoot}'.");
}
if (string.IsNullOrEmpty(entry.Name))
{
// Directory entry — ensure it exists.
Directory.CreateDirectory(entryPath);
}
else
{
Directory.CreateDirectory(Path.GetDirectoryName(entryPath)!);
entry.ExtractToFile(entryPath, overwrite: true);
}
}
}
// Ensures each requested skill is provisioned in Foundry. For each skill name, checks whether
// the skill exists and uploads it from the local source directory if it does not.
//
// This is a sample convenience helper — in production, skill provisioning is an external concern.
static async Task EnsureSkillsProvisionedAsync(ProjectAgentSkills skillsClient, string sourceDir, string[] skillNames)
{
foreach (string name in skillNames)
{
string skillPath = Path.Combine(sourceDir, name);
if (!Directory.Exists(skillPath) || !File.Exists(Path.Combine(skillPath, "SKILL.md")))
{
continue; // No local source for this skill — skip provisioning.
}
try
{
await skillsClient.GetSkillAsync(name);
Console.WriteLine($"Skill '{name}' already exists in Foundry.");
}
catch (ClientResultException ex) when (ex.Status == 404)
{
Console.WriteLine($"Provisioning skill '{name}' from {skillPath}...");
AgentsSkill imported = await skillsClient.CreateSkillFromPackageAsync(skillPath);
Console.WriteLine($" Imported skill '{imported.Name}' (id={imported.SkillId}, has_blob={imported.HasBlob}).");
}
}
}
@@ -0,0 +1,109 @@
# What this sample demonstrates
An [Agent Framework](https://github.com/microsoft/agent-framework) agent that loads its behavioral guidelines from [**Foundry Skills**](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/tools/skills) at startup, hosted using the **Responses protocol**. Skills are authored once as `SKILL.md` files, uploaded to your Foundry project through the Skills REST API, and downloaded by the agent on boot so updates ship without code changes.
## How It Works
### Authoring skills
Each skill is a Markdown file with a YAML front matter block. This sample ships two source skills under [`skills/`](skills/):
| Skill | Purpose |
|---|---|
| [`support-style`](skills/support-style/SKILL.md) | Voice, formatting, and signature rules for Contoso Outdoors support replies. |
| [`escalation-policy`](skills/escalation-policy/SKILL.md) | When and how to escalate a customer ticket. |
Each `SKILL.md` includes a unique `*-CANARY-*` token that the model is asked to echo, so you can prove the skill was loaded from Foundry (not hallucinated) by checking the response.
> The `name` and `description` values in the YAML front matter must be **unquoted** — quoting them causes the Skills REST API to return HTTP 500 on import.
### Uploading skills
The sample includes a convenience provisioning step that checks whether each skill exists in Foundry and uploads it if not, gated behind the `PROVISION_SAMPLE_SKILLS=true` env var. **In production, skill provisioning is an external concern** — it is NOT the hosted agent's responsibility. A real deployment pipeline would provision skills separately (e.g., via a CI/CD step, a CLI script, or a management portal).
The provisioning uses `ProjectAgentSkills.CreateSkillFromPackageAsync(directoryPath)` from the `Azure.AI.Projects.Agents` SDK. The method packages the `SKILL.md` file as a ZIP and uploads it to Foundry.
### Downloading skills at agent startup
[`Program.cs`](Program.cs) reads the comma-separated `SKILL_NAMES` env var and for each skill name downloads the ZIP archive from Foundry via `ProjectAgentSkills.DownloadSkillAsync(name)`, then unpacks it into a **separate runtime directory** at `downloaded_skills/<name>/` (kept distinct from the static `skills/` source folder).
An [`AgentSkillsProvider`](../../../../../src/Microsoft.Agents.AI/Skills/AgentSkillsProvider.cs) is then built over `downloaded_skills/` and attached to the agent as a context provider. The provider follows the [Agent Skills](https://agentskills.io/) progressive-disclosure pattern:
1. **Advertise** — skill names and descriptions are injected into the system prompt at session start (~100 tokens per skill).
2. **Load** — the model calls the `load_skill` tool when it decides a skill is relevant to the user's turn, and the full `SKILL.md` body is returned.
This means the model only pays the token cost for a skill's full body when it actually needs it, and updating a skill in Foundry + restarting the agent is enough to pick up the change — no code redeploy required.
> **Note:** This sample supports instruction-only and resource-based skills. If your downloaded skills contain scripts, add a script runner when constructing the `AgentSkillsProvider`.
### Agent Hosting
The agent is hosted using the [Agent Framework](https://github.com/microsoft/agent-framework) with the Responses API hosting layer (`AddFoundryResponses` / `MapFoundryResponses`).
## Prerequisites
- An Azure AI Foundry project with a deployed model (e.g., `gpt-4o`)
- Azure CLI logged in (`az login`)
### Required RBAC
Your identity (or the Managed Identity running the container in production) needs **Azure AI User** on the Foundry project scope. This single role covers both authoring skills and downloading them.
## Running the Agent Host
Set the required environment variables and run the sample with `dotnet run`:
```bash
export AZURE_AI_PROJECT_ENDPOINT="https://<account>.services.ai.azure.com/api/projects/<project>"
export AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o"
export SKILL_NAMES="support-style,escalation-policy"
export PROVISION_SAMPLE_SKILLS="true" # First run only — provisions skills to Foundry
```
Or in PowerShell:
```powershell
$env:SKILL_NAMES="support-style,escalation-policy"
$env:PROVISION_SAMPLE_SKILLS="true" # First run only — provisions skills to Foundry
```
You can also place these in a `.env` file next to `Program.cs` — see [`.env.example`](.env.example).
On startup you should see:
```text
Skill 'support-style' already exists in Foundry.
Skill 'escalation-policy' already exists in Foundry.
Downloading skill 'support-style' from Foundry...
Downloading skill 'escalation-policy' from Foundry...
```
The downloaded `SKILL.md` files land under `downloaded_skills/<name>/SKILL.md` next to the published output. This directory is recreated from scratch on every run, so deleting it manually is never necessary.
## Interacting with the agent
> Send a POST request to the server with a JSON body containing an `"input"` field to interact with the agent. For example:
```bash
curl -X POST http://localhost:8088/responses -H "Content-Type: application/json" -d '{"input": "Hi, I am Alex. I just want to confirm I can return my tent within 30 days."}'
curl -X POST http://localhost:8088/responses -H "Content-Type: application/json" -d '{"input": "I want a $750 refund on Order #A-1042 right now or I am calling my lawyer."}'
```
| Prompt mentions | Skill that should drive the response |
|---|---|
| Routine return / shipping / care question | Model loads `support-style` (canary `STYLE-CANARY-3318`) — no escalation. |
| Injury, legal threat, press, or refund > $500 | Model loads `escalation-policy` (canary `ESC-CANARY-7742`) **and** `support-style`. |
Because skills are loaded on demand, the canary token in a response also proves the model actually invoked `load_skill` for the matching skill (not just saw its name in the advertised list).
## Deploying the Agent to Foundry
When deploying to Foundry, make sure `SKILL_NAMES` is set in your `azd` environment so it gets injected into the hosted container per [`agent.manifest.yaml`](agent.manifest.yaml):
```bash
azd env set SKILL_NAMES "support-style,escalation-policy"
```
The deployed agent's Managed Identity needs **Azure AI User** on the Foundry project to download skills at startup.
> The `skills/` source folder is **not** deployed to Foundry — only the downloaded skills are used at runtime. The provisioning step must have been run against the same Foundry project before the agent can download the skills.
@@ -0,0 +1,41 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml
name: hosted-agent-skills
displayName: "Hosted Agent Skills"
description: >
An Agent Framework agent that downloads its behavioral guidelines from the Foundry
Skills REST API at startup, demonstrating how to decouple behavioral guidelines
(tone, escalation policy, etc.) from agent code using AgentSkillsProvider.
metadata:
tags:
- AI Agent Hosting
- Azure AI AgentServer
- Responses Protocol
- Agent Framework
- Agent Skills
- Foundry Skills
template:
name: hosted-agent-skills
kind: hosted
protocols:
- protocol: responses
version: 1.0.0
resources:
cpu: "0.25"
memory: 0.5Gi
environment_variables:
- name: AZURE_AI_MODEL_DEPLOYMENT_NAME
value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}"
- name: SKILL_NAMES
value: "{{SKILL_NAMES}}"
parameters:
properties:
- name: SKILL_NAMES
secret: false
description: Comma-separated list of Foundry skill names to download at startup (e.g., support-style,escalation-policy)
resources:
- kind: model
id: gpt-4.1-mini
name: AZURE_AI_MODEL_DEPLOYMENT_NAME
@@ -0,0 +1,14 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml
kind: hosted
name: hosted-agent-skills
protocols:
- protocol: responses
version: 1.0.0
resources:
cpu: "0.25"
memory: 0.5Gi
environment_variables:
- name: AZURE_AI_MODEL_DEPLOYMENT_NAME
value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME}
- name: SKILL_NAMES
value: ${SKILL_NAMES}
@@ -0,0 +1,100 @@
#requires -Version 7
<#
.SYNOPSIS
Local smoke test for the Hosted-AgentSkills sample.
.DESCRIPTION
Publishes the sample, builds the contributor Docker image, runs the container, drives
two conversations via curl invocations, and asserts that the agent loaded the correct
Foundry Skill for each prompt (verified via canary tokens in the response).
Exits non-zero on failure.
Prerequisites:
- Docker
- az login (token is fetched from the host)
- .env populated with AZURE_AI_PROJECT_ENDPOINT and model deployment
- Skills provisioned to Foundry (set PROVISION_SAMPLE_SKILLS=true on first run)
.NOTES
This script is for local Docker debugging only. The Foundry platform supplies the
isolation keys for every inbound request in production and the dev fallback used here
must not be enabled in production deployments.
#>
[CmdletBinding()]
param(
[int]$Port = 8088,
[string]$ImageName = 'hosted-agent-skills-smoke',
[string]$ContainerName = 'hosted-agent-skills-smoke'
)
$ErrorActionPreference = 'Stop'
Set-Location -Path $PSScriptRoot/..
if (-not (Test-Path .env)) {
throw '.env not found. Copy .env.example to .env and fill in AZURE_AI_PROJECT_ENDPOINT.'
}
Write-Host '==> Publishing sample for linux-musl-x64 ...'
dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out --tl:off | Out-Host
if ($LASTEXITCODE -ne 0) { throw 'dotnet publish failed.' }
Write-Host '==> Building docker image ...'
docker build -f Dockerfile.contributor -t $ImageName . | Out-Host
if ($LASTEXITCODE -ne 0) { throw 'docker build failed.' }
Write-Host '==> Fetching bearer token ...'
$bearer = az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv
if (-not $bearer) { throw 'Failed to obtain bearer token. Run az login.' }
function Start-Container {
docker rm -f $ContainerName 2>$null | Out-Null
docker run -d --name $ContainerName -p ${Port}:8088 `
-e AGENT_NAME=hosted-agent-skills `
-e AZURE_BEARER_TOKEN=$bearer `
-e HOSTED_USER_ISOLATION_KEY=smoke-user `
-e HOSTED_CHAT_ISOLATION_KEY=smoke-chat-1 `
--env-file .env `
$ImageName | Out-Host
if ($LASTEXITCODE -ne 0) { throw "docker run failed." }
# Wait for the server to start and download skills from Foundry.
Write-Host ' Waiting for startup (skill download + server ready) ...'
Start-Sleep -Seconds 15
}
function Invoke-Agent([string]$Prompt, [string]$PreviousResponseId = $null) {
$body = @{ input = $Prompt; model = 'hosted-agent-skills' }
if ($PreviousResponseId) { $body['previous_response_id'] = $PreviousResponseId }
$json = $body | ConvertTo-Json -Compress
$resp = Invoke-RestMethod -Method Post -Uri "http://localhost:$Port/responses" -ContentType 'application/json' -Body $json
return $resp
}
function Get-ResponseText($response) {
return ($response.output | ForEach-Object { $_.content | ForEach-Object { $_.text } }) -join ' '
}
function Assert-Contains([string]$Haystack, [string]$Needle, [string]$Label) {
if ($Haystack -notmatch [regex]::Escape($Needle)) {
throw "FAILED [$Label]: expected response to contain '$Needle' but got: $Haystack"
}
Write-Host "PASS [$Label]: response contains '$Needle'."
}
try {
Start-Container
Write-Host '==> Test 1: Routine support question -> support-style skill ...'
$r1 = Invoke-Agent -Prompt 'Hi, I am Alex. I just want to confirm I can return my tent within 30 days.'
$text1 = Get-ResponseText $r1
Assert-Contains $text1 'STYLE-CANARY-3318' 'routine question: support-style canary'
Write-Host '==> Test 2: Escalation trigger -> escalation-policy skill ...'
$r2 = Invoke-Agent -Prompt 'I want a $750 refund on Order #A-1042 right now or I am calling my lawyer.'
$text2 = Get-ResponseText $r2
Assert-Contains $text2 'ESC-CANARY-7742' 'escalation trigger: escalation-policy canary'
Write-Host ''
Write-Host '==> All smoke assertions passed.'
}
finally {
docker rm -f $ContainerName 2>$null | Out-Null
}
@@ -0,0 +1,30 @@
---
name: escalation-policy
description: When and how to escalate Contoso Outdoors customer-support tickets.
---
# Contoso Outdoors Escalation Policy
You must follow this escalation policy on every conversation.
## Escalate immediately when the customer
- Reports an injury, allergic reaction, or other safety incident.
- Mentions legal action, regulators, or the press.
- Has waited more than 14 days for a refund that was already approved.
- Requests a refund larger than $500.
## How to escalate
1. Acknowledge the issue in one sentence.
2. Tell the customer you are escalating to a senior specialist.
3. Provide the escalation reference `ESC-CANARY-7742` and the SLA: a senior
specialist will reply within 1 business day.
4. Do not promise a specific outcome (refund, replacement, compensation) on
escalated tickets — only the senior specialist can commit to one.
## Do not escalate
- Routine returns within the standard 30-day window.
- Shipping status questions.
- Product care and usage questions.
@@ -0,0 +1,25 @@
---
name: support-style
description: Contoso Outdoors customer-support tone and formatting guidelines.
---
# Contoso Outdoors Support Style
You are speaking on behalf of Contoso Outdoors customer support.
## Voice
- Warm, concise, and confident — never apologetic in a hand-wringing way.
- Use the customer's name when it is known.
- Sign every response with `— Contoso Outdoors Support`.
## Formatting
- Keep replies to 13 short paragraphs unless the customer asks for detail.
- Use bullet lists only when enumerating concrete steps or options.
- Always reference order numbers as `Order #<id>` (e.g. `Order #A-1042`).
## Canary
To prove this skill was loaded, include the token `STYLE-CANARY-3318` on a
separate line at the bottom of every response, prefixed with `# `.
@@ -27,9 +27,9 @@
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReferences above
<ItemGroup>
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.0.0" />
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.0.0" />
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0" />
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.6.1-preview.260514.1" />
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.6.1-preview.260514.1" />
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.6.1" />
</ItemGroup>
-->
@@ -23,8 +23,8 @@
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReference above
<ItemGroup>
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.0.0" />
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.0.0" />
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.6.1-preview.260514.1" />
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.6.1-preview.260514.1" />
<PackageReference Include="Azure.AI.Projects" />
<PackageReference Include="Azure.Identity" />
</ItemGroup>
@@ -33,8 +33,8 @@
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReference above
<ItemGroup>
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.0.0" />
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.0.0" />
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.6.1-preview.260514.1" />
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.6.1-preview.260514.1" />
</ItemGroup>
-->
@@ -23,8 +23,8 @@
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReference above
<ItemGroup>
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.0.0" />
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.0.0" />
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.6.1-preview.260514.1" />
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.6.1-preview.260514.1" />
<PackageReference Include="Azure.AI.Projects" />
<PackageReference Include="Azure.Identity" />
</ItemGroup>
@@ -25,8 +25,8 @@
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReference above
<ItemGroup>
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.0.0" />
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.0.0" />
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.6.1-preview.260514.1" />
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.6.1-preview.260514.1" />
</ItemGroup>
-->
@@ -26,8 +26,8 @@
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReference above
<ItemGroup>
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.0.0" />
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.0.0" />
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.6.1-preview.260514.1" />
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.6.1-preview.260514.1" />
</ItemGroup>
-->
@@ -25,8 +25,8 @@
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReferences above
<ItemGroup>
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.0.0" />
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.0.0" />
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.6.1-preview.260514.1" />
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.6.1-preview.260514.1" />
</ItemGroup>
-->
@@ -25,8 +25,8 @@
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReference above
<ItemGroup>
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.0.0" />
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.0.0" />
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.6.1-preview.260514.1" />
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.6.1-preview.260514.1" />
</ItemGroup>
-->
@@ -26,9 +26,9 @@
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReferences above
<ItemGroup>
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.0.0" />
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.0.0" />
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0" />
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.6.1-preview.260514.1" />
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.6.1-preview.260514.1" />
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.6.1" />
</ItemGroup>
-->
@@ -25,8 +25,8 @@
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReference above
<ItemGroup>
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.0.0" />
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.0.0" />
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.6.1-preview.260514.1" />
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.6.1-preview.260514.1" />
</ItemGroup>
-->
@@ -32,11 +32,11 @@
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReference above
<ItemGroup>
<PackageReference Include="Microsoft.Agents.AI.Foundry" />
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" />
<PackageReference Include="Microsoft.Agents.AI.Hosting" />
<PackageReference Include="Microsoft.Agents.AI.OpenAI" />
<PackageReference Include="Microsoft.Agents.AI.Workflows" />
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.6.1-preview.260514.1" />
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.6.1-preview.260514.1" />
<PackageReference Include="Microsoft.Agents.AI.Hosting" Version="1.6.1-preview.260514.1" />
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.6.1" />
<PackageReference Include="Microsoft.Agents.AI.Workflows" Version="1.6.1" />
</ItemGroup>
-->
@@ -27,10 +27,10 @@
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReferences above
<ItemGroup>
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.0.0" />
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.0.0" />
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0" />
<PackageReference Include="Microsoft.Agents.AI.Workflows" Version="1.0.0" />
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.6.1-preview.260514.1" />
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.6.1-preview.260514.1" />
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.6.1" />
<PackageReference Include="Microsoft.Agents.AI.Workflows" Version="1.6.1" />
</ItemGroup>
-->
@@ -27,11 +27,13 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.Projects" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Azure.Search.Documents" />
<PackageReference Include="Microsoft.Extensions.AI" />
@@ -38,6 +38,7 @@ AIAgent agent = scenario switch
"memory" => await CreateMemoryAgentAsync(projectClient, deployment).ConfigureAwait(false),
"azure-search-rag" => CreateAzureSearchRagAgent(projectClient, deployment),
"session-files" => CreateSessionFilesAgent(projectClient, deployment),
"agent-skills" => CreateAgentSkillsAgent(projectClient, deployment),
_ => throw new InvalidOperationException($"Unknown IT_SCENARIO '{scenario}'.")
};
@@ -209,6 +210,77 @@ static async Task<AIAgent> CreateMemoryAgentAsync(AIProjectClient client, string
});
}
// Agent skills scenario. Uses AgentSkillsProvider with two bundled Contoso Outdoors skills
// (support-style + escalation-policy). Skills are loaded from embedded SKILL.md files on disk,
// simulating the download-from-Foundry pattern used by the Hosted-AgentSkills sample. When the
// container starts, it writes the skills to a temp directory and wires AgentSkillsProvider over it.
#pragma warning disable MEAI001 // AgentSkillsProvider is experimental
static AIAgent CreateAgentSkillsAgent(AIProjectClient client, string deployment)
{
string skillsDir = Path.Combine(Path.GetTempPath(), "it-agent-skills-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(Path.Combine(skillsDir, "support-style"));
Directory.CreateDirectory(Path.Combine(skillsDir, "escalation-policy"));
File.WriteAllText(Path.Combine(skillsDir, "support-style", "SKILL.md"),
"""
---
name: support-style
description: Contoso Outdoors customer-support tone and formatting guidelines.
---
# Contoso Outdoors Support Style
You are speaking on behalf of Contoso Outdoors customer support.
## Voice
- Warm, concise, and confident.
- Use the customer's name when known.
- Sign every response with ` Contoso Outdoors Support`.
## Canary
To prove this skill was loaded, include the token `STYLE-CANARY-3318` on a
separate line at the bottom of every response, prefixed with `# `.
""");
File.WriteAllText(Path.Combine(skillsDir, "escalation-policy", "SKILL.md"),
"""
---
name: escalation-policy
description: When and how to escalate Contoso Outdoors customer-support tickets.
---
# Contoso Outdoors Escalation Policy
## Escalate immediately when the customer
- Reports an injury or safety incident.
- Mentions legal action, regulators, or the press.
- Requests a refund larger than $500.
## How to escalate
1. Acknowledge the issue.
2. Tell the customer you are escalating to a senior specialist.
3. Provide the escalation reference `ESC-CANARY-7742`.
""");
var skillsProvider = new AgentSkillsProvider(skillsDir, scriptRunner: null);
return client.AsAIAgent(new ChatClientAgentOptions
{
Name = "agent-skills-agent",
ChatOptions = new ChatOptions
{
ModelId = deployment,
Instructions = "You are a customer-support assistant for Contoso Outdoors.",
},
AIContextProviders = [skillsProvider]
});
}
#pragma warning restore MEAI001
[Description("Returns the current UTC date and time as an ISO 8601 string.")]
static string GetUtcNow() => DateTime.UtcNow.ToString("o");
@@ -0,0 +1,84 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Linq;
using System.Threading.Tasks;
using Foundry.Hosting.IntegrationTests.Fixtures;
using Microsoft.Extensions.AI;
namespace Foundry.Hosting.IntegrationTests;
/// <summary>
/// Integration tests that exercise the Agent Skills pattern in a hosted agent container.
/// The container uses <see cref="Microsoft.Agents.AI.AgentSkillsProvider"/> with two
/// Contoso Outdoors skills (support-style, escalation-policy) to verify the progressive
/// disclosure flow: skills are advertised in the system prompt and loaded on demand via
/// the <c>load_skill</c> tool when the model decides they are relevant.
/// </summary>
[Trait("Category", "FoundryHostedAgents")]
public sealed class AgentSkillsHostedAgentTests(AgentSkillsHostedAgentFixture fixture) : IClassFixture<AgentSkillsHostedAgentFixture>
{
private readonly AgentSkillsHostedAgentFixture _fixture = fixture;
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
public async Task RoutineQuestion_LoadsSupportStyleSkillAsync()
{
// Arrange
var agent = this._fixture.Agent;
// Act — ask a routine support question that should trigger the support-style skill
var response = await agent.RunAsync(
"Hi, I am Alex. I just want to confirm I can return my tent within 30 days.");
// Assert — response should contain the canary token proving the skill was loaded
Assert.False(string.IsNullOrWhiteSpace(response.Text));
Assert.Contains("STYLE-CANARY-3318", response.Text);
}
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
public async Task EscalationTrigger_LoadsEscalationPolicySkillAsync()
{
// Arrange
var agent = this._fixture.Agent;
// Act — trigger an escalation (legal threat + refund > $500)
var response = await agent.RunAsync(
"I want a $750 refund on Order #A-1042 right now or I am calling my lawyer.");
// Assert — response should contain the escalation canary token
Assert.False(string.IsNullOrWhiteSpace(response.Text));
Assert.Contains("ESC-CANARY-7742", response.Text);
}
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
public async Task SkillsAreAdvertised_LoadSkillToolIsAvailableAsync()
{
// Arrange
var agent = this._fixture.Agent;
// Act — ask the model what skills are available (triggers system prompt inspection)
var response = await agent.RunAsync(
"List the skills you have access to. Just give me their names.");
// Assert — both skills should be mentioned (they are advertised in the system prompt)
Assert.False(string.IsNullOrWhiteSpace(response.Text));
Assert.Contains("support-style", response.Text);
Assert.Contains("escalation-policy", response.Text);
}
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
public async Task LoadSkill_InvokesToolAndReturnsContentAsync()
{
// Arrange
var agent = this._fixture.Agent;
// Act — ask a question that should load a specific skill
var response = await agent.RunAsync(
"I need to know the escalation policy for customer tickets. Load the escalation-policy skill and tell me the rules.");
// Assert — the response should reference the load_skill tool invocation
Assert.False(string.IsNullOrWhiteSpace(response.Text));
Assert.True(
response.Messages.Any(m => m.Contents.OfType<FunctionCallContent>().Any(fc => fc.Name == "load_skill")),
"Expected at least one load_skill FunctionCallContent in the response messages.");
}
}
@@ -0,0 +1,14 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Foundry.Hosting.IntegrationTests.Fixtures;
/// <summary>
/// Provisions a hosted agent that runs the test container in <c>IT_SCENARIO=agent-skills</c> mode.
/// The container creates two Contoso Outdoors skills (support-style, escalation-policy) on disk
/// and wires them into <see cref="Microsoft.Agents.AI.AgentSkillsProvider"/> so the model can
/// discover and load skills via the progressive disclosure pattern.
/// </summary>
public sealed class AgentSkillsHostedAgentFixture : HostedAgentFixture
{
protected override string ScenarioName => "agent-skills";
}
@@ -199,6 +199,7 @@ human-only operation; CI only adds and deletes versions under existing agents.
| `CustomStorageHostedAgentFixture` | `custom-storage` | `it-custom-storage` | Round trip with custom `IResponsesStorageProvider`; multi turn reads from the custom store (placeholder). |
| `AzureSearchRagHostedAgentFixture` | `azure-search-rag` | `it-azure-search-rag` | RAG against a real Azure AI Search index seeded with Contoso Outdoors documents; verifies the model cites the retrieved sources. |
| `SessionFilesHostedAgentFixture` | `session-files` | `it-session-files` | End-to-end: upload via `AgentSessionFiles` (alpha) into a pinned `agent_session_id`, invoke the agent, assert it reads the file via the container's `ReadFile` tool. |
| `AgentSkillsHostedAgentFixture` | `agent-skills` | `it-agent-skills` | Agent skills via `AgentSkillsProvider`: advertises two Contoso Outdoors skills (support-style, escalation-policy) in the system prompt, loads them on demand via `load_skill`, verifies canary tokens prove the skill was loaded. |
The placeholder scenarios will be wired up in the test container `Program.cs` once the
relevant `Microsoft.Agents.AI.Foundry.Hosting` API surfaces stabilize.
@@ -47,7 +47,8 @@ $Scenarios = @(
'custom-storage',
'memory',
'azure-search-rag',
'session-files'
'session-files',
'agent-skills'
)
# Resolve project ARM scope from the endpoint.
@@ -0,0 +1,301 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
/// <summary>
/// Unit tests that verify the Hosted-AgentSkills sample patterns: ZIP extraction with
/// zip-slip guard, skill name validation, and AgentSkillsProvider loading from
/// downloaded skill directories (the Foundry download → extract → wire-into-provider flow).
/// </summary>
public sealed class HostedAgentSkillsPatternTests : IDisposable
{
private readonly string _testRoot;
private readonly TestAIAgent _agent = new();
public HostedAgentSkillsPatternTests()
{
this._testRoot = Path.Combine(Path.GetTempPath(), "hosted-skills-tests-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(this._testRoot);
}
public void Dispose()
{
if (Directory.Exists(this._testRoot))
{
Directory.Delete(this._testRoot, recursive: true);
}
}
// ── ZIP extraction tests ──────────────────────────────────────────────────
[Fact]
public void SafeExtractZip_ValidArchive_ExtractsToDestination()
{
// Arrange
string destDir = Path.Combine(this._testRoot, "valid-extract");
Directory.CreateDirectory(destDir);
byte[] zip = CreateZipWithEntry("SKILL.md", "---\nname: test\ndescription: Test\n---\nBody.");
// Act
using var archive = new ZipArchive(new MemoryStream(zip), ZipArchiveMode.Read);
SafeExtractZip(archive, destDir);
// Assert
Assert.True(File.Exists(Path.Combine(destDir, "SKILL.md")));
string content = File.ReadAllText(Path.Combine(destDir, "SKILL.md"));
Assert.Contains("name: test", content);
}
[Fact]
public void SafeExtractZip_ZipSlipAttempt_ThrowsInvalidOperationException()
{
// Arrange
string destDir = Path.Combine(this._testRoot, "zipslip-test");
Directory.CreateDirectory(destDir);
byte[] zip = CreateZipWithEntry("../../../evil.txt", "malicious content");
// Act & Assert
using var archive = new ZipArchive(new MemoryStream(zip), ZipArchiveMode.Read);
var ex = Assert.Throws<InvalidOperationException>(() => SafeExtractZip(archive, destDir));
Assert.Contains("outside of", ex.Message);
}
[Fact]
public void SafeExtractZip_SiblingPrefixAttack_ThrowsInvalidOperationException()
{
// Arrange — sibling path that starts with the dest dir name
string destDir = Path.Combine(this._testRoot, "target");
Directory.CreateDirectory(destDir);
byte[] zip = CreateZipWithEntry("../target-evil/payload.txt", "exploit");
// Act & Assert
using var archive = new ZipArchive(new MemoryStream(zip), ZipArchiveMode.Read);
var ex = Assert.Throws<InvalidOperationException>(() => SafeExtractZip(archive, destDir));
Assert.Contains("outside of", ex.Message);
}
[Fact]
public void SafeExtractZip_DirectoryEntry_CreatesDirectory()
{
// Arrange
string destDir = Path.Combine(this._testRoot, "dir-entry");
Directory.CreateDirectory(destDir);
byte[] zip = CreateZipWithDirectoryEntry("subdir/");
// Act
using var archive = new ZipArchive(new MemoryStream(zip), ZipArchiveMode.Read);
SafeExtractZip(archive, destDir);
// Assert
Assert.True(Directory.Exists(Path.Combine(destDir, "subdir")));
}
// ── Skill name validation tests ──────────────────────────────────────────
[Theory]
[InlineData("../escape")]
[InlineData("path/traversal")]
[InlineData("path\\traversal")]
[InlineData("has.dots")]
public void ValidateSkillName_InvalidNames_Rejected(string name)
{
// Act & Assert
Assert.True(IsInvalidSkillName(name), $"Expected '{name}' to be rejected.");
}
[Theory]
[InlineData("support-style")]
[InlineData("escalation-policy")]
[InlineData("my-skill-123")]
public void ValidateSkillName_ValidNames_Accepted(string name)
{
// Act & Assert
Assert.False(IsInvalidSkillName(name), $"Expected '{name}' to be accepted.");
}
// ── AgentSkillsProvider integration with downloaded skill directories ─────
[Fact]
public async Task AgentSkillsProvider_WithDownloadedSkills_AdvertisesAndLoadsAsync()
{
// Arrange — simulate the Foundry download + extract flow
string downloadDir = Path.Combine(this._testRoot, "downloaded_skills");
Directory.CreateDirectory(downloadDir);
CreateDownloadedSkill(downloadDir, "support-style",
"---\nname: support-style\ndescription: Contoso Outdoors customer-support tone and formatting guidelines.\n---\n\n# Contoso Outdoors Support Style\n\nYou are speaking on behalf of Contoso Outdoors.\n\n## Canary\n\nInclude STYLE-CANARY-3318.");
CreateDownloadedSkill(downloadDir, "escalation-policy",
"---\nname: escalation-policy\ndescription: When and how to escalate Contoso Outdoors customer-support tickets.\n---\n\n# Escalation Policy\n\nProvide ESC-CANARY-7742.");
var provider = new AgentSkillsProvider(downloadDir, scriptRunner: null);
var inputContext = new AIContext
{
Instructions = "You are a customer-support assistant for Contoso Outdoors."
};
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext);
// Act
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
// Assert — skills are advertised in instructions
Assert.NotNull(result.Instructions);
Assert.Contains("support-style", result.Instructions);
Assert.Contains("escalation-policy", result.Instructions);
Assert.Contains("Contoso Outdoors customer-support tone", result.Instructions);
// Assert — load_skill tool is available
Assert.NotNull(result.Tools);
var toolNames = result.Tools!.Select(t => t.Name).ToList();
Assert.Contains("load_skill", toolNames);
// No scripts or resources => no read_skill_resource or run_skill_script
Assert.DoesNotContain("read_skill_resource", toolNames);
Assert.DoesNotContain("run_skill_script", toolNames);
}
[Fact]
public async Task LoadSkill_ReturnsFullContentWithCanaryAsync()
{
// Arrange
string downloadDir = Path.Combine(this._testRoot, "canary_skills");
Directory.CreateDirectory(downloadDir);
CreateDownloadedSkill(downloadDir, "support-style",
"---\nname: support-style\ndescription: Contoso tone guidelines.\n---\n\nInclude STYLE-CANARY-3318 at the bottom.");
var provider = new AgentSkillsProvider(downloadDir, scriptRunner: null);
var inputContext = new AIContext();
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext);
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
var loadSkillTool = result.Tools!.First(t => t.Name == "load_skill") as AIFunction;
Assert.NotNull(loadSkillTool);
// Act
var content = await loadSkillTool!.InvokeAsync(
new AIFunctionArguments(new System.Collections.Generic.Dictionary<string, object?> { ["skillName"] = "support-style" }));
// Assert
var text = content!.ToString()!;
Assert.Contains("STYLE-CANARY-3318", text);
Assert.Contains("name: support-style", text);
}
[Fact]
public async Task LoadSkill_UnknownName_ReturnsErrorAsync()
{
// Arrange
string downloadDir = Path.Combine(this._testRoot, "error_skills");
Directory.CreateDirectory(downloadDir);
CreateDownloadedSkill(downloadDir, "support-style",
"---\nname: support-style\ndescription: Test\n---\nBody.");
var provider = new AgentSkillsProvider(downloadDir, scriptRunner: null);
var inputContext = new AIContext();
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext);
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
var loadSkillTool = result.Tools!.First(t => t.Name == "load_skill") as AIFunction;
// Act
var content = await loadSkillTool!.InvokeAsync(
new AIFunctionArguments(new System.Collections.Generic.Dictionary<string, object?> { ["skillName"] = "nonexistent-skill" }));
// Assert
var text = content!.ToString()!;
Assert.Contains("Error", text);
Assert.Contains("not found", text);
}
// ── Helpers ──────────────────────────────────────────────────────────────
/// <summary>
/// Creates a downloaded skill directory with a SKILL.md file — simulating what
/// the Foundry download + ZIP extract flow produces.
/// </summary>
private static void CreateDownloadedSkill(string parentDir, string name, string content)
{
string skillDir = Path.Combine(parentDir, name);
Directory.CreateDirectory(skillDir);
File.WriteAllText(Path.Combine(skillDir, "SKILL.md"), content);
}
/// <summary>
/// Creates a ZIP archive in memory containing a single file entry.
/// </summary>
private static byte[] CreateZipWithEntry(string entryName, string content)
{
using var ms = new MemoryStream();
using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true))
{
var entry = archive.CreateEntry(entryName);
using var writer = new StreamWriter(entry.Open());
writer.Write(content);
}
return ms.ToArray();
}
/// <summary>
/// Creates a ZIP archive in memory containing a single directory entry.
/// </summary>
private static byte[] CreateZipWithDirectoryEntry(string directoryName)
{
using var ms = new MemoryStream();
using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true))
{
// Directory entries in ZIPs have an empty name portion and end with /
archive.CreateEntry(directoryName);
}
return ms.ToArray();
}
/// <summary>
/// Mirrors the zip-slip guard from the Hosted-AgentSkills sample Program.cs.
/// </summary>
private static void SafeExtractZip(ZipArchive archive, string destinationDir)
{
string destRoot = Path.GetFullPath(destinationDir);
string destRootWithSep = Path.EndsInDirectorySeparator(destRoot)
? destRoot
: destRoot + Path.DirectorySeparatorChar;
var comparison = OperatingSystem.IsWindows()
? StringComparison.OrdinalIgnoreCase
: StringComparison.Ordinal;
foreach (ZipArchiveEntry entry in archive.Entries)
{
string entryPath = Path.GetFullPath(Path.Combine(destRoot, entry.FullName));
if (!entryPath.StartsWith(destRootWithSep, comparison)
&& !string.Equals(entryPath, destRoot, comparison))
{
throw new InvalidOperationException(
$"Refusing to extract unsafe path '{entry.FullName}' outside of '{destRoot}'.");
}
if (string.IsNullOrEmpty(entry.Name))
{
Directory.CreateDirectory(entryPath);
}
else
{
Directory.CreateDirectory(Path.GetDirectoryName(entryPath)!);
entry.ExtractToFile(entryPath, overwrite: true);
}
}
}
/// <summary>
/// Mirrors the skill name validation from the Hosted-AgentSkills sample Program.cs.
/// </summary>
private static bool IsInvalidSkillName(string name) =>
name.Contains('.') || name.Contains('/') || name.Contains('\\') || Path.IsPathRooted(name);
}
@@ -16,6 +16,7 @@
<!-- Evaluation tests require net8.0+ (MEAI.Evaluation does not support legacy TFMs) -->
<ItemGroup Condition="!$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0'))">
<Compile Remove="EvaluationTests.cs" />
<Compile Remove="AgentSkills\HostedAgentSkillsPatternTests.cs" />
</ItemGroup>
<ItemGroup>