mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
* .NET: Add Hosted-AgentSkills sample for Foundry Skills integration Add a new hosted agent sample that demonstrates how to load behavioral guidelines from Foundry Skills at startup using AgentSkillsProvider and the progressive disclosure pattern (advertise -> load on demand). The sample: - Downloads SKILL.md files from Foundry via ProjectAgentSkills SDK - Extracts ZIP archives with zip-slip protection - Wires skills into AgentSkillsProvider as an AIContextProvider - Hosts the agent via the Responses protocol Ships two Contoso Outdoors skills matching the Python sample (PR #5822): - support-style: tone, formatting, signature guidelines - escalation-policy: when and how to escalate tickets Includes convenience provisioning gated behind PROVISION_SAMPLE_SKILLS env var, clearly documented as NOT a production pattern. Closes #5776 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * .NET: Add unit tests and integration test for Hosted-AgentSkills Unit tests (14 tests, all passing): - ZIP extraction with zip-slip guard (valid archive, traversal attack, sibling-prefix attack, directory entries) - Skill name validation (rejects dots, separators, traversal patterns) - AgentSkillsProvider with downloaded skills (advertises both skills, load_skill returns canary tokens, unknown skill returns error) Container integration test: - New 'agent-skills' scenario in the test container that creates Contoso Outdoors skills on disk and wires AgentSkillsProvider - AgentSkillsHostedAgentFixture + 4 integration tests verifying: - Routine questions load support-style skill (STYLE-CANARY-3318) - Escalation triggers load escalation-policy (ESC-CANARY-7742) - Skills are advertised in system prompt - load_skill tool is invoked via FunctionCallContent Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * .NET: Add smoke test, bootstrap, and docs for agent-skills integration - Add scripts/smoke.ps1 for local Docker smoke testing: builds the contributor image, runs the container, verifies both skills are loaded via canary tokens (STYLE-CANARY-3318, ESC-CANARY-7742) - Add 'agent-skills' to the bootstrap script scenario list - Add agent-skills row to the integration test README scenarios table - Exclude HostedAgentSkillsPatternTests from net472 (uses net8.0+ APIs) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * .NET: Update commented-out package versions to latest across all hosted samples Update the end-user PackageReference versions (in the commented-out sections) from 1.0.0 to the current latest NuGet versions: - Microsoft.Agents.AI: 1.6.1 - Microsoft.Agents.AI.Foundry: 1.6.1-preview.260514.1 - Microsoft.Agents.AI.Foundry.Hosting: 1.6.1-preview.260514.1 - Microsoft.Agents.AI.Hosting: 1.6.1-preview.260514.1 - Microsoft.Agents.AI.OpenAI: 1.6.1 - Microsoft.Agents.AI.Workflows: 1.6.1 Also adds explicit versions to Hosted-Workflow-Handoff which had bare PackageReference entries without Version attributes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * .NET: Fix broken markdown links in Hosted-AgentSkills README Remove references to non-existent ../../README.md. Replace with inline instructions matching other hosted samples that don't have a parent README. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * .NET: Use OS-appropriate string comparison in zip-slip guard Use Ordinal on Unix (case-sensitive FS) and OrdinalIgnoreCase on Windows to prevent case-based path bypass on Linux containers. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
216 lines
9.8 KiB
C#
216 lines
9.8 KiB
C#
// Copyright (c) Microsoft. All rights reserved.
|
|
|
|
// Hosted-AgentSkills
|
|
//
|
|
// Demonstrates how to host an agent that loads its behavioral guidelines from Foundry Skills at
|
|
// startup. Skills are authored as SKILL.md files, uploaded to Foundry via the Skills REST API,
|
|
// and downloaded by the agent on boot so guideline updates ship without code changes.
|
|
//
|
|
// The agent uses AgentSkillsProvider from the Agent Framework which implements the progressive
|
|
// disclosure pattern from the Agent Skills specification (https://agentskills.io/):
|
|
// 1. Advertise — skill names and descriptions are injected into the system prompt.
|
|
// 2. Load — the model calls load_skill to retrieve the full SKILL.md body on demand.
|
|
//
|
|
// IMPORTANT: In production, skill provisioning (uploading SKILL.md files to Foundry) is an
|
|
// external concern — it is NOT the hosted agent's responsibility. The provisioning helper below
|
|
// is included for sample convenience only, so the sample is self-contained and runnable without
|
|
// a separate setup step. A real deployment pipeline would provision skills separately (e.g., via
|
|
// a CI/CD step, a CLI script, or a management portal).
|
|
|
|
#pragma warning disable AAIP001 // ProjectAgentSkills is experimental
|
|
|
|
using System.ClientModel;
|
|
using System.IO.Compression;
|
|
using Azure.AI.Projects;
|
|
using Azure.AI.Projects.Agents;
|
|
using Azure.Core;
|
|
using Azure.Identity;
|
|
using DotNetEnv;
|
|
using Hosted_Shared_Contributor_Setup;
|
|
using Microsoft.Agents.AI;
|
|
using Microsoft.Agents.AI.Foundry.Hosting;
|
|
using Microsoft.Extensions.AI;
|
|
|
|
// Load .env file if present (for local development)
|
|
Env.TraversePath().Load();
|
|
|
|
string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
|
|
?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
|
|
string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o";
|
|
string skillNames = Environment.GetEnvironmentVariable("SKILL_NAMES")
|
|
?? throw new InvalidOperationException("SKILL_NAMES is not set. Provide a comma-separated list of skill names (e.g., support-style,escalation-policy).");
|
|
|
|
string[] requestedSkills = skillNames.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
|
if (requestedSkills.Length == 0)
|
|
{
|
|
throw new InvalidOperationException("SKILL_NAMES must list at least one skill name.");
|
|
}
|
|
|
|
// Validate skill names to prevent path traversal.
|
|
foreach (string name in requestedSkills)
|
|
{
|
|
if (name.Contains('.') || name.Contains('/') || name.Contains('\\') || Path.IsPathRooted(name))
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Invalid skill name '{name}': skill names must not contain path separators or dots.");
|
|
}
|
|
}
|
|
|
|
// Use a chained credential: try a temporary dev token first (for local Docker debugging),
|
|
// then fall back to DefaultAzureCredential (for local dev via dotnet run / managed identity in production).
|
|
TokenCredential credential = new ChainedTokenCredential(
|
|
new DevTemporaryTokenCredential(),
|
|
new DefaultAzureCredential());
|
|
|
|
AIProjectClient projectClient = new(new Uri(endpoint), credential);
|
|
ProjectAgentSkills skillsClient = projectClient.AgentAdministrationClient.GetAgentSkills();
|
|
|
|
// ── Provision skills (sample convenience only — NOT a production pattern) ─────
|
|
// In production, skills are provisioned externally (e.g., via CI/CD or a management script).
|
|
// This helper ensures the sample's SKILL.md files exist in Foundry so the sample is runnable
|
|
// out of the box without a separate setup step. Set PROVISION_SAMPLE_SKILLS=true to enable.
|
|
string sourceSkillsDir = Path.Combine(AppContext.BaseDirectory, "skills");
|
|
bool provisionEnabled = string.Equals(
|
|
Environment.GetEnvironmentVariable("PROVISION_SAMPLE_SKILLS"), "true", StringComparison.OrdinalIgnoreCase);
|
|
if (provisionEnabled && Directory.Exists(sourceSkillsDir))
|
|
{
|
|
await EnsureSkillsProvisionedAsync(skillsClient, sourceSkillsDir, requestedSkills);
|
|
}
|
|
|
|
// ── Download skills from Foundry ─────────────────────────────────────────────
|
|
// Pull the latest copy of each skill from Foundry into a runtime-only folder.
|
|
// This directory is recreated on every startup so the agent always picks up
|
|
// the latest version of each skill.
|
|
string downloadedSkillsDir = Path.Combine(AppContext.BaseDirectory, "downloaded_skills");
|
|
await DownloadSkillsAsync(skillsClient, requestedSkills, downloadedSkillsDir);
|
|
|
|
// ── Wire skills into the agent ───────────────────────────────────────────────
|
|
// AgentSkillsProvider implements progressive disclosure: skill names and descriptions
|
|
// are advertised in the system prompt (~100 tokens per skill), and the full SKILL.md
|
|
// body is loaded on demand when the model calls the load_skill tool.
|
|
AgentSkillsProvider skillsProvider = new(downloadedSkillsDir);
|
|
|
|
ChatClientAgent agent = projectClient.AsAIAgent(new ChatClientAgentOptions
|
|
{
|
|
Name = Environment.GetEnvironmentVariable("AGENT_NAME") ?? "hosted-agent-skills",
|
|
ChatOptions = new ChatOptions
|
|
{
|
|
ModelId = deploymentName,
|
|
Instructions = "You are a customer-support assistant for Contoso Outdoors.",
|
|
},
|
|
AIContextProviders = [skillsProvider]
|
|
});
|
|
|
|
// Host the agent as a Foundry Hosted Agent using the Responses API.
|
|
var builder = WebApplication.CreateBuilder(args);
|
|
builder.Services.AddFoundryResponses(agent);
|
|
builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debugging only - must not be used in production.
|
|
|
|
var app = builder.Build();
|
|
app.MapFoundryResponses();
|
|
|
|
// Contributor-only: in Development, also map the per-agent OpenAI route shape that live Foundry uses
|
|
// so a local REPL client can target this server via AIProjectClient.AsAIAgent(Uri agentEndpoint).
|
|
// Do not use this in production. Hosted Foundry agents only support the agent-endpoint path.
|
|
app.MapDevTemporaryLocalAgentEndpoint();
|
|
|
|
app.Run();
|
|
|
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
// Downloads each named skill from Foundry and extracts the ZIP archive into a
|
|
// separate subdirectory under the target directory.
|
|
static async Task DownloadSkillsAsync(ProjectAgentSkills skillsClient, string[] skillNames, string targetDir)
|
|
{
|
|
if (Directory.Exists(targetDir))
|
|
{
|
|
Directory.Delete(targetDir, recursive: true);
|
|
}
|
|
|
|
Directory.CreateDirectory(targetDir);
|
|
|
|
foreach (string name in skillNames)
|
|
{
|
|
Console.WriteLine($"Downloading skill '{name}' from Foundry...");
|
|
BinaryData zipData = await skillsClient.DownloadSkillAsync(name);
|
|
|
|
string skillDir = Path.Combine(targetDir, name);
|
|
Directory.CreateDirectory(skillDir);
|
|
|
|
using var zipStream = zipData.ToStream();
|
|
using var archive = new ZipArchive(zipStream, ZipArchiveMode.Read);
|
|
SafeExtractZip(archive, skillDir);
|
|
|
|
if (!File.Exists(Path.Combine(skillDir, "SKILL.md")))
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Downloaded archive for '{name}' did not contain a SKILL.md at the root.");
|
|
}
|
|
}
|
|
}
|
|
|
|
// Extracts a ZIP archive into a destination directory, rejecting entries that would
|
|
// escape the target path (zip-slip guard).
|
|
static void SafeExtractZip(ZipArchive archive, string destinationDir)
|
|
{
|
|
string destRoot = Path.GetFullPath(destinationDir);
|
|
string destRootWithSep = Path.EndsInDirectorySeparator(destRoot)
|
|
? destRoot
|
|
: destRoot + Path.DirectorySeparatorChar;
|
|
|
|
// Use ordinal comparison on Unix (case-sensitive FS) and ordinal-ignore-case on Windows.
|
|
var comparison = OperatingSystem.IsWindows()
|
|
? StringComparison.OrdinalIgnoreCase
|
|
: StringComparison.Ordinal;
|
|
|
|
foreach (ZipArchiveEntry entry in archive.Entries)
|
|
{
|
|
string entryPath = Path.GetFullPath(Path.Combine(destRoot, entry.FullName));
|
|
if (!entryPath.StartsWith(destRootWithSep, comparison)
|
|
&& !string.Equals(entryPath, destRoot, comparison))
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Refusing to extract unsafe path '{entry.FullName}' outside of '{destRoot}'.");
|
|
}
|
|
|
|
if (string.IsNullOrEmpty(entry.Name))
|
|
{
|
|
// Directory entry — ensure it exists.
|
|
Directory.CreateDirectory(entryPath);
|
|
}
|
|
else
|
|
{
|
|
Directory.CreateDirectory(Path.GetDirectoryName(entryPath)!);
|
|
entry.ExtractToFile(entryPath, overwrite: true);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Ensures each requested skill is provisioned in Foundry. For each skill name, checks whether
|
|
// the skill exists and uploads it from the local source directory if it does not.
|
|
//
|
|
// This is a sample convenience helper — in production, skill provisioning is an external concern.
|
|
static async Task EnsureSkillsProvisionedAsync(ProjectAgentSkills skillsClient, string sourceDir, string[] skillNames)
|
|
{
|
|
foreach (string name in skillNames)
|
|
{
|
|
string skillPath = Path.Combine(sourceDir, name);
|
|
if (!Directory.Exists(skillPath) || !File.Exists(Path.Combine(skillPath, "SKILL.md")))
|
|
{
|
|
continue; // No local source for this skill — skip provisioning.
|
|
}
|
|
|
|
try
|
|
{
|
|
await skillsClient.GetSkillAsync(name);
|
|
Console.WriteLine($"Skill '{name}' already exists in Foundry.");
|
|
}
|
|
catch (ClientResultException ex) when (ex.Status == 404)
|
|
{
|
|
Console.WriteLine($"Provisioning skill '{name}' from {skillPath}...");
|
|
AgentsSkill imported = await skillsClient.CreateSkillFromPackageAsync(skillPath);
|
|
Console.WriteLine($" Imported skill '{imported.Name}' (id={imported.SkillId}, has_blob={imported.HasBlob}).");
|
|
}
|
|
}
|
|
}
|