mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a84ad42f6d | ||
|
|
ded17b178c | ||
|
|
55dc3ce734 | ||
|
|
9d8e5ca4f5 | ||
|
|
af787569b3 | ||
|
|
3db2004e49 | ||
|
|
efdabd56dc | ||
|
|
371a869e44 | ||
|
|
e532ced950 | ||
|
|
5d8dd4ea4b | ||
|
|
4c4e1d9b87 | ||
|
|
1d301af7d2 | ||
|
|
8fbda1de22 | ||
|
|
ef86fb51d5 | ||
|
|
d5c07f2623 | ||
|
|
ae989b92e7 | ||
|
|
3242d8a4c4 | ||
|
|
e1e6e3d35e | ||
|
|
08697f8037 | ||
|
|
b0f5fa541c | ||
|
|
e3290a2d22 | ||
|
|
200488cb08 | ||
|
|
bd4fc64b4d | ||
|
|
b2e77067e9 | ||
|
|
08541ee5a9 | ||
|
|
dc4bafbc1e | ||
|
|
de6d0267f2 | ||
|
|
0099a6e2fa |
@@ -1,15 +1,19 @@
|
||||
{
|
||||
"name": "Python 3",
|
||||
"image": "mcr.microsoft.com/devcontainers/python:3.13-bullseye",
|
||||
"image": "mcr.microsoft.com/devcontainers/python:3.14-bookworm",
|
||||
"features": {
|
||||
"ghcr.io/va-h/devcontainers-features/uv:1": {},
|
||||
"ghcr.io/devcontainers/features/azure-cli:1.2.8": {}
|
||||
"ghcr.io/devcontainers/features/docker-in-docker:3": {},
|
||||
"ghcr.io/devcontainers/features/azure-cli:1.2.9": {},
|
||||
"ghcr.io/devcontainers/features/copilot-cli:1": {}
|
||||
},
|
||||
"postCreateCommand": "bash ./devsetup.sh",
|
||||
"workspaceFolder": "/workspaces/agent-framework/python/",
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
"GitHub.copilot",
|
||||
"GitHub.vscode-github-actions",
|
||||
"ms-python.python",
|
||||
"ms-windows-ai-studio.windows-ai-studio",
|
||||
"littlefoxteam.vscode-python-test-adapter"
|
||||
|
||||
@@ -8,7 +8,7 @@ ignorePatterns:
|
||||
- pattern: "./blob"
|
||||
- pattern: "./issues"
|
||||
- pattern: "./discussions"
|
||||
- pattern: "./pulls"
|
||||
- pattern: "./pull"
|
||||
- pattern: "https:\/\/platform.openai.com"
|
||||
- pattern: "http:\/\/localhost"
|
||||
- pattern: "http:\/\/127.0.0.1"
|
||||
|
||||
+4
-4
@@ -173,11 +173,11 @@ new SampleDefinition
|
||||
```csharp
|
||||
new SampleDefinition
|
||||
{
|
||||
Name = "Workflow_Declarative_GenerateCode",
|
||||
ProjectPath = "samples/03-workflows/Declarative/GenerateCode",
|
||||
Name = "Workflow_Visualization",
|
||||
ProjectPath = "samples/03-workflows/Visualization",
|
||||
IsDeterministic = true,
|
||||
MustContain = ["WORKFLOW: Parsing", "WORKFLOW: Defined"],
|
||||
ExpectedOutputDescription = ["The output should show a YAML workflow being parsed and C# code being generated from it."],
|
||||
MustContain = ["Generating workflow visualization...", "Mermaid string:", "DiGraph string:"],
|
||||
ExpectedOutputDescription = ["The output should show workflow visualization in Mermaid and DiGraph formats."],
|
||||
},
|
||||
```
|
||||
|
||||
|
||||
@@ -117,17 +117,18 @@
|
||||
<Project Path="samples/02-agents/AgentSkills/Agent_Step03_ClassBasedSkills/Agent_Step03_ClassBasedSkills.csproj" />
|
||||
<Project Path="samples/02-agents/AgentSkills/Agent_Step04_MixedSkills/Agent_Step04_MixedSkills.csproj" />
|
||||
<Project Path="samples/02-agents/AgentSkills/Agent_Step05_SkillsWithDI/Agent_Step05_SkillsWithDI.csproj" />
|
||||
<Project Path="samples/02-agents/AgentSkills/Agent_Step06_McpBasedSkills/Agent_Step06_McpBasedSkills.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/02-agents/Harness/">
|
||||
<File Path="samples/02-agents/Harness/README.md" />
|
||||
<Project Path="samples/02-agents/Harness/ConsoleReactiveComponents/ConsoleReactiveComponents.csproj" />
|
||||
<Project Path="samples/02-agents/Harness/ConsoleReactiveFramework/ConsoleReactiveFramework.csproj" />
|
||||
<Project Path="samples/02-agents/Harness/Harness_Shared_Console/Harness_Shared_Console.csproj" />
|
||||
<Project Path="samples/02-agents/Harness/Harness_Shared_Console_OpenAI/Harness_Shared_Console_OpenAI.csproj" />
|
||||
<Project Path="samples/02-agents/Harness/Harness_Step01_Research/Harness_Step01_Research.csproj" />
|
||||
<Project Path="samples/02-agents/Harness/Harness_Step02_Research_WithBackgroundAgents/Harness_Step02_Research_WithBackgroundAgents.csproj" />
|
||||
<Project Path="samples/02-agents/Harness/Harness_Step03_DataProcessing/Harness_Step03_DataProcessing.csproj" />
|
||||
<Project Path="samples/02-agents/Harness/Harness_Step04_CodeExecution/Harness_Step04_CodeExecution.csproj" />
|
||||
<Project Path="samples/02-agents/Harness/ConsoleReactiveFramework/ConsoleReactiveFramework.csproj" />
|
||||
<Project Path="samples/02-agents/Harness/ConsoleReactiveComponents/ConsoleReactiveComponents.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/02-agents/AGUI/Step05_StateManagement/">
|
||||
<Project Path="samples/02-agents/AGUI/Step05_StateManagement/Client/Client.csproj" />
|
||||
@@ -243,8 +244,8 @@
|
||||
<Project Path="samples/03-workflows/Declarative/FunctionTools/FunctionTools.csproj" />
|
||||
<Project Path="samples/03-workflows/Declarative/HostedWorkflow/HostedWorkflow.csproj" />
|
||||
<Project Path="samples/03-workflows/Declarative/InputArguments/InputArguments.csproj" />
|
||||
<Project Path="samples/03-workflows/Declarative/InvokeFunctionTool/InvokeFunctionTool.csproj" />
|
||||
<Project Path="samples/03-workflows/Declarative/InvokeFoundryToolboxMcp/InvokeFoundryToolboxMcp.csproj" />
|
||||
<Project Path="samples/03-workflows/Declarative/InvokeFunctionTool/InvokeFunctionTool.csproj" />
|
||||
<Project Path="samples/03-workflows/Declarative/InvokeHttpRequest/InvokeHttpRequest.csproj" />
|
||||
<Project Path="samples/03-workflows/Declarative/InvokeMcpTool/InvokeMcpTool.csproj" />
|
||||
<Project Path="samples/03-workflows/Declarative/Marketing/Marketing.csproj" />
|
||||
@@ -358,6 +359,9 @@
|
||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/">
|
||||
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/HostedWorkflowHandoff.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/">
|
||||
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/HostedAgentSkills.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/04-hosting/DurableAgents/" />
|
||||
<Folder Name="/Samples/04-hosting/DurableAgents/AzureFunctions/">
|
||||
<File Path="samples/04-hosting/DurableAgents/AzureFunctions/.editorconfig" />
|
||||
@@ -593,8 +597,8 @@
|
||||
<Project Path="src/Microsoft.Agents.AI.DurableTask/Microsoft.Agents.AI.DurableTask.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Foundry.Hosting/Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Foundry/Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Harness/Microsoft.Agents.AI.Harness.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.GitHub.Copilot/Microsoft.Agents.AI.GitHub.Copilot.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Harness/Microsoft.Agents.AI.Harness.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Hosting.A2A/Microsoft.Agents.AI.Hosting.A2A.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj" />
|
||||
@@ -620,8 +624,8 @@
|
||||
<Project Path="tests/AnthropicChatCompletion.IntegrationTests/AnthropicChatCompletion.IntegrationTests.csproj" />
|
||||
<Project Path="tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistent.IntegrationTests.csproj" />
|
||||
<Project Path="tests/CopilotStudio.IntegrationTests/CopilotStudio.IntegrationTests.csproj" />
|
||||
<Project Path="tests/Foundry.Hosting.IntegrationTests/Foundry.Hosting.IntegrationTests.csproj" />
|
||||
<Project Path="tests/Foundry.Hosting.IntegrationTests.TestContainer/Foundry.Hosting.IntegrationTests.TestContainer.csproj" />
|
||||
<Project Path="tests/Foundry.Hosting.IntegrationTests/Foundry.Hosting.IntegrationTests.csproj" />
|
||||
<Project Path="tests/Foundry.IntegrationTests/Foundry.IntegrationTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/Microsoft.Agents.AI.DurableTask.IntegrationTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.GitHub.Copilot.IntegrationTests/Microsoft.Agents.AI.GitHub.Copilot.IntegrationTests.csproj" />
|
||||
@@ -646,8 +650,8 @@
|
||||
<Project Path="tests/Microsoft.Agents.AI.Declarative.UnitTests/Microsoft.Agents.AI.Declarative.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.DevUI.UnitTests/Microsoft.Agents.AI.DevUI.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.DurableTask.UnitTests/Microsoft.Agents.AI.DurableTask.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.GitHub.Copilot.UnitTests/Microsoft.Agents.AI.GitHub.Copilot.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Harness.UnitTests/Microsoft.Agents.AI.Harness.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Microsoft.Agents.AI.Hosting.A2A.UnitTests.csproj" />
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"src\\Microsoft.Agents.AI.Mem0\\Microsoft.Agents.AI.Mem0.csproj",
|
||||
"src\\Microsoft.Agents.AI.OpenAI\\Microsoft.Agents.AI.OpenAI.csproj",
|
||||
"src\\Microsoft.Agents.AI.Purview\\Microsoft.Agents.AI.Purview.csproj",
|
||||
"src\\Microsoft.Agents.AI.Tools.Shell\\Microsoft.Agents.AI.Tools.Shell.csproj",
|
||||
"src\\Microsoft.Agents.AI.Workflows.Declarative.Foundry\\Microsoft.Agents.AI.Workflows.Declarative.Foundry.csproj",
|
||||
"src\\Microsoft.Agents.AI.Workflows.Declarative\\Microsoft.Agents.AI.Workflows.Declarative.csproj",
|
||||
"src\\Microsoft.Agents.AI.Workflows.Generators\\Microsoft.Agents.AI.Workflows.Generators.csproj",
|
||||
|
||||
@@ -11,9 +11,6 @@
|
||||
<ItemGroup Condition="'$(InjectSharedIntegrationTestAzureCredentialsCode)' == 'true'">
|
||||
<Compile Include="$(MSBuildThisFileDirectory)\..\..\src\Shared\IntegrationTestsAzureCredentials\*.cs" LinkBase="Shared\IntegrationTestsAzureCredentials" />
|
||||
</ItemGroup>
|
||||
<ItemGroup Condition="'$(InjectSharedBuildTestCode)' == 'true'">
|
||||
<Compile Include="$(MSBuildThisFileDirectory)\..\..\src\Shared\CodeTests\*.cs" LinkBase="Shared\CodeTests" />
|
||||
</ItemGroup>
|
||||
<ItemGroup Condition="'$(InjectSharedWorkflowsExecution)' == 'true'">
|
||||
<Compile Include="$(MSBuildThisFileDirectory)\..\..\src\Shared\Workflows\Execution\*.cs" LinkBase="Shared\Workflows" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -363,6 +363,25 @@ internal static class AgentsSamples
|
||||
],
|
||||
},
|
||||
|
||||
new SampleDefinition
|
||||
{
|
||||
Name = "Agent_Step06_McpBasedSkills",
|
||||
ProjectPath = "samples/02-agents/AgentSkills/Agent_Step06_McpBasedSkills",
|
||||
RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"],
|
||||
OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"],
|
||||
MustContain =
|
||||
[
|
||||
"Discovering MCP-based skills",
|
||||
"Agent:",
|
||||
],
|
||||
ExpectedOutputDescription =
|
||||
[
|
||||
"The output should show the agent converting 26.2 miles to kilometers and 75 kilograms to pounds.",
|
||||
"The response should contain approximate numeric values for both conversions.",
|
||||
"The output should not contain error messages or stack traces.",
|
||||
],
|
||||
},
|
||||
|
||||
// ── AgentWithMemory ─────────────────────────────────────────────────
|
||||
|
||||
new SampleDefinition
|
||||
|
||||
@@ -439,15 +439,6 @@ internal static class WorkflowSamples
|
||||
ExpectedOutputDescription = ["The output should show a workflow calling function tools (e.g. a menu plugin) to answer a question about restaurant specials."],
|
||||
},
|
||||
|
||||
new SampleDefinition
|
||||
{
|
||||
Name = "Workflow_Declarative_GenerateCode",
|
||||
ProjectPath = "samples/03-workflows/Declarative/GenerateCode",
|
||||
IsDeterministic = true,
|
||||
MustContain = ["WORKFLOW: Parsing", "WORKFLOW: Defined"],
|
||||
ExpectedOutputDescription = ["The output should show a YAML workflow being parsed and C# code being generated from it."],
|
||||
},
|
||||
|
||||
new SampleDefinition
|
||||
{
|
||||
Name = "Workflow_Declarative_HostedWorkflow",
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<!-- Central version prefix - applies to all nuget packages. -->
|
||||
<VersionPrefix>1.6.2</VersionPrefix>
|
||||
<VersionPrefix>1.7.0</VersionPrefix>
|
||||
<RCNumber>1</RCNumber>
|
||||
<DateSuffix>260521</DateSuffix>
|
||||
<DateSuffix>260526</DateSuffix>
|
||||
<PackageVersion Condition="'$(IsReleaseCandidate)' == 'true'">$(VersionPrefix)-rc$(RCNumber)</PackageVersion>
|
||||
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).$(DateSuffix).1</PackageVersion>
|
||||
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' == ''">$(VersionPrefix)-preview.$(DateSuffix).1</PackageVersion>
|
||||
<PackageVersion Condition="'$(IsReleased)' == 'true'">$(VersionPrefix)</PackageVersion>
|
||||
<GitTag>1.6.2</GitTag>
|
||||
<GitTag>1.7.0</GitTag>
|
||||
|
||||
<Configurations>Debug;Release;Publish</Configurations>
|
||||
<IsPackable>true</IsPackable>
|
||||
|
||||
@@ -24,6 +24,7 @@ string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYM
|
||||
var skillsProvider = new AgentSkillsProvider(
|
||||
Path.Combine(AppContext.BaseDirectory, "skills"),
|
||||
SubprocessScriptRunner.RunAsync);
|
||||
|
||||
// --- Agent Setup ---
|
||||
AIAgent agent = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential())
|
||||
.GetResponsesClient()
|
||||
|
||||
@@ -51,7 +51,7 @@ Console.WriteLine($"Agent: {response.Text}");
|
||||
/// Properties annotated with <see cref="AgentSkillResourceAttribute"/> are automatically
|
||||
/// discovered as skill resources, and methods annotated with <see cref="AgentSkillScriptAttribute"/>
|
||||
/// are automatically discovered as skill scripts. Alternatively,
|
||||
/// <see cref="AgentSkill.Resources"/> and <see cref="AgentSkill.Scripts"/> can be overridden.
|
||||
/// <see cref="AgentClassSkill{TSelf}.Resources"/> and <see cref="AgentClassSkill{TSelf}.Scripts"/> can be overridden.
|
||||
/// </remarks>
|
||||
internal sealed class UnitConverterSkill : AgentClassSkill<UnitConverterSkill>
|
||||
{
|
||||
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<NoWarn>$(NoWarn);MAAI001;MCPEXP001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||
<PackageReference Include="ModelContextProtocol" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Mcp\Microsoft.Agents.AI.Mcp.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,142 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample demonstrates how to discover Agent Skills served over MCP.
|
||||
//
|
||||
// When launched with "--server", this executable runs a small MCP stdio server
|
||||
// that exposes a unit-converter skill via the SEP-2640 convention:
|
||||
// - skill://index.json — discovery document listing all skills
|
||||
// - skill://unit-converter/SKILL.md — the skill instructions
|
||||
//
|
||||
// In default (client) mode the sample launches itself as a child process,
|
||||
// connects via StdioClientTransport, and uses AgentSkillsProviderBuilder
|
||||
// to discover and inject the skill into a ChatClientAgent.
|
||||
|
||||
using System.ComponentModel;
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ModelContextProtocol.Client;
|
||||
using ModelContextProtocol.Server;
|
||||
using OpenAI.Responses;
|
||||
|
||||
if (args.Length > 0 && args[0] == "--server")
|
||||
{
|
||||
await RunMcpServerAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Configuration ---
|
||||
string openAiEndpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT")
|
||||
?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini";
|
||||
|
||||
// --- MCP client + skill discovery ---
|
||||
// Launch this same assembly as a stdio MCP server in a child process.
|
||||
var thisAssemblyPath = typeof(Program).Assembly.Location;
|
||||
Console.WriteLine("Discovering MCP-based skills");
|
||||
|
||||
await using McpClient client = await McpClient.CreateAsync(
|
||||
new StdioClientTransport(new()
|
||||
{
|
||||
Name = "skills-server",
|
||||
Command = "dotnet",
|
||||
Arguments = [thisAssemblyPath, "--server"],
|
||||
}));
|
||||
|
||||
var skillsProvider = new AgentSkillsProviderBuilder()
|
||||
.UseMcpSkills(client)
|
||||
.Build();
|
||||
|
||||
// --- Agent ---
|
||||
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
|
||||
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
|
||||
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
|
||||
AIAgent agent = new AzureOpenAIClient(new Uri(openAiEndpoint), new DefaultAzureCredential())
|
||||
.GetResponsesClient()
|
||||
.AsAIAgent(new ChatClientAgentOptions
|
||||
{
|
||||
Name = "SkillsAgent",
|
||||
ChatOptions = new()
|
||||
{
|
||||
Instructions = "You are a helpful assistant. Use available skills to answer the user.",
|
||||
},
|
||||
AIContextProviders = [skillsProvider],
|
||||
},
|
||||
model: deploymentName);
|
||||
|
||||
// --- Run ---
|
||||
Console.WriteLine(new string('-', 60));
|
||||
|
||||
AgentResponse response = await agent.RunAsync(
|
||||
"How many kilometers is a marathon (26.2 miles)? And how many pounds is 75 kilograms?");
|
||||
|
||||
Console.WriteLine($"Agent: {response.Text}");
|
||||
|
||||
// --- Server mode (launched as a child process via --server) ---------------------------------
|
||||
static async Task RunMcpServerAsync()
|
||||
{
|
||||
var builder = Host.CreateApplicationBuilder();
|
||||
|
||||
// Critical for stdio transport: any provider that writes to stdout will corrupt the
|
||||
// JSON-RPC channel. Clear all providers; the MCP SDK routes its own diagnostics
|
||||
// appropriately.
|
||||
builder.Logging.ClearProviders();
|
||||
builder.Logging.AddConsole(o => o.LogToStandardErrorThreshold = LogLevel.Trace);
|
||||
|
||||
builder.Services.AddMcpServer(o => o.ServerInfo = new() { Name = "SkillsServer", Version = "1.0.0" })
|
||||
.WithStdioServerTransport()
|
||||
.WithResources<SkillResources>();
|
||||
|
||||
await builder.Build().RunAsync();
|
||||
}
|
||||
|
||||
#pragma warning disable CA1812 // Discovered by MCP SDK via [McpServerResourceType] attribute
|
||||
[McpServerResourceType]
|
||||
internal sealed class SkillResources
|
||||
#pragma warning restore CA1812
|
||||
{
|
||||
private const string IndexJson = """
|
||||
{
|
||||
"$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
|
||||
"skills": [
|
||||
{
|
||||
"name": "unit-converter",
|
||||
"type": "skill-md",
|
||||
"description": "Convert between common units using a multiplication factor. Use when asked to convert miles, kilometers, pounds, or kilograms.",
|
||||
"url": "skill://unit-converter/SKILL.md"
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
private const string SkillMd = """
|
||||
---
|
||||
name: unit-converter
|
||||
description: Convert between common units using a multiplication factor. Use when asked to convert miles, kilometers, pounds, or kilograms.
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
When the user requests a unit conversion, use these factors:
|
||||
|
||||
| From | To | Factor |
|
||||
|-------------|-------------|----------|
|
||||
| miles | kilometers | 1.60934 |
|
||||
| kilometers | miles | 0.621371 |
|
||||
| pounds | kilograms | 0.453592 |
|
||||
| kilograms | pounds | 2.20462 |
|
||||
|
||||
Formula: result = value × factor
|
||||
""";
|
||||
|
||||
[McpServerResource(UriTemplate = "skill://index.json", Name = "Skill Index", MimeType = "application/json")]
|
||||
[Description("SEP-2640 skill discovery index")]
|
||||
public static string GetIndex() => IndexJson;
|
||||
|
||||
[McpServerResource(UriTemplate = "skill://unit-converter/SKILL.md", Name = "Unit Converter Skill", MimeType = "text/markdown")]
|
||||
[Description("Unit converter skill instructions")]
|
||||
public static string GetSkillMd() => SkillMd;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
# MCP-Based Agent Skills Sample
|
||||
|
||||
This sample demonstrates how to discover **Agent Skills served over MCP** with a `ChatClientAgent`.
|
||||
|
||||
## What it demonstrates
|
||||
|
||||
- Hosting a small MCP server (in this same executable, launched with `--server`) that
|
||||
exposes skill resources following the SEP-2640 convention.
|
||||
- Connecting an `McpClient` to the embedded server via stdio transport.
|
||||
- Building an `AgentSkillsProvider` via `UseMcpSkills(client)`, which reads
|
||||
`skill://index.json` (SEP-2640 canonical discovery) and constructs skills from the
|
||||
index entries.
|
||||
- The progressive disclosure pattern across MCP: advertise → load → read resources, exactly
|
||||
as for filesystem-backed skills.
|
||||
|
||||
## Running the Sample
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- .NET 10.0 SDK
|
||||
- Azure OpenAI endpoint with a deployed model
|
||||
|
||||
### Setup
|
||||
|
||||
```powershell
|
||||
$env:AZURE_OPENAI_ENDPOINT="https://your-endpoint.openai.azure.com/"
|
||||
$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-5.4-mini"
|
||||
```
|
||||
|
||||
### Run
|
||||
|
||||
```powershell
|
||||
dotnet run
|
||||
```
|
||||
@@ -9,6 +9,7 @@ Samples demonstrating Agent Skills capabilities. Each sample shows a different w
|
||||
| [Agent_Step03_ClassBasedSkills](Agent_Step03_ClassBasedSkills/) | Define skills as C# classes using `AgentClassSkill`. |
|
||||
| [Agent_Step04_MixedSkills](Agent_Step04_MixedSkills/) | **(Advanced)** Combine file-based, code-defined, and class-based skills using `AgentSkillsProviderBuilder`. |
|
||||
| [Agent_Step05_SkillsWithDI](Agent_Step05_SkillsWithDI/) | Use Dependency Injection with both code-defined (`AgentInlineSkill`) and class-based (`AgentClassSkill`) skills. |
|
||||
| [Agent_Step06_McpBasedSkills](Agent_Step06_McpBasedSkills/) | Discover skills served over the [Model Context Protocol (MCP)](https://modelcontextprotocol.io) via `AgentMcpSkillsSource`. Spins up an in-process MCP server that exposes skills as resources (`skill://...`) and connects an `McpClient` to it. |
|
||||
|
||||
## Key Concepts
|
||||
|
||||
|
||||
@@ -40,8 +40,8 @@ public class ListSelection : ConsoleReactiveComponent<ListSelectionProps, Consol
|
||||
foreach (string line in props.Title.Split('\n'))
|
||||
{
|
||||
Console.Write(AnsiEscapes.MoveCursor(props.Y + row, props.X));
|
||||
Console.Write(AnsiEscapes.EraseEntireLine);
|
||||
Console.Write(line);
|
||||
Console.Write(AnsiEscapes.EraseToEndOfLine);
|
||||
row++;
|
||||
}
|
||||
}
|
||||
@@ -52,7 +52,6 @@ public class ListSelection : ConsoleReactiveComponent<ListSelectionProps, Consol
|
||||
for (int i = 0; i < totalItems; i++)
|
||||
{
|
||||
Console.Write(AnsiEscapes.MoveCursor(props.Y + row, props.X));
|
||||
Console.Write(AnsiEscapes.EraseEntireLine);
|
||||
|
||||
bool isSelected = i == props.SelectedIndex;
|
||||
bool isCustomTextOption = props.CustomTextPlaceholder != null && i == props.Items.Count;
|
||||
@@ -72,6 +71,7 @@ public class ListSelection : ConsoleReactiveComponent<ListSelectionProps, Consol
|
||||
}
|
||||
|
||||
Console.Write(props.Items[i]);
|
||||
Console.Write(AnsiEscapes.EraseToEndOfLine);
|
||||
|
||||
if (isSelected)
|
||||
{
|
||||
@@ -101,6 +101,7 @@ public class ListSelection : ConsoleReactiveComponent<ListSelectionProps, Consol
|
||||
}
|
||||
|
||||
Console.Write(props.CustomText);
|
||||
Console.Write(AnsiEscapes.EraseToEndOfLine);
|
||||
|
||||
if (isSelected)
|
||||
{
|
||||
@@ -121,6 +122,7 @@ public class ListSelection : ConsoleReactiveComponent<ListSelectionProps, Consol
|
||||
|
||||
Console.Write(" ");
|
||||
Console.Write(props.CustomTextPlaceholder);
|
||||
Console.Write(AnsiEscapes.EraseToEndOfLine);
|
||||
Console.Write(AnsiEscapes.ResetAttributes);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,16 +17,19 @@ public record TextScrollPanelProps : ConsoleReactiveProps
|
||||
/// <summary>
|
||||
/// State for <see cref="TextScrollPanel"/>.
|
||||
/// </summary>
|
||||
/// <param name="RenderedCount">The number of items already rendered.</param>
|
||||
public record TextScrollPanelState(int RenderedCount = 0) : ConsoleReactiveState;
|
||||
public record TextScrollPanelState : ConsoleReactiveState;
|
||||
|
||||
/// <summary>
|
||||
/// A component that renders pre-rendered string items within a scroll area.
|
||||
/// All items are considered finalized — only new items since the last render are output.
|
||||
/// Use <see cref="Reset"/> to force a full re-render.
|
||||
/// The last rendered item is considered dynamic and will be re-rendered on each call.
|
||||
/// All prior items are considered finalized and are not re-rendered.
|
||||
/// Use <see cref="Invalidate"/> to force a full re-render.
|
||||
/// </summary>
|
||||
public class TextScrollPanel : ConsoleReactiveComponent<TextScrollPanelProps, TextScrollPanelState>
|
||||
{
|
||||
private int _renderedCount;
|
||||
private int _lastItemOffsetFromBottom;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TextScrollPanel"/> class.
|
||||
/// </summary>
|
||||
@@ -35,12 +38,12 @@ public class TextScrollPanel : ConsoleReactiveComponent<TextScrollPanelProps, Te
|
||||
this.State = new TextScrollPanelState();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resets the panel so all items will be re-rendered on the next Render call.
|
||||
/// </summary>
|
||||
public void Reset()
|
||||
/// <inheritdoc />
|
||||
public override void Invalidate()
|
||||
{
|
||||
this.State = new TextScrollPanelState();
|
||||
this._renderedCount = 0;
|
||||
this._lastItemOffsetFromBottom = 0;
|
||||
base.Invalidate();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -51,16 +54,59 @@ public class TextScrollPanel : ConsoleReactiveComponent<TextScrollPanelProps, Te
|
||||
return;
|
||||
}
|
||||
|
||||
// Move cursor to the bottom of the scroll area
|
||||
Console.Write(AnsiEscapes.MoveCursor(props.Y + props.Height - 1, props.X));
|
||||
int bottomRow = props.Y + props.Height - 1;
|
||||
|
||||
// Output only new items since last rendered
|
||||
for (int i = state.RenderedCount; i < props.Items.Count; i++)
|
||||
// Determine the first item to render. If we previously rendered items,
|
||||
// re-render the last one (it may have changed/grown) from its stored position.
|
||||
int startIndex = this._renderedCount > 0 ? this._renderedCount - 1 : 0;
|
||||
|
||||
if (this._renderedCount > 0 && this._lastItemOffsetFromBottom > 0)
|
||||
{
|
||||
// Reposition cursor to where the last rendered item began
|
||||
Console.Write(AnsiEscapes.MoveCursor(bottomRow - this._lastItemOffsetFromBottom, props.X));
|
||||
}
|
||||
else
|
||||
{
|
||||
// First render — position at the bottom of the scroll area
|
||||
Console.Write(AnsiEscapes.MoveCursor(bottomRow, props.X));
|
||||
}
|
||||
|
||||
// Render from startIndex onwards
|
||||
for (int i = startIndex; i < props.Items.Count; i++)
|
||||
{
|
||||
Console.Write(props.Items[i]);
|
||||
}
|
||||
|
||||
// Update state to track what we've rendered
|
||||
this.State = new TextScrollPanelState(props.Items.Count);
|
||||
// Calculate the offset from bottom for the start of the new last item
|
||||
int lastItemLines = CountLines(props.Items[^1]);
|
||||
this._lastItemOffsetFromBottom = lastItemLines > 0 ? lastItemLines - 1 : 0;
|
||||
|
||||
// Update rendered count
|
||||
this._renderedCount = props.Items.Count;
|
||||
}
|
||||
|
||||
private static int CountLines(string text)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
int count = 1;
|
||||
for (int i = 0; i < text.Length; i++)
|
||||
{
|
||||
if (text[i] == '\n')
|
||||
{
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
// If text ends with a newline, don't count the trailing empty line
|
||||
if (text[text.Length - 1] == '\n')
|
||||
{
|
||||
count--;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Harness.ConsoleReactiveFramework;
|
||||
|
||||
/// <summary>
|
||||
/// Caches the result of a mapping function and only recomputes when the input changes.
|
||||
/// </summary>
|
||||
/// <typeparam name="TInput">The type of the input value.</typeparam>
|
||||
/// <typeparam name="TOutput">The type of the mapped output value.</typeparam>
|
||||
public class ConsoleReactiveMemo<TInput, TOutput>
|
||||
{
|
||||
private TInput? _previousInput;
|
||||
private TOutput? _cachedOutput;
|
||||
private bool _hasValue;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the cached output if <paramref name="input"/> equals the previously stored input;
|
||||
/// otherwise invokes <paramref name="mapper"/> to compute and cache a new output.
|
||||
/// </summary>
|
||||
/// <param name="input">The current input value.</param>
|
||||
/// <param name="mapper">A function that maps the input to an output value.</param>
|
||||
/// <returns>The cached or newly computed output.</returns>
|
||||
public TOutput Map(TInput input, Func<TInput, TOutput> mapper)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(mapper);
|
||||
|
||||
if (!this._hasValue || !EqualityComparer<TInput>.Default.Equals(input, this._previousInput))
|
||||
{
|
||||
this._previousInput = input;
|
||||
this._cachedOutput = mapper(input);
|
||||
this._hasValue = true;
|
||||
}
|
||||
|
||||
return this._cachedOutput!;
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,6 @@ public class HarnessAppComponent : ConsoleReactiveComponent<ConsoleReactiveProps
|
||||
private readonly ListSelection _listSelection = new();
|
||||
private readonly TextInput _textInput = new();
|
||||
private readonly TextScrollPanel _textScrollPanel = new();
|
||||
private readonly TextPanel _textPanel = new();
|
||||
private readonly TextPanel _queuedPanel = new();
|
||||
private readonly AgentStatus _agentStatus = new();
|
||||
private readonly AgentModeAndHelp _modeAndHelp = new();
|
||||
@@ -341,16 +340,6 @@ public class HarnessAppComponent : ConsoleReactiveComponent<ConsoleReactiveProps
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine the text panel height for the last scroll item
|
||||
IReadOnlyList<string> lastItems = state.ScrollAreaContentItems.Count > 0
|
||||
? [state.ScrollAreaContentItems[^1]]
|
||||
: [];
|
||||
int textPanelHeight = TextPanel.CalculateHeight(lastItems);
|
||||
if (textPanelHeight > 0)
|
||||
{
|
||||
textPanelHeight++; // Extra line for spacing between text panel and rule
|
||||
}
|
||||
|
||||
// Calculate queued items panel height
|
||||
int queuedPanelHeight = TextPanel.CalculateHeight(state.QueuedItems);
|
||||
|
||||
@@ -444,7 +433,7 @@ public class HarnessAppComponent : ConsoleReactiveComponent<ConsoleReactiveProps
|
||||
int modeAndHelpHeight = showStatusAndHelp ? AgentModeAndHelp.CalculateHeight(modeAndHelpProps) : 0;
|
||||
|
||||
int ruleHeight = TopBottomRule.CalculateHeight(ruleProps);
|
||||
int nonScrollHeight = ruleHeight + textPanelHeight + agentStatusHeight + queuedPanelHeight + modeAndHelpHeight + 1; // +1 for bottom padding
|
||||
int nonScrollHeight = ruleHeight + agentStatusHeight + queuedPanelHeight + modeAndHelpHeight + 1; // +1 for bottom padding
|
||||
int scrollBottom = Math.Max(1, state.ConsoleHeight - nonScrollHeight);
|
||||
|
||||
// If scroll region changed or a clear is needed, reset everything
|
||||
@@ -455,52 +444,36 @@ public class HarnessAppComponent : ConsoleReactiveComponent<ConsoleReactiveProps
|
||||
System.Console.Write(AnsiEscapes.ResetScrollRegion);
|
||||
System.Console.Write(AnsiEscapes.EraseEntireScreen);
|
||||
System.Console.Write(AnsiEscapes.EraseScrollbackBuffer);
|
||||
this._textScrollPanel.Reset();
|
||||
this._resizedSinceLastRender = false;
|
||||
|
||||
// Invalidate all children so they re-render even if props haven't changed
|
||||
this._rule.Invalidate();
|
||||
this._textScrollPanel.Invalidate();
|
||||
this._textPanel.Invalidate();
|
||||
this._queuedPanel.Invalidate();
|
||||
this._agentStatus.Invalidate();
|
||||
this._modeAndHelp.Invalidate();
|
||||
this._textInput.Invalidate();
|
||||
this._listSelection.Invalidate();
|
||||
|
||||
this._resizedSinceLastRender = false;
|
||||
}
|
||||
|
||||
this._scrollRegionBottom = scrollBottom;
|
||||
|
||||
System.Console.Write(AnsiEscapes.SetScrollRegion(scrollBottom));
|
||||
|
||||
// Render text scroll panel in the scroll area (all items except the last)
|
||||
IReadOnlyList<string> scrollItems = state.ScrollAreaContentItems.Count > 1
|
||||
? state.ScrollAreaContentItems.Take(state.ScrollAreaContentItems.Count - 1).ToList()
|
||||
: [];
|
||||
|
||||
// Render text scroll panel in the scroll area
|
||||
this._textScrollPanel.Props = new TextScrollPanelProps
|
||||
{
|
||||
X = 1,
|
||||
Y = 1,
|
||||
Width = state.ConsoleWidth,
|
||||
Height = scrollBottom,
|
||||
Items = scrollItems,
|
||||
Items = state.ScrollAreaContentItems,
|
||||
};
|
||||
this._textScrollPanel.Render();
|
||||
|
||||
// Render the text panel for the last (dynamic) item just below the scroll region
|
||||
this._textPanel.Props = new TextPanelProps
|
||||
{
|
||||
X = 1,
|
||||
Y = scrollBottom + 1,
|
||||
Width = state.ConsoleWidth,
|
||||
Height = textPanelHeight,
|
||||
Items = lastItems,
|
||||
};
|
||||
this._textPanel.Render();
|
||||
|
||||
// Render queued input items between text panel and agent status
|
||||
int queuedPanelY = scrollBottom + textPanelHeight + 1;
|
||||
// Render queued input items between scroll area and agent status
|
||||
int queuedPanelY = scrollBottom + 1;
|
||||
this._queuedPanel.Props = new TextPanelProps
|
||||
{
|
||||
X = 1,
|
||||
|
||||
+3
-3
@@ -5,17 +5,17 @@ using Microsoft.Extensions.AI;
|
||||
namespace Harness.Shared.Console.ToolFormatters;
|
||||
|
||||
/// <summary>
|
||||
/// Formats <c>AgentMode_*</c> tool calls, showing the target mode for Set operations.
|
||||
/// Formats <c>mode_*</c> tool calls, showing the target mode for Set operations.
|
||||
/// </summary>
|
||||
public sealed class ModeToolFormatter : ToolCallFormatter
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override bool CanFormat(FunctionCallContent call) => call.Name.StartsWith("AgentMode_", StringComparison.Ordinal);
|
||||
public override bool CanFormat(FunctionCallContent call) => call.Name.StartsWith("mode_", StringComparison.Ordinal);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string? FormatDetail(FunctionCallContent call) => call.Name switch
|
||||
{
|
||||
"AgentMode_Set" => FormatStringArg(call, "mode"),
|
||||
"mode_set" => FormatStringArg(call, "mode"),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
|
||||
+5
-5
@@ -7,20 +7,20 @@ using Microsoft.Extensions.AI;
|
||||
namespace Harness.Shared.Console.ToolFormatters;
|
||||
|
||||
/// <summary>
|
||||
/// Formats <c>TodoList_*</c> tool calls with tree-view output for added items
|
||||
/// Formats <c>todos_*</c> tool calls with tree-view output for added items
|
||||
/// and structured output for complete/remove operations.
|
||||
/// </summary>
|
||||
public sealed class TodoToolFormatter : ToolCallFormatter
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override bool CanFormat(FunctionCallContent call) => call.Name.StartsWith("TodoList_", StringComparison.Ordinal);
|
||||
public override bool CanFormat(FunctionCallContent call) => call.Name.StartsWith("todos_", StringComparison.Ordinal);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string? FormatDetail(FunctionCallContent call) => call.Name switch
|
||||
{
|
||||
"TodoList_Add" => FormatAddTodos(call),
|
||||
"TodoList_Complete" => FormatCompleteTodos(call),
|
||||
"TodoList_Remove" => FormatIdList(call, "ids", "Remove"),
|
||||
"todos_add" => FormatAddTodos(call),
|
||||
"todos_complete" => FormatCompleteTodos(call),
|
||||
"todos_remove" => FormatIdList(call, "ids", "Remove"),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
AZURE_AI_PROJECT_ENDPOINT=<your-azure-ai-project-endpoint>
|
||||
ASPNETCORE_URLS=http://+:8088
|
||||
ASPNETCORE_ENVIRONMENT=Development
|
||||
AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o
|
||||
AGENT_NAME=hosted-agent-skills
|
||||
SKILL_NAMES=support-style,escalation-policy
|
||||
# Set to true to provision sample skills to Foundry on startup (first-run convenience).
|
||||
# In production, skills are provisioned externally — leave this unset or false.
|
||||
PROVISION_SAMPLE_SKILLS=true
|
||||
AZURE_BEARER_TOKEN=DefaultAzureCredential
|
||||
# When running outside the Foundry platform the platform-injected isolation keys are absent.
|
||||
# These two variables provide fallback values for local Docker debugging only.
|
||||
HOSTED_USER_ISOLATION_KEY=local-dev-user
|
||||
HOSTED_CHAT_ISOLATION_KEY=local-dev-chat
|
||||
@@ -0,0 +1,26 @@
|
||||
# Dockerfile for end-users consuming the Agent Framework via NuGet packages.
|
||||
#
|
||||
# This Dockerfile performs a full `dotnet restore` and `dotnet publish` inside the container,
|
||||
# which only succeeds when the project references its dependencies via PackageReference (see the
|
||||
# commented-out section in HostedAgentSkills.csproj). Contributors building from the
|
||||
# agent-framework repository source must use Dockerfile.contributor instead because
|
||||
# ProjectReference dependencies live outside this folder and cannot be restored from inside
|
||||
# this build context.
|
||||
#
|
||||
# Use the official .NET 10.0 ASP.NET runtime as a parent image
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
|
||||
WORKDIR /app
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
WORKDIR /src
|
||||
COPY . .
|
||||
RUN dotnet restore
|
||||
RUN dotnet publish -c Release -o /app/publish
|
||||
|
||||
# Final stage
|
||||
FROM base AS final
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/publish .
|
||||
EXPOSE 8088
|
||||
ENV ASPNETCORE_URLS=http://+:8088
|
||||
ENTRYPOINT ["dotnet", "HostedAgentSkills.dll"]
|
||||
+23
@@ -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"]
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
|
||||
<RootNamespace>HostedAgentSkills</RootNamespace>
|
||||
<AssemblyName>HostedAgentSkills</AssemblyName>
|
||||
<NoWarn>$(NoWarn);MEAI001;OPENAI001;AAIP001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="DotNetEnv" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- For contributors: uses ProjectReference to build against local source -->
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||
<ProjectReference Include="..\Hosted_Shared_Contributor_Setup\Hosted_Shared_Contributor_Setup.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReferences above
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Agents.AI" Version="1.6.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.6.1-preview.260514.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.6.1-preview.260514.1" />
|
||||
</ItemGroup>
|
||||
-->
|
||||
|
||||
<!-- Include the skills/ directory in the publish output so the sample can provision them -->
|
||||
<ItemGroup>
|
||||
<None Include="skills\**\*" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+215
@@ -0,0 +1,215 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// Hosted-AgentSkills
|
||||
//
|
||||
// Demonstrates how to host an agent that loads its behavioral guidelines from Foundry Skills at
|
||||
// startup. Skills are authored as SKILL.md files, uploaded to Foundry via the Skills REST API,
|
||||
// and downloaded by the agent on boot so guideline updates ship without code changes.
|
||||
//
|
||||
// The agent uses AgentSkillsProvider from the Agent Framework which implements the progressive
|
||||
// disclosure pattern from the Agent Skills specification (https://agentskills.io/):
|
||||
// 1. Advertise — skill names and descriptions are injected into the system prompt.
|
||||
// 2. Load — the model calls load_skill to retrieve the full SKILL.md body on demand.
|
||||
//
|
||||
// IMPORTANT: In production, skill provisioning (uploading SKILL.md files to Foundry) is an
|
||||
// external concern — it is NOT the hosted agent's responsibility. The provisioning helper below
|
||||
// is included for sample convenience only, so the sample is self-contained and runnable without
|
||||
// a separate setup step. A real deployment pipeline would provision skills separately (e.g., via
|
||||
// a CI/CD step, a CLI script, or a management portal).
|
||||
|
||||
#pragma warning disable AAIP001 // ProjectAgentSkills is experimental
|
||||
|
||||
using System.ClientModel;
|
||||
using System.IO.Compression;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.AI.Projects.Agents;
|
||||
using Azure.Core;
|
||||
using Azure.Identity;
|
||||
using DotNetEnv;
|
||||
using Hosted_Shared_Contributor_Setup;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
// Load .env file if present (for local development)
|
||||
Env.TraversePath().Load();
|
||||
|
||||
string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
|
||||
?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
|
||||
string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o";
|
||||
string skillNames = Environment.GetEnvironmentVariable("SKILL_NAMES")
|
||||
?? throw new InvalidOperationException("SKILL_NAMES is not set. Provide a comma-separated list of skill names (e.g., support-style,escalation-policy).");
|
||||
|
||||
string[] requestedSkills = skillNames.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (requestedSkills.Length == 0)
|
||||
{
|
||||
throw new InvalidOperationException("SKILL_NAMES must list at least one skill name.");
|
||||
}
|
||||
|
||||
// Validate skill names to prevent path traversal.
|
||||
foreach (string name in requestedSkills)
|
||||
{
|
||||
if (name.Contains('.') || name.Contains('/') || name.Contains('\\') || Path.IsPathRooted(name))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Invalid skill name '{name}': skill names must not contain path separators or dots.");
|
||||
}
|
||||
}
|
||||
|
||||
// Use a chained credential: try a temporary dev token first (for local Docker debugging),
|
||||
// then fall back to DefaultAzureCredential (for local dev via dotnet run / managed identity in production).
|
||||
TokenCredential credential = new ChainedTokenCredential(
|
||||
new DevTemporaryTokenCredential(),
|
||||
new DefaultAzureCredential());
|
||||
|
||||
AIProjectClient projectClient = new(new Uri(endpoint), credential);
|
||||
ProjectAgentSkills skillsClient = projectClient.AgentAdministrationClient.GetAgentSkills();
|
||||
|
||||
// ── Provision skills (sample convenience only — NOT a production pattern) ─────
|
||||
// In production, skills are provisioned externally (e.g., via CI/CD or a management script).
|
||||
// This helper ensures the sample's SKILL.md files exist in Foundry so the sample is runnable
|
||||
// out of the box without a separate setup step. Set PROVISION_SAMPLE_SKILLS=true to enable.
|
||||
string sourceSkillsDir = Path.Combine(AppContext.BaseDirectory, "skills");
|
||||
bool provisionEnabled = string.Equals(
|
||||
Environment.GetEnvironmentVariable("PROVISION_SAMPLE_SKILLS"), "true", StringComparison.OrdinalIgnoreCase);
|
||||
if (provisionEnabled && Directory.Exists(sourceSkillsDir))
|
||||
{
|
||||
await EnsureSkillsProvisionedAsync(skillsClient, sourceSkillsDir, requestedSkills);
|
||||
}
|
||||
|
||||
// ── Download skills from Foundry ─────────────────────────────────────────────
|
||||
// Pull the latest copy of each skill from Foundry into a runtime-only folder.
|
||||
// This directory is recreated on every startup so the agent always picks up
|
||||
// the latest version of each skill.
|
||||
string downloadedSkillsDir = Path.Combine(AppContext.BaseDirectory, "downloaded_skills");
|
||||
await DownloadSkillsAsync(skillsClient, requestedSkills, downloadedSkillsDir);
|
||||
|
||||
// ── Wire skills into the agent ───────────────────────────────────────────────
|
||||
// AgentSkillsProvider implements progressive disclosure: skill names and descriptions
|
||||
// are advertised in the system prompt (~100 tokens per skill), and the full SKILL.md
|
||||
// body is loaded on demand when the model calls the load_skill tool.
|
||||
AgentSkillsProvider skillsProvider = new(downloadedSkillsDir);
|
||||
|
||||
ChatClientAgent agent = projectClient.AsAIAgent(new ChatClientAgentOptions
|
||||
{
|
||||
Name = Environment.GetEnvironmentVariable("AGENT_NAME") ?? "hosted-agent-skills",
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
ModelId = deploymentName,
|
||||
Instructions = "You are a customer-support assistant for Contoso Outdoors.",
|
||||
},
|
||||
AIContextProviders = [skillsProvider]
|
||||
});
|
||||
|
||||
// Host the agent as a Foundry Hosted Agent using the Responses API.
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddFoundryResponses(agent);
|
||||
builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debugging only - must not be used in production.
|
||||
|
||||
var app = builder.Build();
|
||||
app.MapFoundryResponses();
|
||||
|
||||
// Contributor-only: in Development, also map the per-agent OpenAI route shape that live Foundry uses
|
||||
// so a local REPL client can target this server via AIProjectClient.AsAIAgent(Uri agentEndpoint).
|
||||
// Do not use this in production. Hosted Foundry agents only support the agent-endpoint path.
|
||||
app.MapDevTemporaryLocalAgentEndpoint();
|
||||
|
||||
app.Run();
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// Downloads each named skill from Foundry and extracts the ZIP archive into a
|
||||
// separate subdirectory under the target directory.
|
||||
static async Task DownloadSkillsAsync(ProjectAgentSkills skillsClient, string[] skillNames, string targetDir)
|
||||
{
|
||||
if (Directory.Exists(targetDir))
|
||||
{
|
||||
Directory.Delete(targetDir, recursive: true);
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(targetDir);
|
||||
|
||||
foreach (string name in skillNames)
|
||||
{
|
||||
Console.WriteLine($"Downloading skill '{name}' from Foundry...");
|
||||
BinaryData zipData = await skillsClient.DownloadSkillAsync(name);
|
||||
|
||||
string skillDir = Path.Combine(targetDir, name);
|
||||
Directory.CreateDirectory(skillDir);
|
||||
|
||||
using var zipStream = zipData.ToStream();
|
||||
using var archive = new ZipArchive(zipStream, ZipArchiveMode.Read);
|
||||
SafeExtractZip(archive, skillDir);
|
||||
|
||||
if (!File.Exists(Path.Combine(skillDir, "SKILL.md")))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Downloaded archive for '{name}' did not contain a SKILL.md at the root.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extracts a ZIP archive into a destination directory, rejecting entries that would
|
||||
// escape the target path (zip-slip guard).
|
||||
static void SafeExtractZip(ZipArchive archive, string destinationDir)
|
||||
{
|
||||
string destRoot = Path.GetFullPath(destinationDir);
|
||||
string destRootWithSep = Path.EndsInDirectorySeparator(destRoot)
|
||||
? destRoot
|
||||
: destRoot + Path.DirectorySeparatorChar;
|
||||
|
||||
// Use ordinal comparison on Unix (case-sensitive FS) and ordinal-ignore-case on Windows.
|
||||
var comparison = OperatingSystem.IsWindows()
|
||||
? StringComparison.OrdinalIgnoreCase
|
||||
: StringComparison.Ordinal;
|
||||
|
||||
foreach (ZipArchiveEntry entry in archive.Entries)
|
||||
{
|
||||
string entryPath = Path.GetFullPath(Path.Combine(destRoot, entry.FullName));
|
||||
if (!entryPath.StartsWith(destRootWithSep, comparison)
|
||||
&& !string.Equals(entryPath, destRoot, comparison))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Refusing to extract unsafe path '{entry.FullName}' outside of '{destRoot}'.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(entry.Name))
|
||||
{
|
||||
// Directory entry — ensure it exists.
|
||||
Directory.CreateDirectory(entryPath);
|
||||
}
|
||||
else
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(entryPath)!);
|
||||
entry.ExtractToFile(entryPath, overwrite: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ensures each requested skill is provisioned in Foundry. For each skill name, checks whether
|
||||
// the skill exists and uploads it from the local source directory if it does not.
|
||||
//
|
||||
// This is a sample convenience helper — in production, skill provisioning is an external concern.
|
||||
static async Task EnsureSkillsProvisionedAsync(ProjectAgentSkills skillsClient, string sourceDir, string[] skillNames)
|
||||
{
|
||||
foreach (string name in skillNames)
|
||||
{
|
||||
string skillPath = Path.Combine(sourceDir, name);
|
||||
if (!Directory.Exists(skillPath) || !File.Exists(Path.Combine(skillPath, "SKILL.md")))
|
||||
{
|
||||
continue; // No local source for this skill — skip provisioning.
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await skillsClient.GetSkillAsync(name);
|
||||
Console.WriteLine($"Skill '{name}' already exists in Foundry.");
|
||||
}
|
||||
catch (ClientResultException ex) when (ex.Status == 404)
|
||||
{
|
||||
Console.WriteLine($"Provisioning skill '{name}' from {skillPath}...");
|
||||
AgentsSkill imported = await skillsClient.CreateSkillFromPackageAsync(skillPath);
|
||||
Console.WriteLine($" Imported skill '{imported.Name}' (id={imported.SkillId}, has_blob={imported.HasBlob}).");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
# What this sample demonstrates
|
||||
|
||||
An [Agent Framework](https://github.com/microsoft/agent-framework) agent that loads its behavioral guidelines from [**Foundry Skills**](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/tools/skills) at startup, hosted using the **Responses protocol**. Skills are authored once as `SKILL.md` files, uploaded to your Foundry project through the Skills REST API, and downloaded by the agent on boot so updates ship without code changes.
|
||||
|
||||
## How It Works
|
||||
|
||||
### Authoring skills
|
||||
|
||||
Each skill is a Markdown file with a YAML front matter block. This sample ships two source skills under [`skills/`](skills/):
|
||||
|
||||
| Skill | Purpose |
|
||||
|---|---|
|
||||
| [`support-style`](skills/support-style/SKILL.md) | Voice, formatting, and signature rules for Contoso Outdoors support replies. |
|
||||
| [`escalation-policy`](skills/escalation-policy/SKILL.md) | When and how to escalate a customer ticket. |
|
||||
|
||||
Each `SKILL.md` includes a unique `*-CANARY-*` token that the model is asked to echo, so you can prove the skill was loaded from Foundry (not hallucinated) by checking the response.
|
||||
|
||||
> The `name` and `description` values in the YAML front matter must be **unquoted** — quoting them causes the Skills REST API to return HTTP 500 on import.
|
||||
|
||||
### Uploading skills
|
||||
|
||||
The sample includes a convenience provisioning step that checks whether each skill exists in Foundry and uploads it if not, gated behind the `PROVISION_SAMPLE_SKILLS=true` env var. **In production, skill provisioning is an external concern** — it is NOT the hosted agent's responsibility. A real deployment pipeline would provision skills separately (e.g., via a CI/CD step, a CLI script, or a management portal).
|
||||
|
||||
The provisioning uses `ProjectAgentSkills.CreateSkillFromPackageAsync(directoryPath)` from the `Azure.AI.Projects.Agents` SDK. The method packages the `SKILL.md` file as a ZIP and uploads it to Foundry.
|
||||
|
||||
### Downloading skills at agent startup
|
||||
|
||||
[`Program.cs`](Program.cs) reads the comma-separated `SKILL_NAMES` env var and for each skill name downloads the ZIP archive from Foundry via `ProjectAgentSkills.DownloadSkillAsync(name)`, then unpacks it into a **separate runtime directory** at `downloaded_skills/<name>/` (kept distinct from the static `skills/` source folder).
|
||||
|
||||
An [`AgentSkillsProvider`](../../../../../src/Microsoft.Agents.AI/Skills/AgentSkillsProvider.cs) is then built over `downloaded_skills/` and attached to the agent as a context provider. The provider follows the [Agent Skills](https://agentskills.io/) progressive-disclosure pattern:
|
||||
|
||||
1. **Advertise** — skill names and descriptions are injected into the system prompt at session start (~100 tokens per skill).
|
||||
2. **Load** — the model calls the `load_skill` tool when it decides a skill is relevant to the user's turn, and the full `SKILL.md` body is returned.
|
||||
|
||||
This means the model only pays the token cost for a skill's full body when it actually needs it, and updating a skill in Foundry + restarting the agent is enough to pick up the change — no code redeploy required.
|
||||
|
||||
> **Note:** This sample supports instruction-only and resource-based skills. If your downloaded skills contain scripts, add a script runner when constructing the `AgentSkillsProvider`.
|
||||
|
||||
### Agent Hosting
|
||||
|
||||
The agent is hosted using the [Agent Framework](https://github.com/microsoft/agent-framework) with the Responses API hosting layer (`AddFoundryResponses` / `MapFoundryResponses`).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- An Azure AI Foundry project with a deployed model (e.g., `gpt-4o`)
|
||||
- Azure CLI logged in (`az login`)
|
||||
|
||||
### Required RBAC
|
||||
|
||||
Your identity (or the Managed Identity running the container in production) needs **Azure AI User** on the Foundry project scope. This single role covers both authoring skills and downloading them.
|
||||
|
||||
## Running the Agent Host
|
||||
|
||||
Set the required environment variables and run the sample with `dotnet run`:
|
||||
|
||||
```bash
|
||||
export AZURE_AI_PROJECT_ENDPOINT="https://<account>.services.ai.azure.com/api/projects/<project>"
|
||||
export AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o"
|
||||
export SKILL_NAMES="support-style,escalation-policy"
|
||||
export PROVISION_SAMPLE_SKILLS="true" # First run only — provisions skills to Foundry
|
||||
```
|
||||
|
||||
Or in PowerShell:
|
||||
|
||||
```powershell
|
||||
$env:SKILL_NAMES="support-style,escalation-policy"
|
||||
$env:PROVISION_SAMPLE_SKILLS="true" # First run only — provisions skills to Foundry
|
||||
```
|
||||
|
||||
You can also place these in a `.env` file next to `Program.cs` — see [`.env.example`](.env.example).
|
||||
|
||||
On startup you should see:
|
||||
|
||||
```text
|
||||
Skill 'support-style' already exists in Foundry.
|
||||
Skill 'escalation-policy' already exists in Foundry.
|
||||
Downloading skill 'support-style' from Foundry...
|
||||
Downloading skill 'escalation-policy' from Foundry...
|
||||
```
|
||||
|
||||
The downloaded `SKILL.md` files land under `downloaded_skills/<name>/SKILL.md` next to the published output. This directory is recreated from scratch on every run, so deleting it manually is never necessary.
|
||||
|
||||
## Interacting with the agent
|
||||
|
||||
> Send a POST request to the server with a JSON body containing an `"input"` field to interact with the agent. For example:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8088/responses -H "Content-Type: application/json" -d '{"input": "Hi, I am Alex. I just want to confirm I can return my tent within 30 days."}'
|
||||
curl -X POST http://localhost:8088/responses -H "Content-Type: application/json" -d '{"input": "I want a $750 refund on Order #A-1042 right now or I am calling my lawyer."}'
|
||||
```
|
||||
|
||||
| Prompt mentions | Skill that should drive the response |
|
||||
|---|---|
|
||||
| Routine return / shipping / care question | Model loads `support-style` (canary `STYLE-CANARY-3318`) — no escalation. |
|
||||
| Injury, legal threat, press, or refund > $500 | Model loads `escalation-policy` (canary `ESC-CANARY-7742`) **and** `support-style`. |
|
||||
|
||||
Because skills are loaded on demand, the canary token in a response also proves the model actually invoked `load_skill` for the matching skill (not just saw its name in the advertised list).
|
||||
|
||||
## Deploying the Agent to Foundry
|
||||
|
||||
When deploying to Foundry, make sure `SKILL_NAMES` is set in your `azd` environment so it gets injected into the hosted container per [`agent.manifest.yaml`](agent.manifest.yaml):
|
||||
|
||||
```bash
|
||||
azd env set SKILL_NAMES "support-style,escalation-policy"
|
||||
```
|
||||
|
||||
The deployed agent's Managed Identity needs **Azure AI User** on the Foundry project to download skills at startup.
|
||||
|
||||
> The `skills/` source folder is **not** deployed to Foundry — only the downloaded skills are used at runtime. The provisioning step must have been run against the same Foundry project before the agent can download the skills.
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml
|
||||
name: hosted-agent-skills
|
||||
displayName: "Hosted Agent Skills"
|
||||
|
||||
description: >
|
||||
An Agent Framework agent that downloads its behavioral guidelines from the Foundry
|
||||
Skills REST API at startup, demonstrating how to decouple behavioral guidelines
|
||||
(tone, escalation policy, etc.) from agent code using AgentSkillsProvider.
|
||||
|
||||
metadata:
|
||||
tags:
|
||||
- AI Agent Hosting
|
||||
- Azure AI AgentServer
|
||||
- Responses Protocol
|
||||
- Agent Framework
|
||||
- Agent Skills
|
||||
- Foundry Skills
|
||||
|
||||
template:
|
||||
name: hosted-agent-skills
|
||||
kind: hosted
|
||||
protocols:
|
||||
- protocol: responses
|
||||
version: 1.0.0
|
||||
resources:
|
||||
cpu: "0.25"
|
||||
memory: 0.5Gi
|
||||
environment_variables:
|
||||
- name: AZURE_AI_MODEL_DEPLOYMENT_NAME
|
||||
value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}"
|
||||
- name: SKILL_NAMES
|
||||
value: "{{SKILL_NAMES}}"
|
||||
parameters:
|
||||
properties:
|
||||
- name: SKILL_NAMES
|
||||
secret: false
|
||||
description: Comma-separated list of Foundry skill names to download at startup (e.g., support-style,escalation-policy)
|
||||
resources:
|
||||
- kind: model
|
||||
id: gpt-4.1-mini
|
||||
name: AZURE_AI_MODEL_DEPLOYMENT_NAME
|
||||
@@ -0,0 +1,14 @@
|
||||
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml
|
||||
kind: hosted
|
||||
name: hosted-agent-skills
|
||||
protocols:
|
||||
- protocol: responses
|
||||
version: 1.0.0
|
||||
resources:
|
||||
cpu: "0.25"
|
||||
memory: 0.5Gi
|
||||
environment_variables:
|
||||
- name: AZURE_AI_MODEL_DEPLOYMENT_NAME
|
||||
value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME}
|
||||
- name: SKILL_NAMES
|
||||
value: ${SKILL_NAMES}
|
||||
+100
@@ -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
|
||||
}
|
||||
+30
@@ -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.
|
||||
+25
@@ -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 #<id>` (e.g. `Order #A-1042`).
|
||||
|
||||
## Canary
|
||||
|
||||
To prove this skill was loaded, include the token `STYLE-CANARY-3318` on a
|
||||
separate line at the bottom of every response, prefixed with `# `.
|
||||
+3
-3
@@ -27,9 +27,9 @@
|
||||
|
||||
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReferences above
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.6.1-preview.260514.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.6.1-preview.260514.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.6.1" />
|
||||
</ItemGroup>
|
||||
-->
|
||||
|
||||
|
||||
+2
-2
@@ -23,8 +23,8 @@
|
||||
|
||||
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReference above
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.6.1-preview.260514.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.6.1-preview.260514.1" />
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
</ItemGroup>
|
||||
|
||||
+2
-2
@@ -33,8 +33,8 @@
|
||||
|
||||
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReference above
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.6.1-preview.260514.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.6.1-preview.260514.1" />
|
||||
</ItemGroup>
|
||||
-->
|
||||
|
||||
|
||||
+2
-2
@@ -23,8 +23,8 @@
|
||||
|
||||
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReference above
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.6.1-preview.260514.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.6.1-preview.260514.1" />
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
</ItemGroup>
|
||||
|
||||
+2
-2
@@ -25,8 +25,8 @@
|
||||
|
||||
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReference above
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.6.1-preview.260514.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.6.1-preview.260514.1" />
|
||||
</ItemGroup>
|
||||
-->
|
||||
|
||||
|
||||
+2
-2
@@ -26,8 +26,8 @@
|
||||
|
||||
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReference above
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.6.1-preview.260514.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.6.1-preview.260514.1" />
|
||||
</ItemGroup>
|
||||
-->
|
||||
|
||||
|
||||
+2
-2
@@ -25,8 +25,8 @@
|
||||
|
||||
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReferences above
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.6.1-preview.260514.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.6.1-preview.260514.1" />
|
||||
</ItemGroup>
|
||||
-->
|
||||
|
||||
|
||||
+2
-2
@@ -25,8 +25,8 @@
|
||||
|
||||
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReference above
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.6.1-preview.260514.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.6.1-preview.260514.1" />
|
||||
</ItemGroup>
|
||||
-->
|
||||
|
||||
|
||||
+3
-3
@@ -26,9 +26,9 @@
|
||||
|
||||
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReferences above
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.6.1-preview.260514.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.6.1-preview.260514.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.6.1" />
|
||||
</ItemGroup>
|
||||
-->
|
||||
|
||||
|
||||
+2
-2
@@ -25,8 +25,8 @@
|
||||
|
||||
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReference above
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.6.1-preview.260514.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.6.1-preview.260514.1" />
|
||||
</ItemGroup>
|
||||
-->
|
||||
|
||||
|
||||
+5
-5
@@ -32,11 +32,11 @@
|
||||
|
||||
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReference above
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Hosting" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.OpenAI" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Workflows" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.6.1-preview.260514.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.6.1-preview.260514.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Hosting" Version="1.6.1-preview.260514.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.6.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Workflows" Version="1.6.1" />
|
||||
</ItemGroup>
|
||||
-->
|
||||
|
||||
|
||||
+4
-4
@@ -27,10 +27,10 @@
|
||||
|
||||
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReferences above
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Workflows" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.6.1-preview.260514.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.6.1-preview.260514.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.6.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Workflows" Version="1.6.1" />
|
||||
</ItemGroup>
|
||||
-->
|
||||
|
||||
|
||||
@@ -20,8 +20,28 @@ internal static class AGUIChatMessageExtensions
|
||||
this IEnumerable<AGUIMessage> aguiMessages,
|
||||
JsonSerializerOptions jsonSerializerOptions)
|
||||
{
|
||||
// Coalesce consecutive AGUIAssistantMessages that carry tool_calls into a single
|
||||
// ChatMessage. The AG-UI client (e.g. @ag-ui/client) creates a separate assistant
|
||||
// message per tool call when ToolCallStartEvent.parentMessageId is empty, but
|
||||
// OpenAI's chat-completion API requires every assistant message with tool_calls
|
||||
// to be IMMEDIATELY followed by tool responses for each of its tool_call_ids.
|
||||
// Sending two consecutive single-tool-call assistant messages before any tool
|
||||
// result triggers HTTP 400 "tool_call_ids did not have response messages".
|
||||
List<AIContent>? pendingContents = null;
|
||||
string? pendingId = null;
|
||||
|
||||
foreach (var message in aguiMessages)
|
||||
{
|
||||
bool isAssistantWithToolCalls =
|
||||
message is AGUIAssistantMessage am && am.ToolCalls is { Length: > 0 };
|
||||
|
||||
if (pendingContents is not null && !isAssistantWithToolCalls)
|
||||
{
|
||||
yield return new ChatMessage(ChatRole.Assistant, pendingContents) { MessageId = pendingId };
|
||||
pendingContents = null;
|
||||
pendingId = null;
|
||||
}
|
||||
|
||||
var role = MapChatRole(message.Role);
|
||||
|
||||
switch (message)
|
||||
@@ -84,14 +104,14 @@ internal static class AGUIChatMessageExtensions
|
||||
|
||||
case AGUIAssistantMessage assistantMessage when assistantMessage.ToolCalls is { Length: > 0 }:
|
||||
{
|
||||
var contents = new List<AIContent>();
|
||||
pendingContents ??= new List<AIContent>();
|
||||
pendingId ??= message.Id;
|
||||
|
||||
if (!string.IsNullOrEmpty(assistantMessage.Content))
|
||||
{
|
||||
contents.Add(new TextContent(assistantMessage.Content));
|
||||
pendingContents.Add(new TextContent(assistantMessage.Content));
|
||||
}
|
||||
|
||||
// Add tool calls
|
||||
foreach (var toolCall in assistantMessage.ToolCalls)
|
||||
{
|
||||
Dictionary<string, object?>? arguments = null;
|
||||
@@ -102,16 +122,12 @@ internal static class AGUIChatMessageExtensions
|
||||
jsonSerializerOptions.GetTypeInfo(typeof(Dictionary<string, object?>)));
|
||||
}
|
||||
|
||||
contents.Add(new FunctionCallContent(
|
||||
pendingContents.Add(new FunctionCallContent(
|
||||
toolCall.Id,
|
||||
toolCall.Function.Name,
|
||||
arguments));
|
||||
}
|
||||
|
||||
yield return new ChatMessage(role, contents)
|
||||
{
|
||||
MessageId = message.Id
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -134,6 +150,12 @@ internal static class AGUIChatMessageExtensions
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flush remaining pending assistant-tool-call entry at end of stream.
|
||||
if (pendingContents is not null)
|
||||
{
|
||||
yield return new ChatMessage(ChatRole.Assistant, pendingContents) { MessageId = pendingId };
|
||||
}
|
||||
}
|
||||
|
||||
public static IEnumerable<AGUIMessage> AsAGUIMessages(
|
||||
|
||||
@@ -448,24 +448,36 @@ internal static class ChatResponseUpdateAGUIExtensions
|
||||
};
|
||||
|
||||
string? currentMessageId = null;
|
||||
string? streamingMessageId = null;
|
||||
string? textStreamingFallback = null;
|
||||
bool textInFallback = false;
|
||||
string? currentReasoningBaseId = null;
|
||||
string? currentReasoningId = null;
|
||||
string? currentReasoningMessageId = null;
|
||||
await foreach (var chatResponse in updates.WithCancellation(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
// Generate a fallback MessageId when the provider doesn't supply one.
|
||||
// This ensures all AGUI events have a valid messageId regardless of agent type.
|
||||
if (string.IsNullOrWhiteSpace(chatResponse.MessageId))
|
||||
// The text-event surface (TextMessageStart/Content/End) requires a non-empty
|
||||
// MessageId to be valid AGUI. Generate a fallback scoped to a contiguous run of
|
||||
// null/empty-MessageId chunks (one logical text message). Leave the raw
|
||||
// chatResponse.MessageId untouched so the tool-call surface below uses the raw
|
||||
// provider value — collapsing parallel tool calls under a synthetic shared parent
|
||||
// would make the FE render them as one assistant-message bubble instead of
|
||||
// distinct rows.
|
||||
string? textMessageId = chatResponse.MessageId;
|
||||
if (string.IsNullOrWhiteSpace(textMessageId))
|
||||
{
|
||||
chatResponse.MessageId = ContainsToolResult(chatResponse)
|
||||
? Guid.NewGuid().ToString("N")
|
||||
: (streamingMessageId ??= Guid.NewGuid().ToString("N"));
|
||||
textStreamingFallback ??= Guid.NewGuid().ToString("N");
|
||||
textMessageId = textStreamingFallback;
|
||||
textInFallback = true;
|
||||
}
|
||||
else if (textInFallback)
|
||||
{
|
||||
textStreamingFallback = null;
|
||||
textInFallback = false;
|
||||
}
|
||||
|
||||
if (chatResponse is { Contents.Count: > 0 } &&
|
||||
chatResponse.Contents[0] is TextContent &&
|
||||
!string.Equals(currentMessageId, chatResponse.MessageId, StringComparison.Ordinal))
|
||||
!string.Equals(currentMessageId, textMessageId, StringComparison.Ordinal))
|
||||
{
|
||||
// Close any open reasoning block before opening a text message, so AG-UI
|
||||
// events are properly bracketed. MEAI providers share one MessageId across
|
||||
@@ -498,11 +510,11 @@ internal static class ChatResponseUpdateAGUIExtensions
|
||||
// Start the new message
|
||||
yield return new TextMessageStartEvent
|
||||
{
|
||||
MessageId = chatResponse.MessageId!,
|
||||
MessageId = textMessageId!,
|
||||
Role = chatResponse.Role!.Value.Value
|
||||
};
|
||||
|
||||
currentMessageId = chatResponse.MessageId;
|
||||
currentMessageId = textMessageId;
|
||||
}
|
||||
|
||||
// Emit text content if present
|
||||
@@ -577,9 +589,15 @@ internal static class ChatResponseUpdateAGUIExtensions
|
||||
currentReasoningMessageId = null;
|
||||
}
|
||||
|
||||
// Each tool result is a distinct tool-role message on the AGUI wire.
|
||||
// MEAI's FunctionInvokingChatClient shares one synthetic MessageId
|
||||
// across all FunctionResultContent items, but the FE keys messages
|
||||
// by id, so emitting them with the same id collapses them in React
|
||||
// reconciliation. Derive a unique, deterministic per-result id from
|
||||
// the (LLM-assigned) call id.
|
||||
yield return new ToolCallResultEvent
|
||||
{
|
||||
MessageId = chatResponse.MessageId,
|
||||
MessageId = $"result-{functionResultContent.CallId}",
|
||||
ToolCallId = functionResultContent.CallId,
|
||||
Content = SerializeResultContent(functionResultContent, jsonSerializerOptions) ?? "",
|
||||
Role = AGUIRoles.Tool
|
||||
@@ -674,7 +692,7 @@ internal static class ChatResponseUpdateAGUIExtensions
|
||||
// Text content event
|
||||
yield return new TextMessageContentEvent
|
||||
{
|
||||
MessageId = chatResponse.MessageId!,
|
||||
MessageId = textMessageId!,
|
||||
#if !NET
|
||||
Delta = Encoding.UTF8.GetString(dataContent.Data.ToArray())
|
||||
#else
|
||||
@@ -726,17 +744,4 @@ internal static class ChatResponseUpdateAGUIExtensions
|
||||
_ => JsonSerializer.Serialize(functionResultContent.Result, options.GetTypeInfo(functionResultContent.Result.GetType())),
|
||||
};
|
||||
}
|
||||
|
||||
private static bool ContainsToolResult(ChatResponseUpdate chatResponse)
|
||||
{
|
||||
foreach (AIContent content in chatResponse.Contents)
|
||||
{
|
||||
if (content is FunctionResultContent)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
## v1.0.0-preview.260219.1
|
||||
|
||||
- [BREAKING] Changed ChatHistory and AIContext Providers to have pipeline semantics ([#3806](https://github.com/microsoft/agent-framework/pull/3806))
|
||||
- Marked all `RunAsync<T>` overloads as `new`, added missing ones, and added support for primitives and arrays ([#3803](https://github.com/microsoft/agent-framework/pull/3803))
|
||||
- Marked all `RunAsync<T>` overloads as `new`, added missing ones, and added support for primitives and arrays #3803
|
||||
- Improve session cast error message quality and consistency ([#3973](https://github.com/microsoft/agent-framework/pull/3973))
|
||||
|
||||
## v1.0.0-preview.260212.1
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
@@ -13,7 +12,6 @@ using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Foundry;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
using OpenAI.Responses;
|
||||
|
||||
@@ -22,7 +20,6 @@ namespace Azure.AI.Projects;
|
||||
/// <summary>
|
||||
/// Provides extension methods for <see cref="AIProjectClient"/>.
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
|
||||
public static partial class AIProjectClientExtensions
|
||||
{
|
||||
/// <summary>
|
||||
@@ -374,6 +371,7 @@ public static partial class AIProjectClientExtensions
|
||||
if (agentDefinition is DeclarativeAgentDefinition { Tools: { Count: > 0 } definitionTools })
|
||||
{
|
||||
// Check if no tools were provided while the agent definition requires in-proc tools.
|
||||
#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
|
||||
if (requireInvocableTools && chatOptions?.Tools is not { Count: > 0 } && definitionTools.Any(t => t is FunctionTool))
|
||||
{
|
||||
throw new ArgumentException("The agent definition in-process tools must be provided in the extension method tools parameter.");
|
||||
@@ -406,6 +404,7 @@ public static partial class AIProjectClientExtensions
|
||||
|
||||
(agentTools ??= []).Add(responseTool.AsAITool());
|
||||
}
|
||||
#pragma warning restore OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
|
||||
|
||||
if (requireInvocableTools && missingTools is { Count: > 0 })
|
||||
{
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.Extensions.OpenAI;
|
||||
using Azure.AI.Projects.Agents;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry;
|
||||
@@ -17,7 +15,6 @@ namespace Microsoft.Agents.AI.Foundry;
|
||||
/// <c>to_prompt_agent(agent)</c> function for agents whose underlying chat client is a
|
||||
/// <see cref="FoundryChatClient"/>.
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
|
||||
public static class ChatClientAgentFoundryExtensions
|
||||
{
|
||||
/// <summary>
|
||||
|
||||
@@ -5,7 +5,6 @@ using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Azure.AI.Projects.Agents;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using OpenAI.Responses;
|
||||
|
||||
#pragma warning disable OPENAI001
|
||||
@@ -27,7 +26,6 @@ namespace Microsoft.Agents.AI.Foundry;
|
||||
/// <c>FoundryAITool.CreateOpenApiTool(definition)</c>
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
|
||||
public static class FoundryAITool
|
||||
{
|
||||
/// <summary>
|
||||
|
||||
@@ -4,14 +4,12 @@ using System;
|
||||
using System.ClientModel;
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.Extensions.OpenAI;
|
||||
using Azure.AI.Projects;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry;
|
||||
@@ -36,7 +34,6 @@ namespace Microsoft.Agents.AI.Foundry;
|
||||
/// <c>AsAIAgent</c> extension methods on <see cref="AIProjectClient"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
|
||||
public sealed class FoundryAgent : DelegatingAIAgent
|
||||
{
|
||||
/// <summary>
|
||||
@@ -261,6 +258,7 @@ public sealed class FoundryAgent : DelegatingAIAgent
|
||||
return innerAgent;
|
||||
}
|
||||
|
||||
#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
|
||||
if (innerAgent.ChatClient.GetService<OpenAIRequestPolicies>() is { } policies)
|
||||
{
|
||||
OpenAIRequestPoliciesReflection.AddPolicyIfMissing(
|
||||
@@ -268,6 +266,7 @@ public sealed class FoundryAgent : DelegatingAIAgent
|
||||
ClientHeadersPolicy.Instance,
|
||||
PipelinePosition.PerCall);
|
||||
}
|
||||
#pragma warning restore MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
|
||||
|
||||
return new ClientHeadersAgent(innerAgent);
|
||||
}
|
||||
|
||||
@@ -2,13 +2,11 @@
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.Extensions.OpenAI;
|
||||
using Azure.AI.Projects.Agents;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
using OpenAI.Files;
|
||||
using OpenAI.VectorStores;
|
||||
@@ -23,7 +21,6 @@ namespace Microsoft.Agents.AI.Foundry;
|
||||
/// <see cref="FoundryChatClient"/> at the agent level so callers do not need to drop down to
|
||||
/// <c>agent.GetService<FoundryChatClient>().X()</c> for common workflows.
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
|
||||
public static class FoundryAgentExtensions
|
||||
{
|
||||
/// <summary>
|
||||
|
||||
@@ -4,7 +4,6 @@ using System;
|
||||
using System.ClientModel;
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.IO;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading;
|
||||
@@ -13,7 +12,6 @@ using Azure.AI.Extensions.OpenAI;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.AI.Projects.Agents;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
using OpenAI.Files;
|
||||
using OpenAI.Responses;
|
||||
@@ -53,7 +51,6 @@ namespace Microsoft.Agents.AI.Foundry;
|
||||
/// behind an Agent Endpoint. It is not synonymous with the Agent Endpoint mode itself.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
|
||||
public sealed class FoundryChatClient : DelegatingChatClient
|
||||
{
|
||||
private readonly ChatClientMetadata _metadata;
|
||||
@@ -652,6 +649,7 @@ public sealed class FoundryChatClient : DelegatingChatClient
|
||||
/// <summary>Best-effort registration of <see cref="AgentFrameworkUserAgentPolicy"/> via the MEAI <see cref="OpenAIRequestPolicies"/> hook with at-most-once dedup per pipeline.</summary>
|
||||
private static void TryRegisterAgentFrameworkUserAgentPolicy(IChatClient? innerClient)
|
||||
{
|
||||
#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
|
||||
if (innerClient?.GetService<OpenAIRequestPolicies>() is { } policies)
|
||||
{
|
||||
// OpenAIRequestPoliciesReflection.AddPolicyIfMissing performs a check-then-add against
|
||||
@@ -663,6 +661,7 @@ public sealed class FoundryChatClient : DelegatingChatClient
|
||||
AgentFrameworkUserAgentPolicy.Instance,
|
||||
PipelinePosition.PerCall);
|
||||
}
|
||||
#pragma warning restore MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -675,6 +674,7 @@ public sealed class FoundryChatClient : DelegatingChatClient
|
||||
/// </summary>
|
||||
private static void TryRegisterServedModelPolicy(IChatClient? innerClient)
|
||||
{
|
||||
#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
|
||||
if (innerClient?.GetService<OpenAIRequestPolicies>() is { } policies)
|
||||
{
|
||||
OpenAIRequestPoliciesReflection.AddPolicyIfMissing(
|
||||
@@ -682,6 +682,7 @@ public sealed class FoundryChatClient : DelegatingChatClient
|
||||
ServedModelPolicy.Instance,
|
||||
PipelinePosition.PerCall);
|
||||
}
|
||||
#pragma warning restore MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
|
||||
}
|
||||
|
||||
/// <summary>Default OAuth scope for the Azure AI resource. Matches the scope used by <c>Azure.AI.Extensions.OpenAI</c>'s internal authentication helper so the bearer token is accepted by the Foundry control plane.</summary>
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.Extensions.OpenAI;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.AI.Projects.Agents;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
using OpenAI.Responses;
|
||||
|
||||
@@ -34,7 +32,6 @@ namespace Microsoft.Agents.AI.Foundry;
|
||||
/// <item><description><b>Agent Endpoint (Mode 3)</b>: throw — no local definition exists to convert.</description></item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
|
||||
internal static class FoundryPromptAgentConverter
|
||||
{
|
||||
/// <summary>Performs the conversion for an agent whose chat client and chat options are supplied.</summary>
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry;
|
||||
|
||||
@@ -22,7 +21,6 @@ namespace Microsoft.Agents.AI.Foundry;
|
||||
/// <c>FoundryAITool.CreateHostedMcpToolbox(...)</c> factory overloads.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
|
||||
public sealed class HostedMcpToolboxAITool : HostedMcpServerTool
|
||||
{
|
||||
/// <summary>
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
related per-agent endpoint surface). Flip back to IsReleased=true once Azure.AI.Projects
|
||||
ships a stable 2.1.0. -->
|
||||
<InjectSharedThrow>true</InjectSharedThrow>
|
||||
<NoWarn>$(NoWarn);OPENAI001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
|
||||
|
||||
@@ -13,7 +13,6 @@ namespace Azure.AI.Extensions.OpenAI;
|
||||
/// Provides extension methods for <see cref="ProjectResponsesClient"/>
|
||||
/// to simplify the creation of AI agents that work with Azure AI services.
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
|
||||
public static class ProjectResponsesClientExtensions
|
||||
{
|
||||
/// <summary>
|
||||
|
||||
@@ -4,13 +4,15 @@
|
||||
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
|
||||
<RootNamespace>Microsoft.Agents.AI.Mcp</RootNamespace>
|
||||
<VersionSuffix>alpha</VersionSuffix>
|
||||
<NoWarn>$(NoWarn);MEAI001;MCPEXP001</NoWarn>
|
||||
<NoWarn>$(NoWarn);MEAI001;MAAI001;MCPEXP001</NoWarn>
|
||||
|
||||
</PropertyGroup>
|
||||
|
||||
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
|
||||
|
||||
<PropertyGroup>
|
||||
<InjectSharedThrow>true</InjectSharedThrow>
|
||||
<InjectSharedDiagnosticIds>true</InjectSharedDiagnosticIds>
|
||||
<InjectTrimAttributesOnLegacy>true</InjectTrimAttributesOnLegacy>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -34,4 +36,8 @@
|
||||
<InternalsVisibleTo Include="Microsoft.Agents.AI.Mcp.UnitTests" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
using ModelContextProtocol.Client;
|
||||
using ModelContextProtocol.Protocol;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// An <see cref="AgentSkill"/> discovered from an MCP server exposing the Agent Skills convention.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The skill is constructed from <c>skill://index.json</c> discovery metadata only; <see cref="GetContentAsync"/>
|
||||
/// fetches the full <c>SKILL.md</c> content from the MCP server on demand via <c>resources/read</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Per SEP-2640, resources referenced inside SKILL.md are fetched on demand via the originating MCP
|
||||
/// server: <see cref="GetResourceAsync"/> resolves a relative resource name against the
|
||||
/// skill's root URI, issues a <c>resources/read</c> request, and returns an <see cref="AgentMcpSkillResource"/>
|
||||
/// with pre-fetched content.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class AgentMcpSkill : AgentSkill
|
||||
{
|
||||
private const string SkillMdSuffix = "SKILL.md";
|
||||
|
||||
private readonly McpClient _client;
|
||||
private readonly string _skillMdUri;
|
||||
private readonly string _skillRootUri;
|
||||
private string? _content;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AgentMcpSkill"/> class.
|
||||
/// </summary>
|
||||
/// <param name="frontmatter">The parsed frontmatter metadata for this skill.</param>
|
||||
/// <param name="skillMdUri">
|
||||
/// The full MCP resource URI of the <c>SKILL.md</c> resource (e.g. <c>skill://unit-converter/SKILL.md</c>).
|
||||
/// Used by <see cref="GetContentAsync"/> to fetch the skill content on demand. The skill's root URI
|
||||
/// (used to resolve sibling resources) is derived by stripping the trailing <c>SKILL.md</c> segment.
|
||||
/// </param>
|
||||
/// <param name="client">The MCP client used to fetch resources on demand.</param>
|
||||
public AgentMcpSkill(AgentSkillFrontmatter frontmatter, string skillMdUri, McpClient client)
|
||||
{
|
||||
this.Frontmatter = Throw.IfNull(frontmatter);
|
||||
this._skillMdUri = Throw.IfNullOrWhitespace(skillMdUri);
|
||||
this._skillRootUri = ComputeSkillRootUri(skillMdUri);
|
||||
this._client = Throw.IfNull(client);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override AgentSkillFrontmatter Frontmatter { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>
|
||||
/// Fetches the <c>SKILL.md</c> content from the MCP server via <c>resources/read</c> on the first call
|
||||
/// and caches the result.
|
||||
/// </remarks>
|
||||
public override async ValueTask<string> GetContentAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (this._content is not null)
|
||||
{
|
||||
return this._content;
|
||||
}
|
||||
|
||||
#pragma warning disable CA2234 // Pass system uri objects instead of strings
|
||||
ReadResourceResult result = await this._client.ReadResourceAsync(this._skillMdUri, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
#pragma warning restore CA2234 // Pass system uri objects instead of strings
|
||||
|
||||
string text = string.Join("\n", result.Contents.OfType<TextResourceContents>().Select(c => c.Text));
|
||||
|
||||
if (text.Length == 0)
|
||||
{
|
||||
throw new InvalidOperationException($"The MCP server returned no text content for SKILL.md resource '{this._skillMdUri}'.");
|
||||
}
|
||||
|
||||
return this._content = text;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>
|
||||
/// Resolves <paramref name="name"/> as a relative path against the skill's root URI, issues a
|
||||
/// <c>resources/read</c> request to the MCP server, and returns an <see cref="AgentMcpSkillResource"/>
|
||||
/// with the pre-fetched content. Returns <see langword="null"/> when the name is empty, the server
|
||||
/// returns no content, or the resource does not exist on the server.
|
||||
/// </remarks>
|
||||
public override async ValueTask<AgentSkillResource?> GetResourceAsync(string name, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string uri = this._skillRootUri + name;
|
||||
|
||||
ReadResourceResult result;
|
||||
try
|
||||
{
|
||||
#pragma warning disable CA2234 // Pass system uri objects instead of strings
|
||||
result = await this._client.ReadResourceAsync(uri, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
#pragma warning restore CA2234 // Pass system uri objects instead of strings
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new AgentMcpSkillResource(name: name, result: result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Strips the trailing <c>SKILL.md</c> from the URI to produce the skill's root directory URI.
|
||||
/// If the URI doesn't end with <c>SKILL.md</c>, ensures it ends with a trailing slash.
|
||||
/// </summary>
|
||||
private static string ComputeSkillRootUri(string skillMdUri)
|
||||
{
|
||||
if (skillMdUri.EndsWith(SkillMdSuffix, StringComparison.Ordinal))
|
||||
{
|
||||
return skillMdUri.Substring(0, skillMdUri.Length - SkillMdSuffix.Length);
|
||||
}
|
||||
|
||||
if (skillMdUri.EndsWith("/", StringComparison.Ordinal))
|
||||
{
|
||||
return skillMdUri;
|
||||
}
|
||||
|
||||
return skillMdUri + "/";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
using ModelContextProtocol;
|
||||
using ModelContextProtocol.Protocol;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// An <see cref="AgentSkillResource"/> backed by content fetched from an MCP server.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The <see cref="ReadResourceResult"/> is fetched eagerly by <see cref="AgentMcpSkill.GetResourceAsync"/>
|
||||
/// at construction time; <see cref="ReadAsync"/> extracts the content from the result.
|
||||
/// </remarks>
|
||||
internal sealed class AgentMcpSkillResource : AgentSkillResource
|
||||
{
|
||||
private readonly ReadResourceResult _result;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AgentMcpSkillResource"/> class with a pre-fetched result.
|
||||
/// </summary>
|
||||
/// <param name="name">The resource name (e.g. a relative path or identifier).</param>
|
||||
/// <param name="result">The result returned by the MCP server's <c>resources/read</c> request.</param>
|
||||
/// <param name="description">An optional description of the resource.</param>
|
||||
public AgentMcpSkillResource(string name, ReadResourceResult result, string? description = null)
|
||||
: base(Throw.IfNullOrWhitespace(name), description)
|
||||
{
|
||||
this._result = Throw.IfNull(result);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <returns>
|
||||
/// A <see cref="DataContent"/> when the resource contains binary content, a <see cref="string"/> when
|
||||
/// it contains text, or <see langword="null"/> when the server returned no content blocks.
|
||||
/// </returns>
|
||||
public override Task<object?> ReadAsync(IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
BlobResourceContents? blob = this._result.Contents.OfType<BlobResourceContents>().FirstOrDefault();
|
||||
if (blob is not null)
|
||||
{
|
||||
return Task.FromResult<object?>(blob.ToAIContent());
|
||||
}
|
||||
|
||||
string text = string.Join("\n", this._result.Contents.OfType<TextResourceContents>().Select(c => c.Text));
|
||||
|
||||
if (text.Length == 0)
|
||||
{
|
||||
return Task.FromResult<object?>(null);
|
||||
}
|
||||
|
||||
return Task.FromResult<object?>(text);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
using ModelContextProtocol;
|
||||
using ModelContextProtocol.Client;
|
||||
using ModelContextProtocol.Protocol;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// An <see cref="AgentSkillsSource"/> that discovers Agent Skills served over the Model Context Protocol (MCP).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Discovery follows the SEP-2640 recommended approach: the source reads the well-known
|
||||
/// <c>skill://index.json</c> resource and constructs one <see cref="AgentSkill"/> per
|
||||
/// <c>skill-md</c> entry directly from the entry's <c>name</c>, <c>description</c>, and <c>url</c> fields.
|
||||
/// The referenced <c>SKILL.md</c> resource is not read during discovery; hosts fetch its body on
|
||||
/// demand via <c>resources/read</c> against the URI exposed on the resulting skill.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Only index entries of type <c>skill-md</c> are supported at the moment; entries of any other
|
||||
/// type are skipped.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// If <c>skill://index.json</c> is absent, unreadable, empty, or fails to parse, this source
|
||||
/// returns an empty list. Discovered skills serve their referenced resources on demand via
|
||||
/// <see cref="AgentSkill.GetResourceAsync"/>; they do not enumerate sibling files up front.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed partial class AgentMcpSkillsSource : AgentSkillsSource
|
||||
{
|
||||
/// <summary>
|
||||
/// SEP-2640 canonical discovery document URI.
|
||||
/// </summary>
|
||||
private const string IndexUri = "skill://index.json";
|
||||
|
||||
private const string SkillMdEntryType = "skill-md";
|
||||
|
||||
private readonly McpClient _client;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AgentMcpSkillsSource"/> class.
|
||||
/// </summary>
|
||||
/// <param name="client">An MCP client connected to a server that exposes Agent Skills resources.</param>
|
||||
/// <param name="loggerFactory">Optional logger factory.</param>
|
||||
public AgentMcpSkillsSource(McpClient client, ILoggerFactory? loggerFactory = null)
|
||||
{
|
||||
this._client = Throw.IfNull(client);
|
||||
this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger<AgentMcpSkillsSource>();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task<IList<AgentSkill>> GetSkillsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
McpSkillIndex? index = await this.TryReadIndexAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var skills = new List<AgentSkill>();
|
||||
|
||||
foreach (var entry in index?.Skills ?? [])
|
||||
{
|
||||
if (this.TryCreateSkill(entry, out AgentMcpSkill? skill, out string skipReason))
|
||||
{
|
||||
skills.Add(skill);
|
||||
LogSkillLoaded(this._logger, skill.Frontmatter.Name);
|
||||
}
|
||||
else
|
||||
{
|
||||
LogIndexEntrySkipped(this._logger, entry.Name ?? "(unnamed)", skipReason);
|
||||
}
|
||||
}
|
||||
|
||||
LogSkillsLoadedTotal(this._logger, skills.Count);
|
||||
|
||||
return skills;
|
||||
}
|
||||
|
||||
private async Task<McpSkillIndex?> TryReadIndexAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
ReadResourceResult result;
|
||||
|
||||
try
|
||||
{
|
||||
#pragma warning disable CA2234 // Pass system uri objects instead of strings
|
||||
result = await this._client.ReadResourceAsync(IndexUri, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
#pragma warning restore CA2234 // Pass system uri objects instead of strings
|
||||
}
|
||||
catch (McpException ex) when (ex is McpProtocolException pex && pex.ErrorCode == McpErrorCode.ResourceNotFound)
|
||||
{
|
||||
LogIndexAbsent(this._logger, ex.Message);
|
||||
return null;
|
||||
}
|
||||
catch (McpException ex)
|
||||
{
|
||||
LogIndexReadFailed(this._logger, ex);
|
||||
return null;
|
||||
}
|
||||
|
||||
string? indexText = result.Contents.OfType<TextResourceContents>().FirstOrDefault()?.Text;
|
||||
if (string.IsNullOrWhiteSpace(indexText))
|
||||
{
|
||||
LogIndexEmpty(this._logger);
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize(indexText, McpJsonContext.Default.McpSkillIndex);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
LogIndexParseFailed(this._logger, ex);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryCreateSkill(
|
||||
McpSkillIndexEntry entry,
|
||||
[NotNullWhen(true)] out AgentMcpSkill? skill,
|
||||
out string skipReason)
|
||||
{
|
||||
skill = null;
|
||||
|
||||
if (!string.Equals(entry.Type, SkillMdEntryType, StringComparison.Ordinal))
|
||||
{
|
||||
skipReason = $"unsupported type '{entry.Type ?? "(none)"}'";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(entry.Url))
|
||||
{
|
||||
skipReason = "missing required 'url' field";
|
||||
return false;
|
||||
}
|
||||
|
||||
AgentSkillFrontmatter frontmatter;
|
||||
try
|
||||
{
|
||||
frontmatter = new AgentSkillFrontmatter(entry.Name!, entry.Description!);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
skipReason = $"invalid metadata: {ex.Message}";
|
||||
return false;
|
||||
}
|
||||
|
||||
skill = new AgentMcpSkill(frontmatter, entry.Url!, this._client);
|
||||
skipReason = string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
[LoggerMessage(LogLevel.Information, "Loaded MCP skill: {SkillName}")]
|
||||
private static partial void LogSkillLoaded(ILogger logger, string skillName);
|
||||
|
||||
[LoggerMessage(LogLevel.Information, "Successfully loaded {Count} skills from MCP server")]
|
||||
private static partial void LogSkillsLoadedTotal(ILogger logger, int count);
|
||||
|
||||
[LoggerMessage(LogLevel.Debug, "No skill://index.json resource available on MCP server: {Reason}")]
|
||||
private static partial void LogIndexAbsent(ILogger logger, string reason);
|
||||
|
||||
[LoggerMessage(LogLevel.Warning, "Failed to read skill://index.json from MCP server.")]
|
||||
private static partial void LogIndexReadFailed(ILogger logger, Exception exception);
|
||||
|
||||
[LoggerMessage(LogLevel.Debug, "skill://index.json on MCP server returned empty/non-text contents")]
|
||||
private static partial void LogIndexEmpty(ILogger logger);
|
||||
|
||||
[LoggerMessage(LogLevel.Warning, "Failed to parse skill://index.json JSON document.")]
|
||||
private static partial void LogIndexParseFailed(ILogger logger, Exception exception);
|
||||
|
||||
[LoggerMessage(LogLevel.Debug, "Skipping skill index entry '{SkillName}': {Reason}")]
|
||||
private static partial void LogIndexEntrySkipped(ILogger logger, string skillName, string reason);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
using ModelContextProtocol.Client;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// MCP-specific extension methods for <see cref="AgentSkillsProviderBuilder"/>.
|
||||
/// </summary>
|
||||
public static class AgentSkillsProviderBuilderMcpExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds a skill source that discovers skills served over MCP via the supplied <paramref name="client"/>.
|
||||
/// </summary>
|
||||
/// <param name="builder">The builder to extend.</param>
|
||||
/// <param name="client">An MCP client connected to a server exposing Agent Skills resources.</param>
|
||||
/// <returns>The builder instance for chaining.</returns>
|
||||
public static AgentSkillsProviderBuilder UseMcpSkills(this AgentSkillsProviderBuilder builder, McpClient client)
|
||||
{
|
||||
_ = Throw.IfNull(builder);
|
||||
_ = Throw.IfNull(client);
|
||||
|
||||
return builder.UseSource(new AgentMcpSkillsSource(client));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Source-generated JSON context for MCP-skills well-known DTOs.
|
||||
/// </summary>
|
||||
[JsonSourceGenerationOptions(JsonSerializerDefaults.Web, AllowTrailingCommas = true, ReadCommentHandling = JsonCommentHandling.Skip)]
|
||||
[JsonSerializable(typeof(McpSkillIndex))]
|
||||
[JsonSerializable(typeof(McpSkillIndexEntry))]
|
||||
internal sealed partial class McpJsonContext : JsonSerializerContext;
|
||||
@@ -0,0 +1,96 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// DTO for the skill discovery index document served at <c>skill://index.json</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Schema reference: <see href="https://schemas.agentskills.io/discovery/0.2.0/schema.json"/>
|
||||
/// (Agent Skills Discovery v0.2.0), as bound to MCP by SEP-2640. The MCP binding differs from the
|
||||
/// base schema in two ways: the <c>url</c> field contains a full MCP resource URI, and the
|
||||
/// <c>digest</c> field is omitted (integrity is the transport's concern over an authenticated
|
||||
/// MCP connection).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// All properties are nullable so that deserialization succeeds even when the server-side index
|
||||
/// is incomplete or malformed; callers MUST validate required fields before use.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class McpSkillIndex
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the opaque schema identifier URI. Required by the base schema; clients SHOULD
|
||||
/// match this against known schema URIs (e.g.
|
||||
/// <c>https://schemas.agentskills.io/discovery/0.2.0/schema.json</c>) before processing the index.
|
||||
/// </summary>
|
||||
[JsonPropertyName("$schema")]
|
||||
public string? Schema { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the array of skill entries. Required by the schema; an empty or missing
|
||||
/// <c>skills</c> array means the index advertises no skills.
|
||||
/// </summary>
|
||||
[JsonPropertyName("skills")]
|
||||
public List<McpSkillIndexEntry>? Skills { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A single entry in the skill discovery index.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Field requirements per the v0.2.0 schema and the SEP-2640 binding:
|
||||
/// <list type="bullet">
|
||||
/// <item><description><c>type</c>, <c>description</c>, and <c>url</c> are REQUIRED.</description></item>
|
||||
/// <item><description><c>name</c> is REQUIRED for <c>skill-md</c> and <c>archive</c> entries; OMITTED for <c>mcp-resource-template</c>.</description></item>
|
||||
/// <item><description><c>digest</c> is part of the base schema but OMITTED under the SEP-2640 MCP binding; carried here for compatibility with non-MCP indices.</description></item>
|
||||
/// </list>
|
||||
/// All properties are nullable to keep deserialization lenient; callers validate required fields before use.
|
||||
/// </remarks>
|
||||
internal sealed class McpSkillIndexEntry
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the skill name (1-64 chars, lowercase alphanumeric and hyphens; no leading,
|
||||
/// trailing, or consecutive hyphens). Required for <c>skill-md</c> and <c>archive</c> entries;
|
||||
/// omitted for <c>mcp-resource-template</c>.
|
||||
/// </summary>
|
||||
[JsonPropertyName("name")]
|
||||
public string? Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the entry distribution type. Required. Schema-defined values are
|
||||
/// <c>skill-md</c> and <c>archive</c>; the SEP-2640 MCP binding additionally defines
|
||||
/// <c>mcp-resource-template</c>.
|
||||
/// </summary>
|
||||
[JsonPropertyName("type")]
|
||||
public string? Type { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the skill description (max 1024 chars per the Agent Skills specification).
|
||||
/// Required. For <c>skill-md</c> entries, SHOULD match the <c>description</c> in the skill's
|
||||
/// <c>SKILL.md</c> frontmatter.
|
||||
/// </summary>
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the artifact URL. Required. For <c>skill-md</c>, points at the
|
||||
/// <c>SKILL.md</c> resource. For <c>archive</c>, points at the archive file. For
|
||||
/// <c>mcp-resource-template</c>, an RFC 6570 URI template that resolves to a <c>SKILL.md</c>
|
||||
/// resource URI.
|
||||
/// </summary>
|
||||
[JsonPropertyName("url")]
|
||||
public string? Url { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the SHA-256 digest of the artifact bytes (e.g. <c>sha256:abcd1234...</c>).
|
||||
/// Required by the base v0.2.0 schema, but OMITTED under the SEP-2640 MCP binding because
|
||||
/// integrity is the transport's concern over an authenticated MCP connection.
|
||||
/// </summary>
|
||||
[JsonPropertyName("digest")]
|
||||
public string? Digest { get; set; }
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Declarative;
|
||||
|
||||
/// <summary>
|
||||
/// Defines programming language for workflow ejection.
|
||||
/// </summary>
|
||||
public enum DeclarativeWorkflowLanguage
|
||||
{
|
||||
/// <summary>
|
||||
/// Python programming language.
|
||||
/// </summary>
|
||||
Python,
|
||||
|
||||
/// <summary>
|
||||
/// C# programming language.
|
||||
/// </summary>
|
||||
CSharp,
|
||||
|
||||
/// <summary>
|
||||
/// JavaScript programming language.
|
||||
/// </summary>
|
||||
JavaScript,
|
||||
}
|
||||
@@ -22,6 +22,11 @@ internal sealed class ForeachExecutor : DeclarativeActionExecutor<Foreach>
|
||||
public static string End(string id) => $"{id}_{nameof(End)}";
|
||||
}
|
||||
|
||||
// State keys for checkpoint persistence of iteration progress.
|
||||
private const string IndexStateKey = nameof(_index);
|
||||
private const string ValuesStateKey = nameof(_values);
|
||||
private const string HasValueStateKey = nameof(HasValue);
|
||||
|
||||
private int _index;
|
||||
private FormulaValue[] _values;
|
||||
|
||||
@@ -93,4 +98,45 @@ internal sealed class ForeachExecutor : DeclarativeActionExecutor<Foreach>
|
||||
await context.QueueStateResetAsync(this.Model.Index, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>
|
||||
/// Persists the iteration cursor (<see cref="_index"/>), the materialized item snapshot
|
||||
/// (<see cref="_values"/> as <see cref="PortableValue"/>[]), and <see cref="HasValue"/> so a
|
||||
/// foreach loop can resume mid-iteration after a checkpoint (e.g. when a <c>Question</c>
|
||||
/// inside the loop body pauses the workflow and the executor is re-instantiated on resume).
|
||||
/// </remarks>
|
||||
protected override async ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
PortableValue[] portableValues = [.. this._values.Select(value => new PortableValue(value.AsPortable()))];
|
||||
|
||||
await context.QueueStateUpdateAsync(IndexStateKey, this._index, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
await context.QueueStateUpdateAsync(ValuesStateKey, portableValues, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
await context.QueueStateUpdateAsync(HasValueStateKey, this.HasValue, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await base.OnCheckpointingAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>
|
||||
/// Restores the iteration cursor, item snapshot, and <see cref="HasValue"/> recorded by
|
||||
/// <see cref="OnCheckpointingAsync"/>. The presence of the values snapshot is the source of
|
||||
/// truth for "this foreach was previously checkpointed"; if it is absent the executor keeps
|
||||
/// its constructor defaults (fresh-start semantics).
|
||||
/// </remarks>
|
||||
protected override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await base.OnCheckpointRestoredAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
PortableValue[]? savedValues =
|
||||
await context.ReadStateAsync<PortableValue[]>(ValuesStateKey, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
if (savedValues is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
this._values = [.. savedValues.Select(value => value.ToFormula())];
|
||||
this._index = await context.ReadStateAsync<int>(IndexStateKey, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
this.HasValue = await context.ReadStateAsync<bool>(HasValueStateKey, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,27 @@
|
||||
<Right>lib/net10.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkill.get_Content</Target>
|
||||
<Left>lib/net10.0/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/net10.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkill.get_Resources</Target>
|
||||
<Left>lib/net10.0/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/net10.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkill.get_Scripts</Target>
|
||||
<Left>lib/net10.0/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/net10.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken)</Target>
|
||||
@@ -106,6 +127,27 @@
|
||||
<Right>lib/net472/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkill.get_Content</Target>
|
||||
<Left>lib/net472/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/net472/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkill.get_Resources</Target>
|
||||
<Left>lib/net472/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/net472/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkill.get_Scripts</Target>
|
||||
<Left>lib/net472/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/net472/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken)</Target>
|
||||
@@ -169,6 +211,27 @@
|
||||
<Right>lib/net8.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkill.get_Content</Target>
|
||||
<Left>lib/net8.0/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/net8.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkill.get_Resources</Target>
|
||||
<Left>lib/net8.0/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/net8.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkill.get_Scripts</Target>
|
||||
<Left>lib/net8.0/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/net8.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken)</Target>
|
||||
@@ -232,6 +295,27 @@
|
||||
<Right>lib/net9.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkill.get_Content</Target>
|
||||
<Left>lib/net9.0/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/net9.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkill.get_Resources</Target>
|
||||
<Left>lib/net9.0/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/net9.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkill.get_Scripts</Target>
|
||||
<Left>lib/net9.0/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/net9.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken)</Target>
|
||||
@@ -295,6 +379,27 @@
|
||||
<Right>lib/netstandard2.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkill.get_Content</Target>
|
||||
<Left>lib/netstandard2.0/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/netstandard2.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkill.get_Resources</Target>
|
||||
<Left>lib/netstandard2.0/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/netstandard2.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkill.get_Scripts</Target>
|
||||
<Left>lib/netstandard2.0/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/netstandard2.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken)</Target>
|
||||
@@ -316,6 +421,13 @@
|
||||
<Right>lib/netstandard2.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0005</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkill.GetContentAsync(System.Threading.CancellationToken)</Target>
|
||||
<Left>lib/net10.0/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/net10.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0005</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,System.Nullable{System.Text.Json.JsonElement},System.IServiceProvider,System.Threading.CancellationToken)</Target>
|
||||
@@ -323,6 +435,13 @@
|
||||
<Right>lib/net10.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0005</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkill.GetContentAsync(System.Threading.CancellationToken)</Target>
|
||||
<Left>lib/net472/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/net472/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0005</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,System.Nullable{System.Text.Json.JsonElement},System.IServiceProvider,System.Threading.CancellationToken)</Target>
|
||||
@@ -330,6 +449,13 @@
|
||||
<Right>lib/net472/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0005</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkill.GetContentAsync(System.Threading.CancellationToken)</Target>
|
||||
<Left>lib/net8.0/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/net8.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0005</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,System.Nullable{System.Text.Json.JsonElement},System.IServiceProvider,System.Threading.CancellationToken)</Target>
|
||||
@@ -337,6 +463,13 @@
|
||||
<Right>lib/net8.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0005</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkill.GetContentAsync(System.Threading.CancellationToken)</Target>
|
||||
<Left>lib/net9.0/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/net9.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0005</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,System.Nullable{System.Text.Json.JsonElement},System.IServiceProvider,System.Threading.CancellationToken)</Target>
|
||||
@@ -344,6 +477,13 @@
|
||||
<Right>lib/net9.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0005</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkill.GetContentAsync(System.Threading.CancellationToken)</Target>
|
||||
<Left>lib/netstandard2.0/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/netstandard2.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0005</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,System.Nullable{System.Text.Json.JsonElement},System.IServiceProvider,System.Threading.CancellationToken)</Target>
|
||||
|
||||
@@ -29,8 +29,8 @@ namespace Microsoft.Agents.AI;
|
||||
/// <para>
|
||||
/// This provider exposes the following tools to the agent:
|
||||
/// <list type="bullet">
|
||||
/// <item><description><c>AgentMode_Set</c> — Switch the agent's operating mode.</description></item>
|
||||
/// <item><description><c>AgentMode_Get</c> — Retrieve the agent's current operating mode.</description></item>
|
||||
/// <item><description><c>mode_set</c> — Switch the agent's operating mode.</description></item>
|
||||
/// <item><description><c>mode_get</c> — Retrieve the agent's current operating mode.</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// <para>
|
||||
@@ -49,8 +49,8 @@ public sealed class AgentModeProvider : AIContextProvider
|
||||
- You must check the current mode after any user input, since the user may have changed the mode themselves,
|
||||
e.g. the user may have switched to 'plan' mode after a previous research task finished in 'execute' mode, meaning they want to review a plan first before execution.
|
||||
|
||||
Use the AgentMode_Get tool to check your current operating mode.
|
||||
Use the AgentMode_Set tool to switch between modes as your work progresses. Only use AgentMode_Set if the user explicitly instructs/allows you to change modes.
|
||||
Use the mode_get tool to check your current operating mode.
|
||||
Use the mode_set tool to switch between modes as your work progresses. Only use mode_set if the user explicitly instructs/allows you to change modes.
|
||||
|
||||
You are currently operating in the {current_mode} mode.
|
||||
|
||||
@@ -79,7 +79,7 @@ public sealed class AgentModeProvider : AIContextProvider
|
||||
4. Do short exploratory research if it helps with being able to ask sensible clarifications from the user.
|
||||
5. Write the plan to a memory file, so that it is retained even if compaction happens. Make sure to update the plan file if the user requests changes.
|
||||
6. Present the plan to the user and ask for approval to switch to execute mode and process the plan.
|
||||
7. When approval is granted, always switch to execute mode (using the `AgentMode_Set` tool), and follow the steps for *Execute mode*.
|
||||
7. When approval is granted, always switch to execute mode (using the `mode_set` tool), and follow the steps for *Execute mode*.
|
||||
"""),
|
||||
new(
|
||||
"execute",
|
||||
@@ -263,7 +263,7 @@ public sealed class AgentModeProvider : AIContextProvider
|
||||
},
|
||||
new AIFunctionFactoryOptions
|
||||
{
|
||||
Name = "AgentMode_Set",
|
||||
Name = "mode_set",
|
||||
Description = $"Switch the agent's operating mode. Supported modes: \"{this._modeNamesDisplay}\".",
|
||||
SerializerOptions = serializerOptions,
|
||||
}),
|
||||
@@ -272,7 +272,7 @@ public sealed class AgentModeProvider : AIContextProvider
|
||||
() => state.CurrentMode,
|
||||
new AIFunctionFactoryOptions
|
||||
{
|
||||
Name = "AgentMode_Get",
|
||||
Name = "mode_get",
|
||||
Description = "Get the agent's current operating mode.",
|
||||
SerializerOptions = serializerOptions,
|
||||
}),
|
||||
|
||||
@@ -26,11 +26,11 @@ namespace Microsoft.Agents.AI;
|
||||
/// <para>
|
||||
/// This provider exposes the following tools to the agent:
|
||||
/// <list type="bullet">
|
||||
/// <item><description><c>TodoList_Add</c> — Add one or more todo items, each with a title and optional description.</description></item>
|
||||
/// <item><description><c>TodoList_Complete</c> — Mark one or more todo items as complete by their IDs.</description></item>
|
||||
/// <item><description><c>TodoList_Remove</c> — Remove one or more todo items by their IDs.</description></item>
|
||||
/// <item><description><c>TodoList_GetRemaining</c> — Retrieve only incomplete todo items.</description></item>
|
||||
/// <item><description><c>TodoList_GetAll</c> — Retrieve all todo items (complete and incomplete).</description></item>
|
||||
/// <item><description><c>todos_add</c> — Add one or more todo items, each with a title and optional description.</description></item>
|
||||
/// <item><description><c>todos_complete</c> — Mark one or more todo items as complete by their IDs and reasons.</description></item>
|
||||
/// <item><description><c>todos_remove</c> — Remove one or more todo items by their IDs.</description></item>
|
||||
/// <item><description><c>todos_get_remaining</c> — Retrieve only incomplete todo items.</description></item>
|
||||
/// <item><description><c>todos_get_all</c> — Retrieve all todo items (complete and incomplete).</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// <para>
|
||||
@@ -53,11 +53,11 @@ public sealed class TodoProvider : AIContextProvider, IDisposable
|
||||
When a user changes the topic or changes their mind, ensure that you update the todo list accordingly by removing irrelevant/old items or adding new ones as needed.
|
||||
|
||||
Use these tools to manage your tasks:
|
||||
- Use TodoList_Add to break down complex work into trackable items (supports adding one or many at once).
|
||||
- Use TodoList_Complete to mark items as done when finished (supports one or many at once). Include a reason describing how the items were completed.
|
||||
- Use TodoList_GetRemaining to check what work is still pending.
|
||||
- Use TodoList_GetAll to review the full list including completed items.
|
||||
- Use TodoList_Remove to remove items that are no longer needed (supports one or many at once).
|
||||
- Use todos_add to break down complex work into trackable items (supports adding one or many at once).
|
||||
- Use todos_complete to mark items as done when finished (supports one or many at once). Include a reason describing how the items were completed.
|
||||
- Use todos_get_remaining to check what work is still pending.
|
||||
- Use todos_get_all to review the full list including completed items.
|
||||
- Use todos_remove to remove items that are no longer needed (supports one or many at once).
|
||||
""";
|
||||
|
||||
private readonly ProviderSessionState<TodoState> _sessionState;
|
||||
@@ -229,7 +229,7 @@ public sealed class TodoProvider : AIContextProvider, IDisposable
|
||||
},
|
||||
new AIFunctionFactoryOptions
|
||||
{
|
||||
Name = "TodoList_Add",
|
||||
Name = "todos_add",
|
||||
Description = "Add one or more todo items. Each item has a title and an optional description. Returns the list of created todo items.",
|
||||
SerializerOptions = serializerOptions,
|
||||
}),
|
||||
@@ -267,7 +267,7 @@ public sealed class TodoProvider : AIContextProvider, IDisposable
|
||||
},
|
||||
new AIFunctionFactoryOptions
|
||||
{
|
||||
Name = "TodoList_Complete",
|
||||
Name = "todos_complete",
|
||||
Description = "Mark one or more todo items as complete. Each entry has an ID and a reason describing how/why the item was completed. Returns the number of items that were found and marked complete.",
|
||||
SerializerOptions = serializerOptions,
|
||||
}),
|
||||
@@ -297,7 +297,7 @@ public sealed class TodoProvider : AIContextProvider, IDisposable
|
||||
},
|
||||
new AIFunctionFactoryOptions
|
||||
{
|
||||
Name = "TodoList_Remove",
|
||||
Name = "todos_remove",
|
||||
Description = "Remove one or more todo items by their IDs. Returns the number of items that were found and removed.",
|
||||
SerializerOptions = serializerOptions,
|
||||
}),
|
||||
@@ -319,7 +319,7 @@ public sealed class TodoProvider : AIContextProvider, IDisposable
|
||||
},
|
||||
new AIFunctionFactoryOptions
|
||||
{
|
||||
Name = "TodoList_GetRemaining",
|
||||
Name = "todos_get_remaining",
|
||||
Description = "Retrieve the list of incomplete todo items.",
|
||||
SerializerOptions = serializerOptions,
|
||||
}),
|
||||
@@ -341,7 +341,7 @@ public sealed class TodoProvider : AIContextProvider, IDisposable
|
||||
},
|
||||
new AIFunctionFactoryOptions
|
||||
{
|
||||
Name = "TodoList_GetAll",
|
||||
Name = "todos_get_all",
|
||||
Description = "Retrieve the full list of todo items, both complete and incomplete.",
|
||||
SerializerOptions = serializerOptions,
|
||||
}),
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
@@ -34,29 +35,44 @@ public abstract class AgentSkill
|
||||
/// <summary>
|
||||
/// Gets the full skill content.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>
|
||||
/// For file-based skills this is the raw SKILL.md file content, optionally
|
||||
/// augmented with a synthesized scripts block when scripts are present.
|
||||
/// For code-defined skills this is a synthesized XML document
|
||||
/// containing name, description, and body (instructions, resources, scripts).
|
||||
/// </remarks>
|
||||
public abstract string Content { get; }
|
||||
/// </returns>
|
||||
public abstract ValueTask<string> GetContentAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the resources associated with this skill, or <see langword="null"/> if none.
|
||||
/// Gets a resource owned by this skill by name.
|
||||
/// </summary>
|
||||
/// <param name="name">The resource name (e.g. an identifier or a relative path referenced inside the skill content).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>
|
||||
/// The <see cref="AgentSkillResource"/>, or <see langword="null"/> when no resource with the given name exists.
|
||||
/// </returns>
|
||||
/// <remarks>
|
||||
/// The default implementation returns <see langword="null"/>.
|
||||
/// Override this property in derived classes to provide skill-specific resources.
|
||||
/// The default implementation returns <see langword="null"/>. Override in derived classes that
|
||||
/// expose resources.
|
||||
/// </remarks>
|
||||
public virtual IReadOnlyList<AgentSkillResource>? Resources => null;
|
||||
public virtual ValueTask<AgentSkillResource?> GetResourceAsync(
|
||||
string name,
|
||||
CancellationToken cancellationToken = default) => default;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the scripts associated with this skill, or <see langword="null"/> if none.
|
||||
/// Gets a script owned by this skill by name.
|
||||
/// </summary>
|
||||
/// <param name="name">The script name.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>
|
||||
/// The <see cref="AgentSkillScript"/>, or <see langword="null"/> when no script with the given name exists.
|
||||
/// </returns>
|
||||
/// <remarks>
|
||||
/// The default implementation returns <see langword="null"/>.
|
||||
/// Override this property in derived classes to provide skill-specific scripts.
|
||||
/// The default implementation returns <see langword="null"/>. Override in derived classes that
|
||||
/// expose scripts.
|
||||
/// </remarks>
|
||||
public virtual IReadOnlyList<AgentSkillScript>? Scripts => null;
|
||||
public virtual ValueTask<AgentSkillScript?> GetScriptAsync(
|
||||
string name,
|
||||
CancellationToken cancellationToken = default) => default;
|
||||
}
|
||||
|
||||
@@ -186,13 +186,10 @@ public sealed partial class AgentSkillsProvider : AIContextProvider
|
||||
return await base.ProvideAIContextAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
bool hasScripts = skills.Any(s => s.Scripts is { Count: > 0 });
|
||||
bool hasResources = skills.Any(s => s.Resources is { Count: > 0 });
|
||||
|
||||
return new AIContext
|
||||
{
|
||||
Instructions = this.BuildSkillsInstructions(skills, includeScriptInstructions: hasScripts, hasResources),
|
||||
Tools = this.BuildTools(skills, hasScripts, hasResources),
|
||||
Instructions = this.BuildSkillsInstructions(skills),
|
||||
Tools = this.BuildTools(skills),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -219,29 +216,20 @@ public sealed partial class AgentSkillsProvider : AIContextProvider
|
||||
}
|
||||
}
|
||||
|
||||
private IList<AIFunction> BuildTools(IList<AgentSkill> skills, bool hasScripts, bool hasResources)
|
||||
private IList<AIFunction> BuildTools(IList<AgentSkill> skills)
|
||||
{
|
||||
IList<AIFunction> tools =
|
||||
[
|
||||
AIFunctionFactory.Create(
|
||||
(string skillName) => this.LoadSkill(skills, skillName),
|
||||
(string skillName, CancellationToken cancellationToken) => this.LoadSkillAsync(skills, skillName, cancellationToken),
|
||||
name: "load_skill",
|
||||
description: "Loads the full content of a specific skill"),
|
||||
];
|
||||
|
||||
if (hasResources)
|
||||
{
|
||||
tools.Add(AIFunctionFactory.Create(
|
||||
AIFunctionFactory.Create(
|
||||
(string skillName, string resourceName, IServiceProvider? serviceProvider, CancellationToken cancellationToken = default) =>
|
||||
this.ReadSkillResourceAsync(skills, skillName, resourceName, serviceProvider, cancellationToken),
|
||||
name: "read_skill_resource",
|
||||
description: "Reads a resource associated with a skill, such as references, assets, or dynamic data."));
|
||||
}
|
||||
|
||||
if (!hasScripts)
|
||||
{
|
||||
return tools;
|
||||
}
|
||||
description: "Reads a resource associated with a skill, such as references, assets, or dynamic data."),
|
||||
];
|
||||
|
||||
AIFunction scriptFunction = AIFunctionFactory.Create(
|
||||
(string skillName, string scriptName, JsonElement? arguments = null, IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default) =>
|
||||
@@ -257,7 +245,7 @@ public sealed partial class AgentSkillsProvider : AIContextProvider
|
||||
return [.. tools, scriptFunction];
|
||||
}
|
||||
|
||||
private string? BuildSkillsInstructions(IList<AgentSkill> skills, bool includeScriptInstructions, bool includeResourceInstructions)
|
||||
private string? BuildSkillsInstructions(IList<AgentSkill> skills)
|
||||
{
|
||||
string promptTemplate = this._options?.SkillsInstructionPrompt ?? DefaultSkillsInstructionPrompt;
|
||||
|
||||
@@ -270,32 +258,29 @@ public sealed partial class AgentSkillsProvider : AIContextProvider
|
||||
sb.AppendLine(" </skill>");
|
||||
}
|
||||
|
||||
string resourceInstruction = includeResourceInstructions
|
||||
? """
|
||||
const string ResourceInstruction =
|
||||
"""
|
||||
- Use `read_skill_resource` to read any referenced resources, using the name exactly as listed
|
||||
(e.g. `"style-guide"` not `"style-guide.md"`, `"references/FAQ.md"` not `"FAQ.md"`).
|
||||
"""
|
||||
: string.Empty;
|
||||
""";
|
||||
|
||||
string scriptInstruction = includeScriptInstructions
|
||||
? "- Use `run_skill_script` to run referenced scripts, using the name exactly as listed."
|
||||
: string.Empty;
|
||||
const string ScriptInstruction = "- Use `run_skill_script` to run referenced scripts, using the name exactly as listed.";
|
||||
|
||||
return new StringBuilder(promptTemplate)
|
||||
.Replace(SkillsPlaceholder, sb.ToString().TrimEnd())
|
||||
.Replace(ResourceInstructionsPlaceholder, resourceInstruction)
|
||||
.Replace(ScriptInstructionsPlaceholder, scriptInstruction)
|
||||
.Replace(ResourceInstructionsPlaceholder, ResourceInstruction)
|
||||
.Replace(ScriptInstructionsPlaceholder, ScriptInstruction)
|
||||
.ToString();
|
||||
}
|
||||
|
||||
private string LoadSkill(IList<AgentSkill> skills, string skillName)
|
||||
private async Task<string> LoadSkillAsync(IList<AgentSkill> skills, string skillName, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(skillName))
|
||||
{
|
||||
return "Error: Skill name cannot be empty.";
|
||||
}
|
||||
|
||||
var skill = skills?.FirstOrDefault(skill => skill.Frontmatter.Name == skillName);
|
||||
var skill = skills.FirstOrDefault(skill => skill.Frontmatter.Name == skillName);
|
||||
if (skill == null)
|
||||
{
|
||||
return $"Error: Skill '{skillName}' not found.";
|
||||
@@ -303,7 +288,7 @@ public sealed partial class AgentSkillsProvider : AIContextProvider
|
||||
|
||||
LogSkillLoading(this._logger, skillName);
|
||||
|
||||
return skill.Content;
|
||||
return await skill.GetContentAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<object?> ReadSkillResourceAsync(IList<AgentSkill> skills, string skillName, string resourceName, IServiceProvider? serviceProvider, CancellationToken cancellationToken = default)
|
||||
@@ -318,20 +303,20 @@ public sealed partial class AgentSkillsProvider : AIContextProvider
|
||||
return "Error: Resource name cannot be empty.";
|
||||
}
|
||||
|
||||
var skill = skills?.FirstOrDefault(skill => skill.Frontmatter.Name == skillName);
|
||||
var skill = skills.FirstOrDefault(skill => skill.Frontmatter.Name == skillName);
|
||||
if (skill == null)
|
||||
{
|
||||
return $"Error: Skill '{skillName}' not found.";
|
||||
}
|
||||
|
||||
var resource = skill.Resources?.FirstOrDefault(resource => resource.Name == resourceName);
|
||||
if (resource is null)
|
||||
{
|
||||
return $"Error: Resource '{resourceName}' not found in skill '{skillName}'.";
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var resource = await skill.GetResourceAsync(resourceName, cancellationToken).ConfigureAwait(false);
|
||||
if (resource is null)
|
||||
{
|
||||
return $"Error: Resource '{resourceName}' not found in skill '{skillName}'.";
|
||||
}
|
||||
|
||||
return await resource.ReadAsync(serviceProvider, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -353,20 +338,20 @@ public sealed partial class AgentSkillsProvider : AIContextProvider
|
||||
return "Error: Script name cannot be empty.";
|
||||
}
|
||||
|
||||
var skill = skills?.FirstOrDefault(skill => skill.Frontmatter.Name == skillName);
|
||||
var skill = skills.FirstOrDefault(skill => skill.Frontmatter.Name == skillName);
|
||||
if (skill == null)
|
||||
{
|
||||
return $"Error: Skill '{skillName}' not found.";
|
||||
}
|
||||
|
||||
var script = skill.Scripts?.FirstOrDefault(resource => resource.Name == scriptName);
|
||||
if (script is null)
|
||||
{
|
||||
return $"Error: Script '{scriptName}' not found in skill '{skillName}'.";
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var script = await skill.GetScriptAsync(scriptName, cancellationToken).ConfigureAwait(false);
|
||||
if (script is null)
|
||||
{
|
||||
return $"Error: Script '{scriptName}' not found in skill '{skillName}'.";
|
||||
}
|
||||
|
||||
return await script.RunAsync(skill, arguments, serviceProvider, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
@@ -50,11 +53,12 @@ public sealed class AgentFileSkill : AgentSkill
|
||||
/// block is appended with a per-script entry describing the expected argument format.
|
||||
/// The result is cached after the first access.
|
||||
/// </remarks>
|
||||
public override string Content
|
||||
public override ValueTask<string> GetContentAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
get => this._content ??= this._scripts is { Count: > 0 }
|
||||
var content = this._content ??= this._scripts is { Count: > 0 }
|
||||
? this._originalContent + AgentInlineSkillContentBuilder.BuildScriptsBlock(this._scripts)
|
||||
: this._originalContent;
|
||||
return new(content);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -63,8 +67,16 @@ public sealed class AgentFileSkill : AgentSkill
|
||||
public string Path { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override IReadOnlyList<AgentSkillResource> Resources => this._resources;
|
||||
public override ValueTask<AgentSkillResource?> GetResourceAsync(string name, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var resource = this._resources.FirstOrDefault(r => r.Name == name);
|
||||
return new(resource);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override IReadOnlyList<AgentSkillScript> Scripts => this._scripts;
|
||||
public override ValueTask<AgentSkillScript?> GetScriptAsync(string name, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var script = this._scripts.FirstOrDefault(s => s.Name == name);
|
||||
return new(script);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,11 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
@@ -34,9 +36,9 @@ namespace Microsoft.Agents.AI;
|
||||
/// discovered via reflection on <typeparamref name="TSelf"/>. This approach is compatible with Native AOT.
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <b>Explicit override:</b> Override <see cref="AgentSkill.Resources"/> and <see cref="AgentSkill.Scripts"/>, using
|
||||
/// <see cref="CreateResource(string, object, string?)"/>, <see cref="CreateResource(string, Delegate, string?, JsonSerializerOptions?)"/>,
|
||||
/// and <see cref="CreateScript"/> to define inline resources and scripts. This approach is also compatible with Native AOT.
|
||||
/// <b>Explicit override:</b> Override <see cref="Resources"/> and <see cref="Scripts"/>, using <see cref="CreateResource(string, object, string?)"/>,
|
||||
/// <see cref="CreateResource(string, Delegate, string?, JsonSerializerOptions?)"/>, and <see cref="CreateScript"/> to define
|
||||
/// inline resources and scripts. This approach is also compatible with Native AOT.
|
||||
/// </item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
@@ -97,11 +99,24 @@ public abstract class AgentClassSkill<
|
||||
{
|
||||
private const BindingFlags DiscoveryBindingFlags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static;
|
||||
|
||||
private string? _content;
|
||||
private bool _resourcesDiscovered;
|
||||
private bool _scriptsDiscovered;
|
||||
private IReadOnlyList<AgentSkillResource>? _reflectedResources;
|
||||
private IReadOnlyList<AgentSkillScript>? _reflectedScripts;
|
||||
private readonly Lazy<IReadOnlyList<AgentSkillResource>?> _resources;
|
||||
private readonly Lazy<IReadOnlyList<AgentSkillScript>?> _scripts;
|
||||
private readonly Lazy<string> _content;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AgentClassSkill{TSelf}"/> class.
|
||||
/// </summary>
|
||||
protected AgentClassSkill()
|
||||
{
|
||||
this._resources = new Lazy<IReadOnlyList<AgentSkillResource>?>(this.DiscoverResources);
|
||||
this._scripts = new Lazy<IReadOnlyList<AgentSkillScript>?>(this.DiscoverScripts);
|
||||
this._content = new Lazy<string>(() => AgentInlineSkillContentBuilder.Build(
|
||||
this.Frontmatter.Name,
|
||||
this.Frontmatter.Description,
|
||||
this.Instructions,
|
||||
this.Resources,
|
||||
this.Scripts));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the raw instructions text for this skill.
|
||||
@@ -126,53 +141,44 @@ public abstract class AgentClassSkill<
|
||||
/// Returns a synthesized XML document containing name, description, instructions, resources, and scripts.
|
||||
/// The result is cached after the first access. Override to provide custom content.
|
||||
/// </remarks>
|
||||
public override string Content => this._content ??= AgentInlineSkillContentBuilder.Build(
|
||||
this.Frontmatter.Name,
|
||||
this.Frontmatter.Description,
|
||||
this.Instructions,
|
||||
this.Resources,
|
||||
this.Scripts);
|
||||
public override ValueTask<string> GetContentAsync(CancellationToken cancellationToken = default) => new(this._content.Value);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the resources associated with this skill, or <see langword="null"/> if none.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The default implementation returns resources discovered via reflection by scanning
|
||||
/// <typeparamref name="TSelf"/> for members annotated with <see cref="AgentSkillResourceAttribute"/>.
|
||||
/// This discovery is compatible with Native AOT because <typeparamref name="TSelf"/> is annotated with
|
||||
/// <see cref="DynamicallyAccessedMembersAttribute"/>. The result is cached after the first access.
|
||||
/// Override this property in derived classes to provide skill-specific resources.
|
||||
/// </remarks>
|
||||
public virtual IReadOnlyList<AgentSkillResource>? Resources => this._resources.Value;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the scripts associated with this skill, or <see langword="null"/> if none.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The default implementation returns scripts discovered via reflection by scanning
|
||||
/// <typeparamref name="TSelf"/> for methods annotated with <see cref="AgentSkillScriptAttribute"/>.
|
||||
/// This discovery is compatible with Native AOT because <typeparamref name="TSelf"/> is annotated with
|
||||
/// <see cref="DynamicallyAccessedMembersAttribute"/>. The result is cached after the first access.
|
||||
/// Override this property in derived classes to provide skill-specific scripts.
|
||||
/// </remarks>
|
||||
public virtual IReadOnlyList<AgentSkillScript>? Scripts => this._scripts.Value;
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>
|
||||
/// Returns resources discovered via reflection by scanning <typeparamref name="TSelf"/> for
|
||||
/// members annotated with <see cref="AgentSkillResourceAttribute"/>. This discovery is
|
||||
/// compatible with Native AOT because <typeparamref name="TSelf"/> is annotated with
|
||||
/// <see cref="DynamicallyAccessedMembersAttribute"/>. The result is cached after the first access.
|
||||
/// </remarks>
|
||||
public override IReadOnlyList<AgentSkillResource>? Resources
|
||||
public sealed override ValueTask<AgentSkillResource?> GetResourceAsync(string name, CancellationToken cancellationToken = default)
|
||||
{
|
||||
get
|
||||
{
|
||||
if (!this._resourcesDiscovered)
|
||||
{
|
||||
this._reflectedResources = this.DiscoverResources();
|
||||
this._resourcesDiscovered = true;
|
||||
}
|
||||
|
||||
return this._reflectedResources;
|
||||
}
|
||||
var resource = this.Resources?.FirstOrDefault(r => r.Name == name);
|
||||
return new(resource);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>
|
||||
/// Returns scripts discovered via reflection by scanning <typeparamref name="TSelf"/> for
|
||||
/// methods annotated with <see cref="AgentSkillScriptAttribute"/>. This discovery is
|
||||
/// compatible with Native AOT because <typeparamref name="TSelf"/> is annotated with
|
||||
/// <see cref="DynamicallyAccessedMembersAttribute"/>. The result is cached after the first access.
|
||||
/// </remarks>
|
||||
public override IReadOnlyList<AgentSkillScript>? Scripts
|
||||
public sealed override ValueTask<AgentSkillScript?> GetScriptAsync(string name, CancellationToken cancellationToken = default)
|
||||
{
|
||||
get
|
||||
{
|
||||
if (!this._scriptsDiscovered)
|
||||
{
|
||||
this._reflectedScripts = this.DiscoverScripts();
|
||||
this._scriptsDiscovered = true;
|
||||
}
|
||||
|
||||
return this._reflectedScripts;
|
||||
}
|
||||
var script = this.Scripts?.FirstOrDefault(s => s.Name == name);
|
||||
return new(script);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
@@ -16,9 +19,9 @@ namespace Microsoft.Agents.AI;
|
||||
/// <remarks>
|
||||
/// All calls to <see cref="AddResource(string, object, string?)"/>,
|
||||
/// <see cref="AddResource(string, Delegate, string?, JsonSerializerOptions?)"/>, and <see cref="AddScript"/>
|
||||
/// must be made before the skill's <see cref="Content"/> is first accessed.
|
||||
/// must be made before the skill's <see cref="GetContentAsync"/> is first called.
|
||||
/// Calls made after that point will not be reflected in the generated
|
||||
/// <see cref="Content"/>. In typical usage, this means configuring all
|
||||
/// content. In typical usage, this means configuring all
|
||||
/// resources and scripts before registering the skill with an
|
||||
/// <see cref="AgentSkillsProvider"/> or <see cref="AgentSkillsProviderBuilder"/>.
|
||||
/// </remarks>
|
||||
@@ -90,13 +93,24 @@ public sealed class AgentInlineSkill : AgentSkill
|
||||
public override AgentSkillFrontmatter Frontmatter { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string Content => this._cachedContent ??= AgentInlineSkillContentBuilder.Build(this.Frontmatter.Name, this.Frontmatter.Description, this._instructions, this._resources, this._scripts);
|
||||
public override ValueTask<string> GetContentAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return new(this._cachedContent ??= AgentInlineSkillContentBuilder.Build(this.Frontmatter.Name, this.Frontmatter.Description, this._instructions, this._resources, this._scripts));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override IReadOnlyList<AgentSkillResource>? Resources => this._resources;
|
||||
public override ValueTask<AgentSkillResource?> GetResourceAsync(string name, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var resource = this._resources?.FirstOrDefault(r => r.Name == name);
|
||||
return new(resource);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override IReadOnlyList<AgentSkillScript>? Scripts => this._scripts;
|
||||
public override ValueTask<AgentSkillScript?> GetScriptAsync(string name, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var script = this._scripts?.FirstOrDefault(s => s.Name == name);
|
||||
return new(script);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a static resource with this skill.
|
||||
|
||||
@@ -27,7 +27,7 @@ namespace Microsoft.Agents.AI;
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This attribute is compatible with Native AOT when used with <see cref="AgentClassSkill{TSelf}"/>.
|
||||
/// Alternatively, override the <see cref="AgentSkill.Resources"/> property and use
|
||||
/// Alternatively, override <see cref="AgentClassSkill{TSelf}.Resources"/> and use
|
||||
/// <see cref="AgentClassSkill{TSelf}.CreateResource(string, object, string?)"/> instead.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
|
||||
@@ -26,7 +26,7 @@ namespace Microsoft.Agents.AI;
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This attribute is compatible with Native AOT when used with <see cref="AgentClassSkill{TSelf}"/>.
|
||||
/// Alternatively, override the <see cref="AgentSkill.Scripts"/> property and use
|
||||
/// Alternatively, override <see cref="AgentClassSkill{TSelf}.Scripts"/> and use
|
||||
/// <see cref="AgentClassSkill{TSelf}.CreateScript"/> instead.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
#if !NET
|
||||
using System.Threading.Tasks;
|
||||
#endif
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.CSharp;
|
||||
using Microsoft.CodeAnalysis.Emit;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Xunit.Sdk;
|
||||
|
||||
namespace Shared.Code;
|
||||
|
||||
internal static class Compiler
|
||||
{
|
||||
public static IEnumerable<Assembly> RepoDependencies(params IEnumerable<Type> types)
|
||||
{
|
||||
yield return typeof(object).Assembly;
|
||||
yield return typeof(Console).Assembly;
|
||||
yield return typeof(Enumerable).Assembly;
|
||||
#if NET
|
||||
yield return Assembly.Load("System.Runtime");
|
||||
#else
|
||||
yield return Assembly.LoadFrom(AppDomain.CurrentDomain.GetAssemblies().Single(a => a.GetName().Name == "netstandard").Location);
|
||||
yield return typeof(IAsyncEnumerable<>).Assembly;
|
||||
yield return typeof(ValueTask).Assembly;
|
||||
#endif
|
||||
yield return typeof(ChatMessage).Assembly;
|
||||
yield return typeof(AIAgent).Assembly;
|
||||
yield return typeof(Workflow).Assembly;
|
||||
|
||||
foreach (Type type in types)
|
||||
{
|
||||
yield return type.Assembly;
|
||||
}
|
||||
}
|
||||
|
||||
public static Assembly Build(string workflowProviderCode, params IEnumerable<Assembly> dependencies)
|
||||
{
|
||||
// Compile the code
|
||||
SyntaxTree syntaxTree = CSharpSyntaxTree.ParseText(workflowProviderCode);
|
||||
CSharpCompilation compilation = CSharpCompilation.Create(
|
||||
"DynamicAssembly",
|
||||
[syntaxTree],
|
||||
dependencies.Select(d => MetadataReference.CreateFromFile(d.Location)),
|
||||
new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)
|
||||
);
|
||||
|
||||
using MemoryStream memoryStream = new();
|
||||
EmitResult result = compilation.Emit(memoryStream);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
Console.WriteLine("COMPLILATION FAILURE:");
|
||||
foreach (var diagnostic in result.Diagnostics)
|
||||
{
|
||||
Console.WriteLine(diagnostic.ToString());
|
||||
}
|
||||
throw new XunitException("Compilation failed.");
|
||||
}
|
||||
|
||||
Console.WriteLine("COMPLILATION SUCCEEDED...");
|
||||
memoryStream.Seek(0, SeekOrigin.Begin);
|
||||
return Assembly.Load(memoryStream.ToArray());
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
# Build Code
|
||||
|
||||
Re-usable utility for building C# code in tests.
|
||||
|
||||
To use this in your project, add the following to your `.csproj` file:
|
||||
|
||||
```xml
|
||||
<PropertyGroup>
|
||||
<InjectSharedBuildTestCode>true</InjectSharedBuildTestCode>
|
||||
</PropertyGroup>
|
||||
```
|
||||
+2
@@ -27,11 +27,13 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Azure.Search.Documents" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI" />
|
||||
|
||||
@@ -38,6 +38,7 @@ AIAgent agent = scenario switch
|
||||
"memory" => await CreateMemoryAgentAsync(projectClient, deployment).ConfigureAwait(false),
|
||||
"azure-search-rag" => CreateAzureSearchRagAgent(projectClient, deployment),
|
||||
"session-files" => CreateSessionFilesAgent(projectClient, deployment),
|
||||
"agent-skills" => CreateAgentSkillsAgent(projectClient, deployment),
|
||||
_ => throw new InvalidOperationException($"Unknown IT_SCENARIO '{scenario}'.")
|
||||
};
|
||||
|
||||
@@ -209,6 +210,77 @@ static async Task<AIAgent> CreateMemoryAgentAsync(AIProjectClient client, string
|
||||
});
|
||||
}
|
||||
|
||||
// Agent skills scenario. Uses AgentSkillsProvider with two bundled Contoso Outdoors skills
|
||||
// (support-style + escalation-policy). Skills are loaded from embedded SKILL.md files on disk,
|
||||
// simulating the download-from-Foundry pattern used by the Hosted-AgentSkills sample. When the
|
||||
// container starts, it writes the skills to a temp directory and wires AgentSkillsProvider over it.
|
||||
#pragma warning disable MEAI001 // AgentSkillsProvider is experimental
|
||||
static AIAgent CreateAgentSkillsAgent(AIProjectClient client, string deployment)
|
||||
{
|
||||
string skillsDir = Path.Combine(Path.GetTempPath(), "it-agent-skills-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(Path.Combine(skillsDir, "support-style"));
|
||||
Directory.CreateDirectory(Path.Combine(skillsDir, "escalation-policy"));
|
||||
|
||||
File.WriteAllText(Path.Combine(skillsDir, "support-style", "SKILL.md"),
|
||||
"""
|
||||
---
|
||||
name: support-style
|
||||
description: Contoso Outdoors customer-support tone and formatting guidelines.
|
||||
---
|
||||
|
||||
# Contoso Outdoors Support Style
|
||||
|
||||
You are speaking on behalf of Contoso Outdoors customer support.
|
||||
|
||||
## Voice
|
||||
|
||||
- Warm, concise, and confident.
|
||||
- Use the customer's name when known.
|
||||
- Sign every response with `— Contoso Outdoors Support`.
|
||||
|
||||
## Canary
|
||||
|
||||
To prove this skill was loaded, include the token `STYLE-CANARY-3318` on a
|
||||
separate line at the bottom of every response, prefixed with `# `.
|
||||
""");
|
||||
|
||||
File.WriteAllText(Path.Combine(skillsDir, "escalation-policy", "SKILL.md"),
|
||||
"""
|
||||
---
|
||||
name: escalation-policy
|
||||
description: When and how to escalate Contoso Outdoors customer-support tickets.
|
||||
---
|
||||
|
||||
# Contoso Outdoors Escalation Policy
|
||||
|
||||
## Escalate immediately when the customer
|
||||
|
||||
- Reports an injury or safety incident.
|
||||
- Mentions legal action, regulators, or the press.
|
||||
- Requests a refund larger than $500.
|
||||
|
||||
## How to escalate
|
||||
|
||||
1. Acknowledge the issue.
|
||||
2. Tell the customer you are escalating to a senior specialist.
|
||||
3. Provide the escalation reference `ESC-CANARY-7742`.
|
||||
""");
|
||||
|
||||
var skillsProvider = new AgentSkillsProvider(skillsDir, scriptRunner: null);
|
||||
|
||||
return client.AsAIAgent(new ChatClientAgentOptions
|
||||
{
|
||||
Name = "agent-skills-agent",
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
ModelId = deployment,
|
||||
Instructions = "You are a customer-support assistant for Contoso Outdoors.",
|
||||
},
|
||||
AIContextProviders = [skillsProvider]
|
||||
});
|
||||
}
|
||||
#pragma warning restore MEAI001
|
||||
|
||||
[Description("Returns the current UTC date and time as an ISO 8601 string.")]
|
||||
static string GetUtcNow() => DateTime.UtcNow.ToString("o");
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Foundry.Hosting.IntegrationTests.Fixtures;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Foundry.Hosting.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests that exercise the Agent Skills pattern in a hosted agent container.
|
||||
/// The container uses <see cref="Microsoft.Agents.AI.AgentSkillsProvider"/> with two
|
||||
/// Contoso Outdoors skills (support-style, escalation-policy) to verify the progressive
|
||||
/// disclosure flow: skills are advertised in the system prompt and loaded on demand via
|
||||
/// the <c>load_skill</c> tool when the model decides they are relevant.
|
||||
/// </summary>
|
||||
[Trait("Category", "FoundryHostedAgents")]
|
||||
public sealed class AgentSkillsHostedAgentTests(AgentSkillsHostedAgentFixture fixture) : IClassFixture<AgentSkillsHostedAgentFixture>
|
||||
{
|
||||
private readonly AgentSkillsHostedAgentFixture _fixture = fixture;
|
||||
|
||||
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
|
||||
public async Task RoutineQuestion_LoadsSupportStyleSkillAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agent = this._fixture.Agent;
|
||||
|
||||
// Act — ask a routine support question that should trigger the support-style skill
|
||||
var response = await agent.RunAsync(
|
||||
"Hi, I am Alex. I just want to confirm I can return my tent within 30 days.");
|
||||
|
||||
// Assert — response should contain the canary token proving the skill was loaded
|
||||
Assert.False(string.IsNullOrWhiteSpace(response.Text));
|
||||
Assert.Contains("STYLE-CANARY-3318", response.Text);
|
||||
}
|
||||
|
||||
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
|
||||
public async Task EscalationTrigger_LoadsEscalationPolicySkillAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agent = this._fixture.Agent;
|
||||
|
||||
// Act — trigger an escalation (legal threat + refund > $500)
|
||||
var response = await agent.RunAsync(
|
||||
"I want a $750 refund on Order #A-1042 right now or I am calling my lawyer.");
|
||||
|
||||
// Assert — response should contain the escalation canary token
|
||||
Assert.False(string.IsNullOrWhiteSpace(response.Text));
|
||||
Assert.Contains("ESC-CANARY-7742", response.Text);
|
||||
}
|
||||
|
||||
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
|
||||
public async Task SkillsAreAdvertised_LoadSkillToolIsAvailableAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agent = this._fixture.Agent;
|
||||
|
||||
// Act — ask the model what skills are available (triggers system prompt inspection)
|
||||
var response = await agent.RunAsync(
|
||||
"List the skills you have access to. Just give me their names.");
|
||||
|
||||
// Assert — both skills should be mentioned (they are advertised in the system prompt)
|
||||
Assert.False(string.IsNullOrWhiteSpace(response.Text));
|
||||
Assert.Contains("support-style", response.Text);
|
||||
Assert.Contains("escalation-policy", response.Text);
|
||||
}
|
||||
|
||||
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
|
||||
public async Task LoadSkill_InvokesToolAndReturnsContentAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agent = this._fixture.Agent;
|
||||
|
||||
// Act — ask a question that should load a specific skill
|
||||
var response = await agent.RunAsync(
|
||||
"I need to know the escalation policy for customer tickets. Load the escalation-policy skill and tell me the rules.");
|
||||
|
||||
// Assert — the response should reference the load_skill tool invocation
|
||||
Assert.False(string.IsNullOrWhiteSpace(response.Text));
|
||||
Assert.True(
|
||||
response.Messages.Any(m => m.Contents.OfType<FunctionCallContent>().Any(fc => fc.Name == "load_skill")),
|
||||
"Expected at least one load_skill FunctionCallContent in the response messages.");
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Foundry.Hosting.IntegrationTests.Fixtures;
|
||||
|
||||
/// <summary>
|
||||
/// Provisions a hosted agent that runs the test container in <c>IT_SCENARIO=agent-skills</c> mode.
|
||||
/// The container creates two Contoso Outdoors skills (support-style, escalation-policy) on disk
|
||||
/// and wires them into <see cref="Microsoft.Agents.AI.AgentSkillsProvider"/> so the model can
|
||||
/// discover and load skills via the progressive disclosure pattern.
|
||||
/// </summary>
|
||||
public sealed class AgentSkillsHostedAgentFixture : HostedAgentFixture
|
||||
{
|
||||
protected override string ScenarioName => "agent-skills";
|
||||
}
|
||||
@@ -199,6 +199,7 @@ human-only operation; CI only adds and deletes versions under existing agents.
|
||||
| `CustomStorageHostedAgentFixture` | `custom-storage` | `it-custom-storage` | Round trip with custom `IResponsesStorageProvider`; multi turn reads from the custom store (placeholder). |
|
||||
| `AzureSearchRagHostedAgentFixture` | `azure-search-rag` | `it-azure-search-rag` | RAG against a real Azure AI Search index seeded with Contoso Outdoors documents; verifies the model cites the retrieved sources. |
|
||||
| `SessionFilesHostedAgentFixture` | `session-files` | `it-session-files` | End-to-end: upload via `AgentSessionFiles` (alpha) into a pinned `agent_session_id`, invoke the agent, assert it reads the file via the container's `ReadFile` tool. |
|
||||
| `AgentSkillsHostedAgentFixture` | `agent-skills` | `it-agent-skills` | Agent skills via `AgentSkillsProvider`: advertises two Contoso Outdoors skills (support-style, escalation-policy) in the system prompt, loads them on demand via `load_skill`, verifies canary tokens prove the skill was loaded. |
|
||||
|
||||
The placeholder scenarios will be wired up in the test container `Program.cs` once the
|
||||
relevant `Microsoft.Agents.AI.Foundry.Hosting` API surfaces stabilize.
|
||||
|
||||
@@ -47,7 +47,8 @@ $Scenarios = @(
|
||||
'custom-storage',
|
||||
'memory',
|
||||
'azure-search-rag',
|
||||
'session-files'
|
||||
'session-files',
|
||||
'agent-skills'
|
||||
)
|
||||
|
||||
# Resolve project ARM scope from the endpoint.
|
||||
|
||||
@@ -914,4 +914,147 @@ public sealed class AGUIChatMessageExtensionsTests
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Consecutive Assistant-Tool-Call Coalescing
|
||||
|
||||
/// <summary>
|
||||
/// Bug #3 reproduction: consecutive AGUIAssistantMessages with ToolCalls should
|
||||
/// be coalesced into a single ChatMessage with multiple FunctionCallContent
|
||||
/// entries. Without coalescing, Azure OpenAI rejects the history with HTTP 400.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AsChatMessages_ConsecutiveAssistantToolCallMessages_CoalesceIntoOneChatMessage()
|
||||
{
|
||||
// Arrange — 3 consecutive assistant messages with tool calls (no intervening tool msg)
|
||||
List<AGUIMessage> aguiMessages =
|
||||
[
|
||||
new AGUIUserMessage { Id = "user-1", Content = "Run 3 queries" },
|
||||
new AGUIAssistantMessage
|
||||
{
|
||||
Id = "asst-1",
|
||||
Content = "",
|
||||
ToolCalls =
|
||||
[
|
||||
new AGUIToolCall { Id = "call_A", Type = "function", Function = new AGUIFunctionCall { Name = "query", Arguments = "{\"q\":\"1\"}" } }
|
||||
]
|
||||
},
|
||||
new AGUIAssistantMessage
|
||||
{
|
||||
Id = "asst-2",
|
||||
Content = "",
|
||||
ToolCalls =
|
||||
[
|
||||
new AGUIToolCall { Id = "call_B", Type = "function", Function = new AGUIFunctionCall { Name = "query", Arguments = "{\"q\":\"2\"}" } }
|
||||
]
|
||||
},
|
||||
new AGUIAssistantMessage
|
||||
{
|
||||
Id = "asst-3",
|
||||
Content = "",
|
||||
ToolCalls =
|
||||
[
|
||||
new AGUIToolCall { Id = "call_C", Type = "function", Function = new AGUIFunctionCall { Name = "query", Arguments = "{\"q\":\"3\"}" } }
|
||||
]
|
||||
},
|
||||
new AGUIToolMessage { Id = "tool-1", ToolCallId = "call_A", Content = "\"result1\"" },
|
||||
new AGUIToolMessage { Id = "tool-2", ToolCallId = "call_B", Content = "\"result2\"" },
|
||||
new AGUIToolMessage { Id = "tool-3", ToolCallId = "call_C", Content = "\"result3\"" },
|
||||
new AGUIUserMessage { Id = "user-2", Content = "Run it again" },
|
||||
];
|
||||
|
||||
// Act
|
||||
List<ChatMessage> chatMessages = aguiMessages.AsChatMessages(AGUIJsonSerializerContext.Default.Options).ToList();
|
||||
|
||||
// Assert — the 3 consecutive assistant-tool-call messages should coalesce into 1
|
||||
List<ChatMessage> assistantWithToolCalls = chatMessages
|
||||
.Where(m => m.Role == ChatRole.Assistant && m.Contents.OfType<FunctionCallContent>().Any())
|
||||
.ToList();
|
||||
|
||||
Assert.Single(assistantWithToolCalls);
|
||||
|
||||
// The single coalesced message should contain all 3 FunctionCallContent entries
|
||||
List<FunctionCallContent> functionCalls = assistantWithToolCalls[0].Contents
|
||||
.OfType<FunctionCallContent>().ToList();
|
||||
Assert.Equal(3, functionCalls.Count);
|
||||
Assert.Equal("call_A", functionCalls[0].CallId);
|
||||
Assert.Equal("call_B", functionCalls[1].CallId);
|
||||
Assert.Equal("call_C", functionCalls[2].CallId);
|
||||
|
||||
// MessageId should be from the first message in the coalesced group
|
||||
Assert.Equal("asst-1", assistantWithToolCalls[0].MessageId);
|
||||
|
||||
// Total messages: user + coalesced assistant + 3 tools + user = 6
|
||||
Assert.Equal(6, chatMessages.Count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A single assistant message with tool calls (not consecutive) should still
|
||||
/// produce one ChatMessage — no behavior change from coalescing logic.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AsChatMessages_SingleAssistantToolCallMessage_ProducesOneChatMessage()
|
||||
{
|
||||
// Arrange
|
||||
List<AGUIMessage> aguiMessages =
|
||||
[
|
||||
new AGUIAssistantMessage
|
||||
{
|
||||
Id = "asst-1",
|
||||
Content = "Here are the results",
|
||||
ToolCalls =
|
||||
[
|
||||
new AGUIToolCall { Id = "call_A", Type = "function", Function = new AGUIFunctionCall { Name = "query", Arguments = "{}" } },
|
||||
new AGUIToolCall { Id = "call_B", Type = "function", Function = new AGUIFunctionCall { Name = "query", Arguments = "{}" } },
|
||||
]
|
||||
},
|
||||
new AGUIToolMessage { Id = "tool-1", ToolCallId = "call_A", Content = "\"r1\"" },
|
||||
new AGUIToolMessage { Id = "tool-2", ToolCallId = "call_B", Content = "\"r2\"" },
|
||||
];
|
||||
|
||||
// Act
|
||||
List<ChatMessage> chatMessages = aguiMessages.AsChatMessages(AGUIJsonSerializerContext.Default.Options).ToList();
|
||||
|
||||
// Assert — single assistant message, not coalesced from multiple
|
||||
Assert.Equal(3, chatMessages.Count);
|
||||
Assert.Equal(ChatRole.Assistant, chatMessages[0].Role);
|
||||
List<FunctionCallContent> calls = chatMessages[0].Contents.OfType<FunctionCallContent>().ToList();
|
||||
Assert.Equal(2, calls.Count);
|
||||
Assert.Equal("asst-1", chatMessages[0].MessageId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When consecutive assistant-tool-call messages are at the END of the stream
|
||||
/// (no subsequent non-tool-call message to trigger flush), they should still
|
||||
/// be coalesced and flushed.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AsChatMessages_ConsecutiveAssistantToolCallsAtEndOfStream_FlushesCorrectly()
|
||||
{
|
||||
// Arrange — stream ends with consecutive assistant tool-call messages
|
||||
List<AGUIMessage> aguiMessages =
|
||||
[
|
||||
new AGUIUserMessage { Id = "user-1", Content = "Do things" },
|
||||
new AGUIAssistantMessage
|
||||
{
|
||||
Id = "asst-1",
|
||||
ToolCalls = [new AGUIToolCall { Id = "call_X", Type = "function", Function = new AGUIFunctionCall { Name = "fn", Arguments = "{}" } }]
|
||||
},
|
||||
new AGUIAssistantMessage
|
||||
{
|
||||
Id = "asst-2",
|
||||
ToolCalls = [new AGUIToolCall { Id = "call_Y", Type = "function", Function = new AGUIFunctionCall { Name = "fn", Arguments = "{}" } }]
|
||||
},
|
||||
];
|
||||
|
||||
// Act
|
||||
List<ChatMessage> chatMessages = aguiMessages.AsChatMessages(AGUIJsonSerializerContext.Default.Options).ToList();
|
||||
|
||||
// Assert — should be user + 1 coalesced assistant = 2 messages
|
||||
Assert.Equal(2, chatMessages.Count);
|
||||
Assert.Equal(ChatRole.User, chatMessages[0].Role);
|
||||
Assert.Equal(ChatRole.Assistant, chatMessages[1].Role);
|
||||
Assert.Equal(2, chatMessages[1].Contents.OfType<FunctionCallContent>().Count());
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -109,11 +109,13 @@ public sealed class AGUIStreamingMessageIdTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When ChatResponseUpdate has empty string MessageId, the AGUI layer generates
|
||||
/// a fallback so ToolCallStartEvent.ParentMessageId is valid.
|
||||
/// When ChatResponseUpdate has empty string MessageId, the AGUI layer passes
|
||||
/// through the raw provider value for ToolCallStartEvent.ParentMessageId.
|
||||
/// Tool-call chunks should NOT receive the text-event fallback GUID — that
|
||||
/// would collapse parallel tool calls into one assistant message in the FE.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ToolCalls_EmptyMessageId_GeneratesFallbackParentMessageIdAsync()
|
||||
public async Task ToolCalls_EmptyMessageId_DoesNotGenerateFallbackParentMessageIdAsync()
|
||||
{
|
||||
// Arrange - ChatResponseUpdate with a tool call but empty MessageId
|
||||
FunctionCallContent functionCall = new("call_abc123", "GetWeather")
|
||||
@@ -139,14 +141,14 @@ public sealed class AGUIStreamingMessageIdTests
|
||||
aguiEvents.Add(evt);
|
||||
}
|
||||
|
||||
// Assert — ParentMessageId should have a generated fallback
|
||||
// Assert — ParentMessageId should be empty (raw provider value, no synthetic fallback)
|
||||
ToolCallStartEvent? toolCallStart = aguiEvents.OfType<ToolCallStartEvent>().FirstOrDefault();
|
||||
Assert.NotNull(toolCallStart);
|
||||
Assert.Equal("call_abc123", toolCallStart.ToolCallId);
|
||||
Assert.Equal("GetWeather", toolCallStart.ToolCallName);
|
||||
Assert.False(
|
||||
Assert.True(
|
||||
string.IsNullOrEmpty(toolCallStart.ParentMessageId),
|
||||
"ParentMessageId should have a generated fallback for empty provider MessageId");
|
||||
"ParentMessageId should be empty when provider omits MessageId (raw pass-through)");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -183,10 +185,13 @@ public sealed class AGUIStreamingMessageIdTests
|
||||
ToolCallStartEvent toolCallStart = Assert.Single(aguiEvents.OfType<ToolCallStartEvent>());
|
||||
ToolCallResultEvent toolCallResult = Assert.Single(aguiEvents.OfType<ToolCallResultEvent>());
|
||||
|
||||
Assert.Equal(textStart.MessageId, toolCallStart.ParentMessageId);
|
||||
// Tool-call ParentMessageId should NOT leak the text fallback GUID
|
||||
Assert.NotEqual(textStart.MessageId, toolCallStart.ParentMessageId);
|
||||
Assert.Equal("call_abc123", toolCallResult.ToolCallId);
|
||||
Assert.False(string.IsNullOrEmpty(toolCallResult.MessageId));
|
||||
Assert.NotEqual(textStart.MessageId, toolCallResult.MessageId);
|
||||
// Result MessageId should be deterministic based on CallId
|
||||
Assert.Equal("result-call_abc123", toolCallResult.MessageId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -230,10 +235,11 @@ public sealed class AGUIStreamingMessageIdTests
|
||||
ToolCallStartEvent toolCallStart = Assert.Single(aguiEvents.OfType<ToolCallStartEvent>());
|
||||
ToolCallResultEvent toolCallResult = Assert.Single(aguiEvents.OfType<ToolCallResultEvent>());
|
||||
|
||||
Assert.Equal(textStarts[0].MessageId, toolCallStart.ParentMessageId);
|
||||
// Tool-call ParentMessageId should NOT leak the text fallback GUID
|
||||
Assert.NotEqual(textStarts[0].MessageId, toolCallStart.ParentMessageId);
|
||||
Assert.NotEqual(textStarts[0].MessageId, toolCallResult.MessageId);
|
||||
Assert.Equal(toolCallResult.MessageId, toolText.MessageId);
|
||||
Assert.Equal(textStarts[^1].MessageId, toolCallResult.MessageId);
|
||||
// Result MessageId should be deterministic based on CallId
|
||||
Assert.Equal("result-call_abc123", toolCallResult.MessageId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -274,6 +280,86 @@ public sealed class AGUIStreamingMessageIdTests
|
||||
Assert.Equal(2, contentEvents.Count);
|
||||
Assert.All(contentEvents, e => Assert.Equal("chatcmpl-abc123", e.MessageId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bug #1 reproduction: parallel tool calls with empty MessageId should NOT all
|
||||
/// share the same synthetic ParentMessageId. Each should pass through the raw
|
||||
/// provider value (empty), allowing the FE to render them as distinct cards.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ParallelToolCalls_EmptyMessageId_DoNotShareParentMessageIdAsync()
|
||||
{
|
||||
// Arrange — 3 parallel tool calls with empty MessageId (real OpenAI behavior)
|
||||
List<ChatResponseUpdate> providerUpdates =
|
||||
[
|
||||
new ChatResponseUpdate(ChatRole.Assistant, "Let me run those queries.") { MessageId = "chatcmpl-real" },
|
||||
new ChatResponseUpdate { Role = ChatRole.Assistant, MessageId = "", Contents = [new FunctionCallContent("call_A", "query") { Arguments = new Dictionary<string, object?> { ["q"] = "1" } }] },
|
||||
new ChatResponseUpdate { Role = ChatRole.Assistant, MessageId = "", Contents = [new FunctionCallContent("call_B", "query") { Arguments = new Dictionary<string, object?> { ["q"] = "2" } }] },
|
||||
new ChatResponseUpdate { Role = ChatRole.Assistant, MessageId = "", Contents = [new FunctionCallContent("call_C", "query") { Arguments = new Dictionary<string, object?> { ["q"] = "3" } }] },
|
||||
];
|
||||
|
||||
// Act
|
||||
List<BaseEvent> aguiEvents = [];
|
||||
await foreach (BaseEvent evt in providerUpdates.ToAsyncEnumerableAsync()
|
||||
.AsAGUIEventStreamAsync("thread-1", "run-1", AGUIJsonSerializerContext.Default.Options))
|
||||
{
|
||||
aguiEvents.Add(evt);
|
||||
}
|
||||
|
||||
// Assert — all 3 tool calls should have empty ParentMessageId (raw provider value),
|
||||
// NOT the text fallback GUID
|
||||
List<ToolCallStartEvent> toolCallStarts = aguiEvents.OfType<ToolCallStartEvent>().ToList();
|
||||
Assert.Equal(3, toolCallStarts.Count);
|
||||
Assert.All(toolCallStarts, tc => Assert.True(string.IsNullOrEmpty(tc.ParentMessageId)));
|
||||
|
||||
// Text events should still have a valid fallback MessageId
|
||||
TextMessageStartEvent textStart = Assert.Single(aguiEvents.OfType<TextMessageStartEvent>());
|
||||
Assert.False(string.IsNullOrEmpty(textStart.MessageId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bug #2 reproduction: tool results batched into one ChatResponseUpdate with a
|
||||
/// shared MEAI MessageId should each get a unique deterministic MessageId.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ToolCallResults_SharedMeaiMessageId_HaveUniqueMessageIdsPerCallAsync()
|
||||
{
|
||||
// Arrange — MEAI batches all FunctionResultContent into one update with shared id
|
||||
List<ChatResponseUpdate> providerUpdates =
|
||||
[
|
||||
new ChatResponseUpdate
|
||||
{
|
||||
Role = ChatRole.Tool,
|
||||
MessageId = "meai-shared-id",
|
||||
Contents =
|
||||
[
|
||||
new FunctionResultContent("call_A", "result1"),
|
||||
new FunctionResultContent("call_B", "result2"),
|
||||
new FunctionResultContent("call_C", "result3"),
|
||||
]
|
||||
},
|
||||
];
|
||||
|
||||
// Act
|
||||
List<BaseEvent> aguiEvents = [];
|
||||
await foreach (BaseEvent evt in providerUpdates.ToAsyncEnumerableAsync()
|
||||
.AsAGUIEventStreamAsync("thread-1", "run-1", AGUIJsonSerializerContext.Default.Options))
|
||||
{
|
||||
aguiEvents.Add(evt);
|
||||
}
|
||||
|
||||
// Assert — each result should have a unique MessageId
|
||||
List<ToolCallResultEvent> toolResults = aguiEvents.OfType<ToolCallResultEvent>().ToList();
|
||||
Assert.Equal(3, toolResults.Count);
|
||||
|
||||
string?[] distinctIds = toolResults.Select(r => r.MessageId).Distinct().ToArray();
|
||||
Assert.Equal(3, distinctIds.Length);
|
||||
|
||||
// Verify deterministic format
|
||||
Assert.Equal("result-call_A", toolResults[0].MessageId);
|
||||
Assert.Equal("result-call_B", toolResults[1].MessageId);
|
||||
Assert.Equal("result-call_C", toolResults[2].MessageId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
+2
@@ -7,6 +7,8 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="ModelContextProtocol" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ModelContextProtocol.Protocol;
|
||||
using ModelContextProtocol.Server;
|
||||
|
||||
namespace Microsoft.Agents.AI.Skills.Mcp.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="AgentMcpSkillsSource"/>.
|
||||
/// </summary>
|
||||
public sealed class AgentMcpSkillsSourceTests
|
||||
{
|
||||
private const string SampleSkillMd = """
|
||||
---
|
||||
name: unit-converter
|
||||
description: Convert between common units.
|
||||
---
|
||||
# Unit Converter
|
||||
|
||||
Body content here.
|
||||
""";
|
||||
|
||||
private const string SampleSkillIndex = """
|
||||
{
|
||||
"$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
|
||||
"skills": [
|
||||
{
|
||||
"name": "unit-converter",
|
||||
"type": "skill-md",
|
||||
"description": "Convert between common units.",
|
||||
"url": "skill://unit-converter/SKILL.md"
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_IndexBasedDiscovery_ReturnsSkillAsync()
|
||||
{
|
||||
// Arrange — server exposes both skill://index.json and the skill itself.
|
||||
await using var server = new InMemoryMcpServer(builder =>
|
||||
builder.WithResources<IndexAndSkill>());
|
||||
await using var client = await server.CreateClientAsync();
|
||||
var source = new AgentMcpSkillsSource(client);
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync();
|
||||
|
||||
// Assert — frontmatter comes from index; Content is the actual SKILL.md body from the server.
|
||||
var skill = Assert.Single(skills);
|
||||
Assert.Equal("unit-converter", skill.Frontmatter.Name);
|
||||
Assert.Equal("Convert between common units.", skill.Frontmatter.Description);
|
||||
|
||||
string content = await skill.GetContentAsync();
|
||||
Assert.Contains("name: unit-converter", content);
|
||||
Assert.Contains("description: Convert between common units.", content);
|
||||
Assert.Contains("Body content here.", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_NoIndex_ReturnsEmptyAsync()
|
||||
{
|
||||
// Arrange — server only exposes SKILL.md, no skill://index.json.
|
||||
// Per SEP-2640, discovery requires the index document; without it, no skills are surfaced.
|
||||
await using var server = new InMemoryMcpServer(builder =>
|
||||
builder.WithResources<SkillOnly>());
|
||||
await using var client = await server.CreateClientAsync();
|
||||
var source = new AgentMcpSkillsSource(client);
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Empty(skills);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetResourceAsync_SiblingText_ReturnsContentAsync()
|
||||
{
|
||||
// Arrange — server exposes index, SKILL.md, and a sibling reference file.
|
||||
// The skill reads the sibling on demand via GetResourceAsync.
|
||||
await using var server = new InMemoryMcpServer(builder =>
|
||||
builder.WithResources<IndexAndSkillWithSibling>());
|
||||
await using var client = await server.CreateClientAsync();
|
||||
var source = new AgentMcpSkillsSource(client);
|
||||
|
||||
// Act
|
||||
var skill = Assert.Single(await source.GetSkillsAsync());
|
||||
var resource = await skill.GetResourceAsync("references/checklist.md");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(resource);
|
||||
var content = await resource!.ReadAsync();
|
||||
Assert.Equal("- check thing 1\n- check thing 2", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetResourceAsync_SiblingBinary_ReturnsDataContentAsync()
|
||||
{
|
||||
// Arrange — server exposes index, SKILL.md, and a binary sibling.
|
||||
await using var server = new InMemoryMcpServer(builder =>
|
||||
builder.WithResources<IndexAndSkillWithBinarySibling>());
|
||||
await using var client = await server.CreateClientAsync();
|
||||
var source = new AgentMcpSkillsSource(client);
|
||||
|
||||
// Act
|
||||
var skill = Assert.Single(await source.GetSkillsAsync());
|
||||
var resource = await skill.GetResourceAsync("assets/icon.bin");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(resource);
|
||||
var content = await resource!.ReadAsync();
|
||||
var dataContent = Assert.IsType<DataContent>(content);
|
||||
Assert.Equal("application/octet-stream", dataContent.MediaType);
|
||||
Assert.Equal([0x01, 0x02, 0x03, 0x04], dataContent.Data.ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetResourceAsync_UnknownName_ReturnsNullAsync()
|
||||
{
|
||||
// Arrange — index advertises a skill, but no sibling resource exists.
|
||||
// GetResourceAsync eagerly fetches from the MCP server; a non-existent
|
||||
// resource causes the server to return an error, so null is returned.
|
||||
await using var server = new InMemoryMcpServer(builder =>
|
||||
builder.WithResources<IndexAndSkill>());
|
||||
await using var client = await server.CreateClientAsync();
|
||||
var source = new AgentMcpSkillsSource(client);
|
||||
|
||||
// Act
|
||||
var skill = Assert.Single(await source.GetSkillsAsync());
|
||||
var resource = await skill.GetResourceAsync("references/does-not-exist.md");
|
||||
|
||||
// Assert — resource does not exist on the server, so null is returned
|
||||
Assert.Null(resource);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("../escape.md")]
|
||||
[InlineData("references/../../escape.md")]
|
||||
[InlineData("..")]
|
||||
public async Task GetResourceAsync_PathTraversalName_ReturnsNullAsync(string name)
|
||||
{
|
||||
// Arrange — '..' segments result in URIs that don't match any server resource.
|
||||
// The MCP server returns an error for unknown URIs, so GetResourceAsync returns null.
|
||||
await using var server = new InMemoryMcpServer(builder =>
|
||||
builder.WithResources<IndexAndSkill>());
|
||||
await using var client = await server.CreateClientAsync();
|
||||
var source = new AgentMcpSkillsSource(client);
|
||||
|
||||
// Act
|
||||
var skill = Assert.Single(await source.GetSkillsAsync());
|
||||
var resource = await skill.GetResourceAsync(name);
|
||||
|
||||
// Assert — resource does not exist on the server, so null is returned
|
||||
Assert.Null(resource);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_DoesNotReadSkillMdAsync()
|
||||
{
|
||||
// Arrange — index points to a non-existent SKILL.md URI. Because the source builds
|
||||
// skills from index info only, discovery still succeeds.
|
||||
await using var server = new InMemoryMcpServer(builder =>
|
||||
builder.WithResources<IndexWithoutSkillMdResource>());
|
||||
await using var client = await server.CreateClientAsync();
|
||||
var source = new AgentMcpSkillsSource(client);
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync();
|
||||
|
||||
// Assert — discovery succeeds from index alone.
|
||||
var skill = Assert.Single(skills);
|
||||
Assert.Equal("unit-converter", skill.Frontmatter.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_IndexEntryWithInvalidName_IsSkippedAsync()
|
||||
{
|
||||
// Arrange — index entry has an invalid (uppercase) name, which AgentSkillFrontmatter rejects.
|
||||
await using var server = new InMemoryMcpServer(builder =>
|
||||
builder.WithResources<IndexWithInvalidName>());
|
||||
await using var client = await server.CreateClientAsync();
|
||||
var source = new AgentMcpSkillsSource(client);
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Empty(skills);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_IndexEntryWithMissingRequiredFields_IsSkippedAsync()
|
||||
{
|
||||
// Arrange — index entry is missing the required description and url fields.
|
||||
await using var server = new InMemoryMcpServer(builder =>
|
||||
builder.WithResources<IndexWithIncompleteEntry>());
|
||||
await using var client = await server.CreateClientAsync();
|
||||
var source = new AgentMcpSkillsSource(client);
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Empty(skills);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_IndexEntryWithUnsupportedType_IsSkippedAsync()
|
||||
{
|
||||
// Arrange — index has an "archive" entry, which this source does not support.
|
||||
await using var server = new InMemoryMcpServer(builder =>
|
||||
builder.WithResources<IndexWithArchiveOnly>());
|
||||
await using var client = await server.CreateClientAsync();
|
||||
var source = new AgentMcpSkillsSource(client);
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Empty(skills);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_IndexEntryWithTemplateType_IsSkippedAsync()
|
||||
{
|
||||
// Arrange — index has an "mcp-resource-template" entry (parameterized skill namespace).
|
||||
// The current source skips template entries; they require user input to materialize.
|
||||
await using var server = new InMemoryMcpServer(builder =>
|
||||
builder.WithResources<IndexWithTemplateOnly>());
|
||||
await using var client = await server.CreateClientAsync();
|
||||
var source = new AgentMcpSkillsSource(client);
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Empty(skills);
|
||||
}
|
||||
|
||||
#region Resource classes (registered with the MCP server via WithResources<T>)
|
||||
|
||||
// CA1812 flags these classes as "never instantiated", which is technically correct —
|
||||
// they are never constructed because they only contain static methods (e.g. `public static string Index()`).
|
||||
// The MCP framework discovers and invokes these static methods via the [McpServerResourceType] and
|
||||
// [McpServerResource] attributes registered through WithResources<T>(), without ever creating an instance.
|
||||
#pragma warning disable CA1812
|
||||
|
||||
/// <summary>
|
||||
/// Server type that exposes both <c>skill://index.json</c> and a single <c>skill-md</c> resource.
|
||||
/// </summary>
|
||||
[McpServerResourceType]
|
||||
private sealed class IndexAndSkill
|
||||
{
|
||||
[McpServerResource(UriTemplate = "skill://index.json", Name = "index", MimeType = "application/json")]
|
||||
public static string Index() => SampleSkillIndex;
|
||||
|
||||
[McpServerResource(UriTemplate = "skill://unit-converter/SKILL.md", Name = "unit-converter", MimeType = "text/markdown")]
|
||||
public static string Skill() => SampleSkillMd;
|
||||
}
|
||||
|
||||
/// <summary>Server type that exposes only <c>SKILL.md</c> (no index, no siblings).</summary>
|
||||
[McpServerResourceType]
|
||||
private sealed class SkillOnly
|
||||
{
|
||||
[McpServerResource(UriTemplate = "skill://unit-converter/SKILL.md", Name = "unit-converter", MimeType = "text/markdown")]
|
||||
public static string Skill() => SampleSkillMd;
|
||||
}
|
||||
|
||||
/// <summary>Server type that exposes <c>skill://index.json</c>, <c>SKILL.md</c>, and one text sibling.</summary>
|
||||
[McpServerResourceType]
|
||||
private sealed class IndexAndSkillWithSibling
|
||||
{
|
||||
[McpServerResource(UriTemplate = "skill://index.json", Name = "index", MimeType = "application/json")]
|
||||
public static string Index() => SampleSkillIndex;
|
||||
|
||||
[McpServerResource(UriTemplate = "skill://unit-converter/SKILL.md", Name = "unit-converter", MimeType = "text/markdown")]
|
||||
public static string Skill() => SampleSkillMd;
|
||||
|
||||
[McpServerResource(UriTemplate = "skill://unit-converter/references/checklist.md", Name = "checklist", MimeType = "text/markdown")]
|
||||
public static string Checklist() => "- check thing 1\n- check thing 2";
|
||||
}
|
||||
|
||||
/// <summary>Server type that exposes <c>skill://index.json</c>, <c>SKILL.md</c>, and one binary sibling.</summary>
|
||||
[McpServerResourceType]
|
||||
private sealed class IndexAndSkillWithBinarySibling
|
||||
{
|
||||
[McpServerResource(UriTemplate = "skill://index.json", Name = "index", MimeType = "application/json")]
|
||||
public static string Index() => SampleSkillIndex;
|
||||
|
||||
[McpServerResource(UriTemplate = "skill://unit-converter/SKILL.md", Name = "unit-converter", MimeType = "text/markdown")]
|
||||
public static string Skill() => SampleSkillMd;
|
||||
|
||||
[McpServerResource(UriTemplate = "skill://unit-converter/assets/icon.bin", Name = "icon", MimeType = "application/octet-stream")]
|
||||
public static BlobResourceContents Icon() => BlobResourceContents.FromBytes(
|
||||
new byte[] { 0x01, 0x02, 0x03, 0x04 },
|
||||
"skill://unit-converter/assets/icon.bin",
|
||||
"application/octet-stream");
|
||||
}
|
||||
|
||||
/// <summary>Server type that exposes only the index (no concrete SKILL.md resource).</summary>
|
||||
[McpServerResourceType]
|
||||
private sealed class IndexWithoutSkillMdResource
|
||||
{
|
||||
[McpServerResource(UriTemplate = "skill://index.json", Name = "index", MimeType = "application/json")]
|
||||
public static string Index() => SampleSkillIndex;
|
||||
}
|
||||
|
||||
/// <summary>Server type whose index entry has an invalid (uppercase) name.</summary>
|
||||
[McpServerResourceType]
|
||||
private sealed class IndexWithInvalidName
|
||||
{
|
||||
[McpServerResource(UriTemplate = "skill://index.json", Name = "index", MimeType = "application/json")]
|
||||
public static string Index() => """
|
||||
{
|
||||
"$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
|
||||
"skills": [
|
||||
{
|
||||
"name": "UnitConverter",
|
||||
"type": "skill-md",
|
||||
"description": "Convert between common units.",
|
||||
"url": "skill://UnitConverter/SKILL.md"
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
}
|
||||
|
||||
/// <summary>Server type whose index entry is missing required fields (description, url).</summary>
|
||||
[McpServerResourceType]
|
||||
private sealed class IndexWithIncompleteEntry
|
||||
{
|
||||
[McpServerResource(UriTemplate = "skill://index.json", Name = "index", MimeType = "application/json")]
|
||||
public static string Index() => """
|
||||
{
|
||||
"$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
|
||||
"skills": [
|
||||
{
|
||||
"name": "unit-converter",
|
||||
"type": "skill-md"
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
}
|
||||
|
||||
/// <summary>Server type whose index references only an <c>archive</c> entry.</summary>
|
||||
[McpServerResourceType]
|
||||
private sealed class IndexWithArchiveOnly
|
||||
{
|
||||
[McpServerResource(UriTemplate = "skill://index.json", Name = "index", MimeType = "application/json")]
|
||||
public static string Index() => """
|
||||
{
|
||||
"$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
|
||||
"skills": [
|
||||
{
|
||||
"name": "some-skill",
|
||||
"type": "archive",
|
||||
"description": "Packaged skill.",
|
||||
"url": "skill://some-skill.tar.gz"
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
}
|
||||
|
||||
/// <summary>Server type whose index references only an <c>mcp-resource-template</c> entry.</summary>
|
||||
[McpServerResourceType]
|
||||
private sealed class IndexWithTemplateOnly
|
||||
{
|
||||
[McpServerResource(UriTemplate = "skill://index.json", Name = "index", MimeType = "application/json")]
|
||||
public static string Index() => """
|
||||
{
|
||||
"$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
|
||||
"skills": [
|
||||
{
|
||||
"type": "mcp-resource-template",
|
||||
"description": "Per-product documentation skill",
|
||||
"url": "skill://docs/{product}/SKILL.md"
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
}
|
||||
|
||||
#pragma warning restore CA1812
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.IO.Pipelines;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ModelContextProtocol.Client;
|
||||
using ModelContextProtocol.Protocol;
|
||||
using ModelContextProtocol.Server;
|
||||
|
||||
namespace Microsoft.Agents.AI.Skills.Mcp.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Spins up an in-memory MCP server hosting a configurable set of resources, and returns an
|
||||
/// <see cref="McpClient"/> connected to it. Disposes both ends together.
|
||||
/// </summary>
|
||||
[SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated by AgentMcpSkillsSourceTests which is temporarily excluded from compilation.")]
|
||||
internal sealed class InMemoryMcpServer : IAsyncDisposable
|
||||
{
|
||||
private readonly Pipe _clientToServerPipe = new();
|
||||
private readonly Pipe _serverToClientPipe = new();
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private readonly ServiceProvider _serviceProvider;
|
||||
private readonly Task _serverTask;
|
||||
|
||||
public InMemoryMcpServer(Action<IMcpServerBuilder> configure)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging(builder => builder.AddProvider(NullLoggerProvider.Instance));
|
||||
|
||||
IMcpServerBuilder builder = services
|
||||
.AddMcpServer()
|
||||
.WithStreamServerTransport(
|
||||
inputStream: this._clientToServerPipe.Reader.AsStream(),
|
||||
outputStream: this._serverToClientPipe.Writer.AsStream());
|
||||
|
||||
configure(builder);
|
||||
|
||||
this._serviceProvider = services.BuildServiceProvider();
|
||||
var server = this._serviceProvider.GetRequiredService<McpServer>();
|
||||
this._serverTask = server.RunAsync(this._cts.Token);
|
||||
}
|
||||
|
||||
public async Task<McpClient> CreateClientAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await McpClient.CreateAsync(
|
||||
new StreamClientTransport(
|
||||
serverInput: this._clientToServerPipe.Writer.AsStream(),
|
||||
serverOutput: this._serverToClientPipe.Reader.AsStream()),
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await this._cts.CancelAsync().ConfigureAwait(false);
|
||||
|
||||
this._clientToServerPipe.Writer.Complete();
|
||||
this._serverToClientPipe.Writer.Complete();
|
||||
|
||||
try
|
||||
{
|
||||
await this._serverTask.ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Expected when the server is cancelled during shutdown.
|
||||
}
|
||||
|
||||
await this._serviceProvider.DisposeAsync().ConfigureAwait(false);
|
||||
this._cts.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
|
||||
public sealed class AgentClassSkillTests
|
||||
{
|
||||
[Fact]
|
||||
public void MinimalClassSkill_HasNullOverrides_AndSynthesizesContent()
|
||||
public async Task MinimalClassSkill_HasNullOverrides_AndSynthesizesContentAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new MinimalClassSkill();
|
||||
@@ -26,18 +26,17 @@ public sealed class AgentClassSkillTests
|
||||
// Act & Assert — null overrides
|
||||
Assert.Equal("minimal", skill.Frontmatter.Name);
|
||||
Assert.Null(skill.Resources);
|
||||
Assert.Null(skill.Scripts);
|
||||
|
||||
// Act & Assert — synthesized XML content
|
||||
Assert.Contains("<name>minimal</name>", skill.Content);
|
||||
Assert.Contains("<description>A minimal skill.</description>", skill.Content);
|
||||
Assert.Contains("<instructions>", skill.Content);
|
||||
Assert.Contains("Minimal skill body.", skill.Content);
|
||||
Assert.Contains("</instructions>", skill.Content);
|
||||
Assert.Contains("<name>minimal</name>", await skill.GetContentAsync());
|
||||
Assert.Contains("<description>A minimal skill.</description>", await skill.GetContentAsync());
|
||||
Assert.Contains("<instructions>", await skill.GetContentAsync());
|
||||
Assert.Contains("Minimal skill body.", await skill.GetContentAsync());
|
||||
Assert.Contains("</instructions>", await skill.GetContentAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FullClassSkill_ReturnsOverriddenLists_AndCachesContent()
|
||||
public async Task FullClassSkill_ReturnsOverriddenLists_AndCachesContentAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new FullClassSkill();
|
||||
@@ -50,11 +49,11 @@ public sealed class AgentClassSkillTests
|
||||
Assert.Equal("TestScript", skill.Scripts![0].Name);
|
||||
|
||||
// Act & Assert — Content is cached
|
||||
Assert.Same(skill.Content, skill.Content);
|
||||
Assert.Same(await skill.GetContentAsync(), await skill.GetContentAsync());
|
||||
|
||||
// Act & Assert — Content includes parameter schema from typed script
|
||||
Assert.Contains("parameters_schema", skill.Content);
|
||||
Assert.Contains("value", skill.Content);
|
||||
Assert.Contains("parameters_schema", await skill.GetContentAsync());
|
||||
Assert.Contains("value", await skill.GetContentAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -117,6 +116,116 @@ public sealed class AgentClassSkillTests
|
||||
Assert.Single(scriptOnly.Scripts!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetResourceAsync_ExistingName_ReturnsResourceAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new FullClassSkill();
|
||||
|
||||
// Act
|
||||
var resource = await skill.GetResourceAsync("test-resource");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(resource);
|
||||
Assert.Equal("test-resource", resource!.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetResourceAsync_NonExistingName_ReturnsNullAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new FullClassSkill();
|
||||
|
||||
// Act
|
||||
var resource = await skill.GetResourceAsync("missing");
|
||||
|
||||
// Assert
|
||||
Assert.Null(resource);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetResourceAsync_NoResources_ReturnsNullAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new MinimalClassSkill();
|
||||
|
||||
// Act
|
||||
var resource = await skill.GetResourceAsync("anything");
|
||||
|
||||
// Assert
|
||||
Assert.Null(resource);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetScriptAsync_ExistingName_ReturnsScriptAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new FullClassSkill();
|
||||
|
||||
// Act
|
||||
var script = await skill.GetScriptAsync("TestScript");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(script);
|
||||
Assert.Equal("TestScript", script!.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetScriptAsync_NonExistingName_ReturnsNullAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new FullClassSkill();
|
||||
|
||||
// Act
|
||||
var script = await skill.GetScriptAsync("missing");
|
||||
|
||||
// Assert
|
||||
Assert.Null(script);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetScriptAsync_NoScripts_ReturnsNullAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new MinimalClassSkill();
|
||||
|
||||
// Act
|
||||
var script = await skill.GetScriptAsync("anything");
|
||||
|
||||
// Assert
|
||||
Assert.Null(script);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConcurrentAccess_ToReflectedResourcesScriptsAndContent_InvokesDiscoveryOnceAsync()
|
||||
{
|
||||
// Regression test for thread-safety of Lazy<T> initialization in AgentClassSkill<TSelf>.
|
||||
// AttributedFullSkill uses attribute-based discovery (no override), so it exercises
|
||||
// the base class's Lazy<T> fields rather than a subclass's own caching.
|
||||
var skill = new AttributedFullSkill();
|
||||
|
||||
const int Concurrency = 32;
|
||||
var resourcesResults = new IReadOnlyList<AgentSkillResource>?[Concurrency];
|
||||
var scriptsResults = new IReadOnlyList<AgentSkillScript>?[Concurrency];
|
||||
var contentResults = new string[Concurrency];
|
||||
|
||||
// Act — invoke all three accessors concurrently from many threads.
|
||||
await Task.WhenAll(Enumerable.Range(0, Concurrency).Select(i => Task.Run(async () =>
|
||||
{
|
||||
resourcesResults[i] = skill.Resources;
|
||||
scriptsResults[i] = skill.Scripts;
|
||||
contentResults[i] = await skill.GetContentAsync();
|
||||
})));
|
||||
|
||||
// Assert — every thread observed the same cached instances (no torn state).
|
||||
for (int i = 1; i < Concurrency; i++)
|
||||
{
|
||||
Assert.Same(resourcesResults[0], resourcesResults[i]);
|
||||
Assert.Same(scriptsResults[0], scriptsResults[i]);
|
||||
Assert.Same(contentResults[0], contentResults[i]);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateScriptAndResource_WithSerializerOptions_HandleCustomTypesAsync()
|
||||
{
|
||||
@@ -151,17 +260,14 @@ public sealed class AgentClassSkillTests
|
||||
// Arrange
|
||||
var skill = new AttributedScriptsSkill();
|
||||
|
||||
// Act
|
||||
var scripts = skill.Scripts;
|
||||
// Act & Assert — all scripts discovered with correct metadata
|
||||
Assert.NotNull(skill.Scripts);
|
||||
Assert.Equal(4, skill.Scripts!.Count);
|
||||
Assert.Contains(skill.Scripts, s => s.Name == "do-work");
|
||||
Assert.Contains(skill.Scripts, s => s.Name == "DefaultNamed");
|
||||
Assert.Contains(skill.Scripts, s => s.Name == "append");
|
||||
|
||||
// Assert — all scripts discovered with correct metadata
|
||||
Assert.NotNull(scripts);
|
||||
Assert.Equal(4, scripts!.Count);
|
||||
Assert.Contains(scripts, s => s.Name == "do-work");
|
||||
Assert.Contains(scripts, s => s.Name == "DefaultNamed");
|
||||
Assert.Contains(scripts, s => s.Name == "append");
|
||||
|
||||
var processScript = scripts.First(s => s.Name == "process");
|
||||
var processScript = skill.Scripts.First(s => s.Name == "process");
|
||||
Assert.Equal("Processes the input.", processScript.Description);
|
||||
}
|
||||
|
||||
@@ -272,16 +378,16 @@ public sealed class AgentClassSkillTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AttributedFullSkill_IncludesContentWithSchema_AndCachesMembers()
|
||||
public async Task AttributedFullSkill_IncludesContentWithSchema_AndCachesMembersAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AttributedFullSkill();
|
||||
|
||||
// Act & Assert — Content includes reflected resources and scripts
|
||||
Assert.Contains("<resources>", skill.Content);
|
||||
Assert.Contains("conversion-table", skill.Content);
|
||||
Assert.Contains("<scripts>", skill.Content);
|
||||
Assert.Contains("convert", skill.Content);
|
||||
Assert.Contains("<resources>", await skill.GetContentAsync());
|
||||
Assert.Contains("conversion-table", await skill.GetContentAsync());
|
||||
Assert.Contains("<scripts>", await skill.GetContentAsync());
|
||||
Assert.Contains("convert", await skill.GetContentAsync());
|
||||
|
||||
// Act & Assert — discovered members are cached
|
||||
Assert.Same(skill.Resources, skill.Resources);
|
||||
@@ -299,33 +405,33 @@ public sealed class AgentClassSkillTests
|
||||
// Arrange — skill with no attributes and no overrides; base discovery returns null (not empty list)
|
||||
var skill = new NoAttributesNoOverridesSkill();
|
||||
var baseType = typeof(AgentClassSkill<NoAttributesNoOverridesSkill>);
|
||||
var resourcesDiscoveredField = baseType.GetField("_resourcesDiscovered", BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
var scriptsDiscoveredField = baseType.GetField("_scriptsDiscovered", BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
var reflectedResourcesField = baseType.GetField("_reflectedResources", BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
var reflectedScriptsField = baseType.GetField("_reflectedScripts", BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
var resourcesField = baseType.GetField("_resources", BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
var scriptsField = baseType.GetField("_scripts", BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
|
||||
Assert.NotNull(resourcesDiscoveredField);
|
||||
Assert.NotNull(scriptsDiscoveredField);
|
||||
Assert.NotNull(reflectedResourcesField);
|
||||
Assert.NotNull(reflectedScriptsField);
|
||||
Assert.False((bool)resourcesDiscoveredField!.GetValue(skill)!);
|
||||
Assert.False((bool)scriptsDiscoveredField!.GetValue(skill)!);
|
||||
Assert.NotNull(resourcesField);
|
||||
Assert.NotNull(scriptsField);
|
||||
|
||||
var resourcesLazy = (Lazy<IReadOnlyList<AgentSkillResource>?>)resourcesField!.GetValue(skill)!;
|
||||
var scriptsLazy = (Lazy<IReadOnlyList<AgentSkillScript>?>)scriptsField!.GetValue(skill)!;
|
||||
|
||||
Assert.False(resourcesLazy.IsValueCreated);
|
||||
Assert.False(scriptsLazy.IsValueCreated);
|
||||
|
||||
// Act & Assert
|
||||
Assert.Null(skill.Resources);
|
||||
Assert.Null(skill.Scripts);
|
||||
Assert.True((bool)resourcesDiscoveredField.GetValue(skill)!);
|
||||
Assert.True((bool)scriptsDiscoveredField.GetValue(skill)!);
|
||||
Assert.Null(reflectedResourcesField!.GetValue(skill));
|
||||
Assert.Null(reflectedScriptsField!.GetValue(skill));
|
||||
Assert.True(resourcesLazy.IsValueCreated);
|
||||
Assert.True(scriptsLazy.IsValueCreated);
|
||||
Assert.Null(resourcesLazy.Value);
|
||||
Assert.Null(scriptsLazy.Value);
|
||||
|
||||
// Repeated access should not re-trigger discovery even when discovered value is null.
|
||||
Assert.Null(skill.Resources);
|
||||
Assert.Null(skill.Scripts);
|
||||
Assert.True((bool)resourcesDiscoveredField.GetValue(skill)!);
|
||||
Assert.True((bool)scriptsDiscoveredField.GetValue(skill)!);
|
||||
Assert.Null(reflectedResourcesField.GetValue(skill));
|
||||
Assert.Null(reflectedScriptsField.GetValue(skill));
|
||||
Assert.True(resourcesLazy.IsValueCreated);
|
||||
Assert.True(scriptsLazy.IsValueCreated);
|
||||
Assert.Null(resourcesLazy.Value);
|
||||
Assert.Null(scriptsLazy.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -382,7 +488,7 @@ public sealed class AgentClassSkillTests
|
||||
var jso = SkillTestJsonContext.Default.Options;
|
||||
|
||||
// Act & Assert — script with custom JSO
|
||||
var script = skill.Scripts![0];
|
||||
var script = skill.Scripts!.First(s => s.Name == "lookup");
|
||||
var inputJson = JsonSerializer.SerializeToElement(new LookupRequest { Query = "test", MaxResults = 3 }, jso);
|
||||
using var argsDoc = JsonDocument.Parse($$"""{ "request": {{inputJson.GetRawText()}} }""");
|
||||
var args = argsDoc.RootElement;
|
||||
@@ -398,13 +504,13 @@ public sealed class AgentClassSkillTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Content_IncludesDescription_ForReflectedResources()
|
||||
public async Task Content_IncludesDescription_ForReflectedResourcesAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AttributedResourcePropertiesSkill();
|
||||
|
||||
// Act
|
||||
var content = skill.Content;
|
||||
var content = await skill.GetContentAsync();
|
||||
|
||||
// Assert — descriptions from [Description] attribute appear in synthesized content
|
||||
Assert.Contains("Some important data.", content);
|
||||
|
||||
@@ -105,7 +105,7 @@ public sealed class AgentFileSkillScriptTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Content_WithScripts_AppendsPerScriptEntries()
|
||||
public async Task Content_WithScripts_AppendsPerScriptEntriesAsync()
|
||||
{
|
||||
// Arrange
|
||||
static Task<object?> RunnerAsync(AgentFileSkill s, AgentFileSkillScript sc, JsonElement? a, IServiceProvider? sp, CancellationToken ct) => Task.FromResult<object?>(null);
|
||||
@@ -118,7 +118,7 @@ public sealed class AgentFileSkillScriptTests
|
||||
scripts: [script1, script2]);
|
||||
|
||||
// Act
|
||||
var content = fileSkill.Content;
|
||||
var content = await fileSkill.GetContentAsync();
|
||||
|
||||
// Assert — content starts with original and appends per-script entries
|
||||
Assert.StartsWith("Original content", content);
|
||||
@@ -130,7 +130,7 @@ public sealed class AgentFileSkillScriptTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Content_WithoutScripts_ReturnsOriginalContent()
|
||||
public async Task Content_WithoutScripts_ReturnsOriginalContentAsync()
|
||||
{
|
||||
// Arrange
|
||||
var fileSkill = new AgentFileSkill(
|
||||
@@ -139,14 +139,14 @@ public sealed class AgentFileSkillScriptTests
|
||||
"/skills/my-skill");
|
||||
|
||||
// Act
|
||||
var content = fileSkill.Content;
|
||||
var content = await fileSkill.GetContentAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Original content only", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Content_WithScripts_IsCached()
|
||||
public async Task Content_WithScripts_IsCachedAsync()
|
||||
{
|
||||
// Arrange
|
||||
static Task<object?> RunnerAsync(AgentFileSkill s, AgentFileSkillScript sc, JsonElement? a, IServiceProvider? sp, CancellationToken ct) => Task.FromResult<object?>(null);
|
||||
@@ -158,8 +158,8 @@ public sealed class AgentFileSkillScriptTests
|
||||
scripts: [script]);
|
||||
|
||||
// Act
|
||||
var content1 = fileSkill.Content;
|
||||
var content2 = fileSkill.Content;
|
||||
var content1 = await fileSkill.GetContentAsync();
|
||||
var content2 = await fileSkill.GetContentAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Same(content1, content2);
|
||||
@@ -232,7 +232,7 @@ public sealed class AgentFileSkillScriptTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Content_WithScripts_ContainsDefaultParametersSchema()
|
||||
public async Task Content_WithScripts_ContainsDefaultParametersSchemaAsync()
|
||||
{
|
||||
// Arrange
|
||||
static Task<object?> RunnerAsync(AgentFileSkill s, AgentFileSkillScript sc, JsonElement? a, IServiceProvider? sp, CancellationToken ct) => Task.FromResult<object?>(null);
|
||||
@@ -244,7 +244,7 @@ public sealed class AgentFileSkillScriptTests
|
||||
scripts: [script]);
|
||||
|
||||
// Act
|
||||
var content = fileSkill.Content;
|
||||
var content = await fileSkill.GetContentAsync();
|
||||
|
||||
// Assert — the appended block contains the actual default schema from AgentFileSkillScript
|
||||
Assert.Contains("""{"type":"array","items":{"type":"string"}}""", content);
|
||||
|
||||
+25
-25
@@ -2,7 +2,6 @@
|
||||
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@@ -46,9 +45,9 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable
|
||||
// Assert
|
||||
Assert.Single(skills);
|
||||
var skill = skills[0];
|
||||
Assert.NotNull(skill.Scripts);
|
||||
Assert.Single(skill.Scripts!);
|
||||
Assert.Equal("scripts/convert.py", skill.Scripts![0].Name);
|
||||
var script = await skill.GetScriptAsync("scripts/convert.py");
|
||||
Assert.NotNull(script);
|
||||
Assert.Equal("scripts/convert.py", script!.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -69,14 +68,13 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable
|
||||
|
||||
// Assert
|
||||
Assert.Single(skills);
|
||||
var scriptNames = skills[0].Scripts!.Select(s => s.Name).OrderBy(n => n, StringComparer.Ordinal).ToList();
|
||||
Assert.Equal(6, scriptNames.Count);
|
||||
Assert.Contains("scripts/run.cs", scriptNames);
|
||||
Assert.Contains("scripts/run.csx", scriptNames);
|
||||
Assert.Contains("scripts/run.js", scriptNames);
|
||||
Assert.Contains("scripts/run.ps1", scriptNames);
|
||||
Assert.Contains("scripts/run.py", scriptNames);
|
||||
Assert.Contains("scripts/run.sh", scriptNames);
|
||||
// Assert — verify all expected scripts are discoverable
|
||||
foreach (var name in (string[])["scripts/run.cs", "scripts/run.csx", "scripts/run.js", "scripts/run.ps1", "scripts/run.py", "scripts/run.sh"])
|
||||
{
|
||||
var script = await skills[0].GetScriptAsync(name);
|
||||
Assert.NotNull(script);
|
||||
Assert.Equal(name, script!.Name);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -94,7 +92,7 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable
|
||||
|
||||
// Assert
|
||||
Assert.Single(skills);
|
||||
Assert.Empty(skills[0].Scripts!);
|
||||
Assert.Null(await skills[0].GetScriptAsync("scripts/data.txt"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -109,8 +107,7 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable
|
||||
|
||||
// Assert
|
||||
Assert.Single(skills);
|
||||
Assert.NotNull(skills[0].Scripts);
|
||||
Assert.Empty(skills[0].Scripts!);
|
||||
Assert.Null(await skills[0].GetScriptAsync("any-script"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -128,7 +125,7 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable
|
||||
|
||||
// Assert — neither file is in the default scripts/ directory, so no scripts are discovered
|
||||
Assert.Single(skills);
|
||||
Assert.Empty(skills[0].Scripts!);
|
||||
Assert.Null(await skills[0].GetScriptAsync("convert.py"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -150,7 +147,7 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync(CancellationToken.None);
|
||||
var scriptResult = await skills[0].Scripts![0].RunAsync(skills[0], null, null, CancellationToken.None);
|
||||
var scriptResult = await (await skills[0].GetScriptAsync("scripts/test.py"))!.RunAsync(skills[0], null, null, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.True(executorCalled);
|
||||
@@ -175,7 +172,7 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable
|
||||
|
||||
// Act — discovery succeeds even without a runner
|
||||
var skills = await source.GetSkillsAsync(CancellationToken.None);
|
||||
var script = skills[0].Scripts![0];
|
||||
var script = (await skills[0].GetScriptAsync("scripts/run.sh"))!;
|
||||
|
||||
// Assert — running the script throws because no runner was provided
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => script.RunAsync(skills[0], null, null, CancellationToken.None));
|
||||
@@ -195,8 +192,9 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable
|
||||
|
||||
// Assert
|
||||
Assert.Single(skills);
|
||||
Assert.Single(skills[0].Scripts!);
|
||||
Assert.Equal("scripts/run.rb", skills[0].Scripts![0].Name);
|
||||
var rbScript = await skills[0].GetScriptAsync("scripts/run.rb");
|
||||
Assert.NotNull(rbScript);
|
||||
Assert.Equal("scripts/run.rb", rbScript!.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -217,7 +215,7 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable
|
||||
var skills = await source.GetSkillsAsync(CancellationToken.None);
|
||||
using var argumentsDoc = JsonDocument.Parse("""{"value":26.2,"factor":1.60934}""");
|
||||
var arguments = argumentsDoc.RootElement;
|
||||
await skills[0].Scripts![0].RunAsync(skills[0], arguments, null, CancellationToken.None);
|
||||
await (await skills[0].GetScriptAsync("scripts/test.py"))!.RunAsync(skills[0], arguments, null, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedArgs);
|
||||
@@ -240,8 +238,9 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable
|
||||
|
||||
// Assert — script file inside the deeply nested directory is discovered
|
||||
Assert.Single(skills);
|
||||
Assert.Single(skills[0].Scripts!);
|
||||
Assert.Equal("f1/f2/f3/run.py", skills[0].Scripts![0].Name);
|
||||
var nestedScript = await skills[0].GetScriptAsync("f1/f2/f3/run.py");
|
||||
Assert.NotNull(nestedScript);
|
||||
Assert.Equal("f1/f2/f3/run.py", nestedScript!.Name);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -267,11 +266,12 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable
|
||||
|
||||
// Assert — scripts are discovered with names identical to using directories without "./"
|
||||
Assert.Single(skills);
|
||||
Assert.Equal(directories.Length, skills[0].Scripts!.Count);
|
||||
foreach (string directory in directories)
|
||||
{
|
||||
string expectedName = $"{directory.Substring(2)}/run.py";
|
||||
Assert.Contains(skills[0].Scripts!, s => s.Name == expectedName);
|
||||
var script = await skills[0].GetScriptAsync(expectedName);
|
||||
Assert.NotNull(script);
|
||||
Assert.Equal(expectedName, script!.Name);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -105,13 +105,13 @@ public sealed class AgentInlineSkillTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Content_ContainsNameDescriptionAndInstructions()
|
||||
public async Task Content_ContainsNameDescriptionAndInstructionsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Do the thing.");
|
||||
|
||||
// Act
|
||||
var content = skill.Content;
|
||||
var content = await skill.GetContentAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Contains("<name>my-skill</name>", content);
|
||||
@@ -120,13 +120,13 @@ public sealed class AgentInlineSkillTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Content_EscapesXmlCharacters()
|
||||
public async Task Content_EscapesXmlCharactersAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "x<y>z\"w & it's more", "1 & 2 < 3");
|
||||
|
||||
// Act
|
||||
var content = skill.Content;
|
||||
var content = await skill.GetContentAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Contains("<name>my-skill</name>", content);
|
||||
@@ -135,28 +135,28 @@ public sealed class AgentInlineSkillTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Content_IsCachedAcrossAccesses()
|
||||
public async Task Content_IsCachedAcrossAccessesAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
|
||||
// Act
|
||||
var first = skill.Content;
|
||||
var second = skill.Content;
|
||||
var first = await skill.GetContentAsync();
|
||||
var second = await skill.GetContentAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Same(first, second);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Content_IncludesResourcesAddedBeforeFirstAccess()
|
||||
public async Task Content_IncludesResourcesAddedBeforeFirstAccessAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
skill.AddResource("config", "value1", "A config resource.");
|
||||
|
||||
// Act
|
||||
var content = skill.Content;
|
||||
var content = await skill.GetContentAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Contains("<resources>", content);
|
||||
@@ -164,14 +164,14 @@ public sealed class AgentInlineSkillTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Content_IncludesDelegateResourcesAddedBeforeFirstAccess()
|
||||
public async Task Content_IncludesDelegateResourcesAddedBeforeFirstAccessAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
skill.AddResource("dynamic", () => "hello");
|
||||
|
||||
// Act
|
||||
var content = skill.Content;
|
||||
var content = await skill.GetContentAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Contains("<resources>", content);
|
||||
@@ -179,14 +179,14 @@ public sealed class AgentInlineSkillTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Content_IncludesScriptsAddedBeforeFirstAccess()
|
||||
public async Task Content_IncludesScriptsAddedBeforeFirstAccessAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
skill.AddScript("run", () => "result", "Runs something.");
|
||||
|
||||
// Act
|
||||
var content = skill.Content;
|
||||
var content = await skill.GetContentAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Contains("<scripts>", content);
|
||||
@@ -194,22 +194,22 @@ public sealed class AgentInlineSkillTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Content_IsCachedAndNotRebuilt()
|
||||
public async Task Content_IsCachedAndNotRebuiltAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
skill.AddResource("r1", "v1");
|
||||
|
||||
// Act
|
||||
var first = skill.Content;
|
||||
var second = skill.Content;
|
||||
var first = await skill.GetContentAsync();
|
||||
var second = await skill.GetContentAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Same(first, second);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Content_IncludesResourcesAndScriptsAddedBeforeFirstAccess()
|
||||
public async Task Content_IncludesResourcesAndScriptsAddedBeforeFirstAccessAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
@@ -217,7 +217,7 @@ public sealed class AgentInlineSkillTests
|
||||
skill.AddScript("s1", () => "ok");
|
||||
|
||||
// Act
|
||||
var content = skill.Content;
|
||||
var content = await skill.GetContentAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Contains("<resources>", content);
|
||||
@@ -227,14 +227,14 @@ public sealed class AgentInlineSkillTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Content_ParametersSchema_IsXmlEscaped()
|
||||
public async Task Content_ParametersSchema_IsXmlEscapedAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
skill.AddScript("search", (string query, int limit) => $"found {limit} results for {query}");
|
||||
|
||||
// Act
|
||||
var content = skill.Content;
|
||||
var content = await skill.GetContentAsync();
|
||||
|
||||
// Assert — JSON schema should be present and XML content chars escaped
|
||||
Assert.Contains("parameters_schema", content);
|
||||
@@ -280,17 +280,103 @@ public sealed class AgentInlineSkillTests
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
|
||||
// Act & Assert
|
||||
Assert.Null(skill.Resources);
|
||||
Assert.Null(skill.GetTestResources());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Scripts_WhenNoneAdded_ReturnsNull()
|
||||
public async Task Scripts_WhenNoneAdded_ReturnsNullAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
|
||||
// Act & Assert
|
||||
Assert.Null(skill.Scripts);
|
||||
Assert.Null(await skill.GetScriptAsync("nonexistent"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetResourceAsync_ExistingName_ReturnsResourceAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
skill.AddResource("r1", "v1");
|
||||
skill.AddResource("r2", "v2");
|
||||
|
||||
// Act
|
||||
var resource = await skill.GetResourceAsync("r2");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(resource);
|
||||
Assert.Equal("r2", resource!.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetResourceAsync_NonExistingName_ReturnsNullAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
skill.AddResource("r1", "v1");
|
||||
|
||||
// Act
|
||||
var resource = await skill.GetResourceAsync("missing");
|
||||
|
||||
// Assert
|
||||
Assert.Null(resource);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetResourceAsync_NoResourcesAdded_ReturnsNullAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
|
||||
// Act
|
||||
var resource = await skill.GetResourceAsync("missing");
|
||||
|
||||
// Assert
|
||||
Assert.Null(resource);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetScriptAsync_ExistingName_ReturnsScriptAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
skill.AddScript("s1", () => "first");
|
||||
skill.AddScript("s2", () => "second");
|
||||
|
||||
// Act
|
||||
var script = await skill.GetScriptAsync("s2");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(script);
|
||||
Assert.Equal("s2", script!.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetScriptAsync_NonExistingName_ReturnsNullAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
skill.AddScript("s1", () => "ok");
|
||||
|
||||
// Act
|
||||
var script = await skill.GetScriptAsync("missing");
|
||||
|
||||
// Assert
|
||||
Assert.Null(script);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetScriptAsync_NoScriptsAdded_ReturnsNullAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
|
||||
// Act
|
||||
var script = await skill.GetScriptAsync("missing");
|
||||
|
||||
// Assert
|
||||
Assert.Null(script);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -333,13 +419,13 @@ public sealed class AgentInlineSkillTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Content_NoResourcesOrScripts_DoesNotContainResourcesOrScriptsTags()
|
||||
public async Task Content_NoResourcesOrScripts_DoesNotContainResourcesOrScriptsTagsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
|
||||
// Act
|
||||
var content = skill.Content;
|
||||
var content = await skill.GetContentAsync();
|
||||
|
||||
// Assert
|
||||
Assert.DoesNotContain("<resources>", content);
|
||||
@@ -347,58 +433,58 @@ public sealed class AgentInlineSkillTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Content_ResourcesAddedAfterCaching_AreNotIncluded()
|
||||
public async Task Content_ResourcesAddedAfterCaching_AreNotIncludedAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
_ = skill.Content; // trigger caching
|
||||
_ = await skill.GetContentAsync(); // trigger caching
|
||||
skill.AddResource("late-resource", "late-value");
|
||||
|
||||
// Act
|
||||
var content = skill.Content;
|
||||
var content = await skill.GetContentAsync();
|
||||
|
||||
// Assert — the late resource should not appear because content was cached
|
||||
Assert.DoesNotContain("late-resource", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Content_ScriptsAddedAfterCaching_AreNotIncluded()
|
||||
public async Task Content_ScriptsAddedAfterCaching_AreNotIncludedAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
_ = skill.Content; // trigger caching
|
||||
_ = await skill.GetContentAsync(); // trigger caching
|
||||
skill.AddScript("late-script", () => "late");
|
||||
|
||||
// Act
|
||||
var content = skill.Content;
|
||||
var content = await skill.GetContentAsync();
|
||||
|
||||
// Assert — the late script should not appear because content was cached
|
||||
Assert.DoesNotContain("late-script", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Content_ScriptWithDescription_IncludesDescriptionAttribute()
|
||||
public async Task Content_ScriptWithDescription_IncludesDescriptionAttributeAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
skill.AddScript("my-script", () => "ok", "Runs something.");
|
||||
|
||||
// Act
|
||||
var content = skill.Content;
|
||||
var content = await skill.GetContentAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Contains("description=\"Runs something.\"", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Content_ScriptWithoutParametersOrDescription_UsesSelfClosingTag()
|
||||
public async Task Content_ScriptWithoutParametersOrDescription_UsesSelfClosingTagAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
skill.AddScript("simple", () => "ok");
|
||||
|
||||
// Act
|
||||
var content = skill.Content;
|
||||
var content = await skill.GetContentAsync();
|
||||
|
||||
// Assert — parameterless Action delegates still produce a schema, so this
|
||||
// verifies the script is at least included in the output
|
||||
@@ -406,7 +492,7 @@ public sealed class AgentInlineSkillTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Content_ResourceWithDescription_IncludesDescriptionAttribute()
|
||||
public async Task Content_ResourceWithDescription_IncludesDescriptionAttributeAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
@@ -414,7 +500,7 @@ public sealed class AgentInlineSkillTests
|
||||
skill.AddResource("no-desc", "value");
|
||||
|
||||
// Act
|
||||
var content = skill.Content;
|
||||
var content = await skill.GetContentAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Contains("description=\"A described resource.\"", content);
|
||||
@@ -437,7 +523,7 @@ public sealed class AgentInlineSkillTests
|
||||
var args = argsDoc.RootElement;
|
||||
|
||||
// Act
|
||||
var result = await skill.Scripts![0].RunAsync(skill, args, null, CancellationToken.None);
|
||||
var result = await (await skill.GetScriptAsync("lookup"))!.RunAsync(skill, args, null, CancellationToken.None);
|
||||
|
||||
// Assert — the custom input was deserialized via skill-level JSO and response was produced
|
||||
Assert.NotNull(result);
|
||||
@@ -461,7 +547,7 @@ public sealed class AgentInlineSkillTests
|
||||
var args = argsDoc.RootElement;
|
||||
|
||||
// Act
|
||||
var result = await skill.Scripts![0].RunAsync(skill, args, null, CancellationToken.None);
|
||||
var result = await (await skill.GetScriptAsync("lookup"))!.RunAsync(skill, args, null, CancellationToken.None);
|
||||
|
||||
// Assert — per-script JSO takes effect and custom types are properly marshaled
|
||||
Assert.NotNull(result);
|
||||
@@ -477,7 +563,7 @@ public sealed class AgentInlineSkillTests
|
||||
skill.AddResource("config", () => new SkillConfig { Theme = "dark", Verbose = true });
|
||||
|
||||
// Act
|
||||
var result = await skill.Resources![0].ReadAsync();
|
||||
var result = await skill.GetTestResources()![0].ReadAsync();
|
||||
|
||||
// Assert — the custom type was returned successfully via skill-level JSO
|
||||
Assert.NotNull(result);
|
||||
@@ -494,7 +580,7 @@ public sealed class AgentInlineSkillTests
|
||||
skill.AddResource("config", () => new SkillConfig { Theme = "dark", Verbose = true }, serializerOptions: resourceJso);
|
||||
|
||||
// Act
|
||||
var result = await skill.Resources![0].ReadAsync();
|
||||
var result = await skill.GetTestResources()![0].ReadAsync();
|
||||
|
||||
// Assert — per-resource JSO takes effect and custom type is properly marshaled
|
||||
Assert.NotNull(result);
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
|
||||
|
||||
/// <summary>
|
||||
/// Test-only helpers that peek at the underlying resource list of a skill via reflection.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The public <see cref="AgentSkill"/> API exposes resources only through
|
||||
/// <see cref="AgentSkill.GetResourceAsync"/>.
|
||||
/// These helpers exist purely to allow unit tests for <see cref="AgentFileSkill"/> and
|
||||
/// <see cref="AgentInlineSkill"/> to inspect the concrete enumerated list a skill carries.
|
||||
/// </remarks>
|
||||
internal static class AgentSkillTestExtensions
|
||||
{
|
||||
public static IReadOnlyList<AgentSkillResource>? GetTestResources(this AgentSkill skill)
|
||||
{
|
||||
// AgentFileSkill / AgentInlineSkill: private "_resources" field.
|
||||
for (var type = skill.GetType(); type is not null; type = type.BaseType)
|
||||
{
|
||||
var field = type.GetField("_resources", BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
if (field is not null)
|
||||
{
|
||||
return UnwrapList(field.GetValue(skill));
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<AgentSkillResource>? UnwrapList(object? value) =>
|
||||
value switch
|
||||
{
|
||||
null => null,
|
||||
IReadOnlyList<AgentSkillResource> list => list,
|
||||
IEnumerable<AgentSkillResource> seq => seq.ToList(),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
+282
-10
@@ -68,11 +68,12 @@ public sealed class AgentSkillsProviderTests : IDisposable
|
||||
Assert.Contains("provider-skill", result.Instructions);
|
||||
Assert.Contains("Provider skill test", result.Instructions);
|
||||
|
||||
// Should have load_skill tool (no resources, so no read_skill_resource)
|
||||
// Should have load_skill, read_skill_resource, and run_skill_script tools
|
||||
Assert.NotNull(result.Tools);
|
||||
var toolNames = result.Tools!.Select(t => t.Name).ToList();
|
||||
Assert.Contains("load_skill", toolNames);
|
||||
Assert.DoesNotContain("read_skill_resource", toolNames);
|
||||
Assert.Contains("read_skill_resource", toolNames);
|
||||
Assert.Contains("run_skill_script", toolNames);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -316,7 +317,7 @@ public sealed class AgentSkillsProviderTests : IDisposable
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokingCoreAsync_WithoutScripts_NoRunSkillScriptToolAsync()
|
||||
public async Task InvokingCoreAsync_WithoutScripts_StillIncludesAllToolsAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.CreateSkill("no-script-skill", "No scripts", "Body.");
|
||||
@@ -328,10 +329,12 @@ public sealed class AgentSkillsProviderTests : IDisposable
|
||||
// Act
|
||||
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
// Assert — all tools are always included regardless of skill content
|
||||
Assert.NotNull(result.Tools);
|
||||
var toolNames = result.Tools!.Select(t => t.Name).ToList();
|
||||
Assert.DoesNotContain("run_skill_script", toolNames);
|
||||
Assert.Contains("load_skill", toolNames);
|
||||
Assert.Contains("read_skill_resource", toolNames);
|
||||
Assert.Contains("run_skill_script", toolNames);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -416,7 +419,7 @@ public sealed class AgentSkillsProviderTests : IDisposable
|
||||
// Assert
|
||||
Assert.Single(skills);
|
||||
var fileSkill = Assert.IsType<AgentFileSkill>(skills[0]);
|
||||
Assert.All(fileSkill.Resources, r => Assert.EndsWith(".json", r.Name));
|
||||
Assert.All(fileSkill.GetTestResources()!, r => Assert.EndsWith(".json", r.Name));
|
||||
}
|
||||
|
||||
private void CreateSkill(string name, string description, string body)
|
||||
@@ -445,6 +448,279 @@ public sealed class AgentSkillsProviderTests : IDisposable
|
||||
Assert.Contains("Skill body.", text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadSkill_EmptySkillName_ReturnsErrorAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.CreateSkill("any-skill", "Test", "Body.");
|
||||
var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor));
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
||||
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 Dictionary<string, object?> { ["skillName"] = "" }));
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Error: Skill name cannot be empty.", content!.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadSkill_SkillNotFound_ReturnsErrorAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.CreateSkill("only-skill", "Test", "Body.");
|
||||
var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor));
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
||||
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 Dictionary<string, object?> { ["skillName"] = "non-existent" }));
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Error: Skill 'non-existent' not found.", content!.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokingCoreAsync_WithResources_IncludesReadSkillResourceToolAsync()
|
||||
{
|
||||
// Arrange — inline skill with a resource
|
||||
var skill = new AgentInlineSkill("res-skill", "Has resources", "Body.");
|
||||
skill.AddResource("config", "value1", "A config resource.");
|
||||
var provider = new AgentSkillsProvider(skill);
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
||||
|
||||
// Act
|
||||
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result.Tools);
|
||||
var toolNames = result.Tools!.Select(t => t.Name).ToList();
|
||||
Assert.Contains("read_skill_resource", toolNames);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadSkillResource_ReturnsResourceContentAsync()
|
||||
{
|
||||
// Arrange — inline skill with a resource
|
||||
var skill = new AgentInlineSkill("res-skill", "Has resources", "Body.");
|
||||
skill.AddResource("config", "resource-value");
|
||||
var provider = new AgentSkillsProvider(skill);
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
||||
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
var readTool = result.Tools!.First(t => t.Name == "read_skill_resource") as AIFunction;
|
||||
|
||||
// Act
|
||||
var content = await readTool!.InvokeAsync(new AIFunctionArguments(new Dictionary<string, object?>
|
||||
{
|
||||
["skillName"] = "res-skill",
|
||||
["resourceName"] = "config",
|
||||
})
|
||||
{
|
||||
Services = new TestServiceProvider(),
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.Equal("resource-value", content!.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadSkillResource_EmptySkillName_ReturnsErrorAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("res-skill", "Has resources", "Body.");
|
||||
skill.AddResource("config", "v");
|
||||
var provider = new AgentSkillsProvider(skill);
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
||||
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
var readTool = result.Tools!.First(t => t.Name == "read_skill_resource") as AIFunction;
|
||||
|
||||
// Act
|
||||
var content = await readTool!.InvokeAsync(new AIFunctionArguments(new Dictionary<string, object?>
|
||||
{
|
||||
["skillName"] = "",
|
||||
["resourceName"] = "config",
|
||||
})
|
||||
{
|
||||
Services = new TestServiceProvider(),
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Error: Skill name cannot be empty.", content!.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadSkillResource_EmptyResourceName_ReturnsErrorAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("res-skill", "Has resources", "Body.");
|
||||
skill.AddResource("config", "v");
|
||||
var provider = new AgentSkillsProvider(skill);
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
||||
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
var readTool = result.Tools!.First(t => t.Name == "read_skill_resource") as AIFunction;
|
||||
|
||||
// Act
|
||||
var content = await readTool!.InvokeAsync(new AIFunctionArguments(new Dictionary<string, object?>
|
||||
{
|
||||
["skillName"] = "res-skill",
|
||||
["resourceName"] = "",
|
||||
})
|
||||
{
|
||||
Services = new TestServiceProvider(),
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Error: Resource name cannot be empty.", content!.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadSkillResource_SkillNotFound_ReturnsErrorAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("res-skill", "Has resources", "Body.");
|
||||
skill.AddResource("config", "v");
|
||||
var provider = new AgentSkillsProvider(skill);
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
||||
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
var readTool = result.Tools!.First(t => t.Name == "read_skill_resource") as AIFunction;
|
||||
|
||||
// Act
|
||||
var content = await readTool!.InvokeAsync(new AIFunctionArguments(new Dictionary<string, object?>
|
||||
{
|
||||
["skillName"] = "non-existent",
|
||||
["resourceName"] = "config",
|
||||
})
|
||||
{
|
||||
Services = new TestServiceProvider(),
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Error: Skill 'non-existent' not found.", content!.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadSkillResource_ResourceNotFound_ReturnsErrorAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("res-skill", "Has resources", "Body.");
|
||||
skill.AddResource("config", "v");
|
||||
var provider = new AgentSkillsProvider(skill);
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
||||
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
var readTool = result.Tools!.First(t => t.Name == "read_skill_resource") as AIFunction;
|
||||
|
||||
// Act
|
||||
var content = await readTool!.InvokeAsync(new AIFunctionArguments(new Dictionary<string, object?>
|
||||
{
|
||||
["skillName"] = "res-skill",
|
||||
["resourceName"] = "missing",
|
||||
})
|
||||
{
|
||||
Services = new TestServiceProvider(),
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Error: Resource 'missing' not found in skill 'res-skill'.", content!.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunSkillScript_EmptySkillName_ReturnsErrorAsync()
|
||||
{
|
||||
// Arrange
|
||||
string skillDir = Path.Combine(this._testRoot, "err-script-skill");
|
||||
Directory.CreateDirectory(Path.Combine(skillDir, "scripts"));
|
||||
File.WriteAllText(Path.Combine(skillDir, "SKILL.md"), "---\nname: err-script-skill\ndescription: Test\n---\nBody.");
|
||||
File.WriteAllText(Path.Combine(skillDir, "scripts", "run.py"), "print('hi')");
|
||||
var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor));
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
||||
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
var runScriptTool = result.Tools!.First(t => t.Name == "run_skill_script") as AIFunction;
|
||||
|
||||
// Act
|
||||
var content = await runScriptTool!.InvokeAsync(new AIFunctionArguments(new Dictionary<string, object?>
|
||||
{
|
||||
["skillName"] = "",
|
||||
["scriptName"] = "scripts/run.py",
|
||||
}));
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Error: Skill name cannot be empty.", content!.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunSkillScript_EmptyScriptName_ReturnsErrorAsync()
|
||||
{
|
||||
// Arrange
|
||||
string skillDir = Path.Combine(this._testRoot, "err-script2-skill");
|
||||
Directory.CreateDirectory(Path.Combine(skillDir, "scripts"));
|
||||
File.WriteAllText(Path.Combine(skillDir, "SKILL.md"), "---\nname: err-script2-skill\ndescription: Test\n---\nBody.");
|
||||
File.WriteAllText(Path.Combine(skillDir, "scripts", "run.py"), "print('hi')");
|
||||
var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor));
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
||||
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
var runScriptTool = result.Tools!.First(t => t.Name == "run_skill_script") as AIFunction;
|
||||
|
||||
// Act
|
||||
var content = await runScriptTool!.InvokeAsync(new AIFunctionArguments(new Dictionary<string, object?>
|
||||
{
|
||||
["skillName"] = "err-script2-skill",
|
||||
["scriptName"] = "",
|
||||
}));
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Error: Script name cannot be empty.", content!.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunSkillScript_SkillNotFound_ReturnsErrorAsync()
|
||||
{
|
||||
// Arrange
|
||||
string skillDir = Path.Combine(this._testRoot, "err-script3-skill");
|
||||
Directory.CreateDirectory(Path.Combine(skillDir, "scripts"));
|
||||
File.WriteAllText(Path.Combine(skillDir, "SKILL.md"), "---\nname: err-script3-skill\ndescription: Test\n---\nBody.");
|
||||
File.WriteAllText(Path.Combine(skillDir, "scripts", "run.py"), "print('hi')");
|
||||
var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor));
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
||||
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
var runScriptTool = result.Tools!.First(t => t.Name == "run_skill_script") as AIFunction;
|
||||
|
||||
// Act
|
||||
var content = await runScriptTool!.InvokeAsync(new AIFunctionArguments(new Dictionary<string, object?>
|
||||
{
|
||||
["skillName"] = "non-existent",
|
||||
["scriptName"] = "scripts/run.py",
|
||||
}));
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Error: Skill 'non-existent' not found.", content!.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunSkillScript_ScriptNotFound_ReturnsErrorAsync()
|
||||
{
|
||||
// Arrange
|
||||
string skillDir = Path.Combine(this._testRoot, "err-script4-skill");
|
||||
Directory.CreateDirectory(Path.Combine(skillDir, "scripts"));
|
||||
File.WriteAllText(Path.Combine(skillDir, "SKILL.md"), "---\nname: err-script4-skill\ndescription: Test\n---\nBody.");
|
||||
File.WriteAllText(Path.Combine(skillDir, "scripts", "run.py"), "print('hi')");
|
||||
var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor));
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
||||
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
var runScriptTool = result.Tools!.First(t => t.Name == "run_skill_script") as AIFunction;
|
||||
|
||||
// Act
|
||||
var content = await runScriptTool!.InvokeAsync(new AIFunctionArguments(new Dictionary<string, object?>
|
||||
{
|
||||
["skillName"] = "err-script4-skill",
|
||||
["scriptName"] = "scripts/missing.py",
|
||||
}));
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Error: Script 'scripts/missing.py' not found in skill 'err-script4-skill'.", content!.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Builder_UseFileScriptRunnerAfterUseFileSkills_RunnerIsUsedAsync()
|
||||
{
|
||||
@@ -998,9 +1274,5 @@ public sealed class AgentSkillsProviderTests : IDisposable
|
||||
public override AgentSkillFrontmatter Frontmatter { get; }
|
||||
|
||||
protected override string Instructions => this._instructions;
|
||||
|
||||
public override IReadOnlyList<AgentSkillResource>? Resources => null;
|
||||
|
||||
public override IReadOnlyList<AgentSkillScript>? Scripts => null;
|
||||
}
|
||||
}
|
||||
|
||||
+44
-42
@@ -281,9 +281,9 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
// Assert
|
||||
Assert.Single(skills);
|
||||
var skill = skills[0];
|
||||
Assert.Equal(2, skill.Resources!.Count);
|
||||
Assert.Contains(skill.Resources!, r => r.Name.Equals("references/FAQ.md", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Contains(skill.Resources!, r => r.Name.Equals("assets/data.json", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Equal(2, skill.GetTestResources()!.Count);
|
||||
Assert.Contains(skill.GetTestResources()!, r => r.Name.Equals("references/FAQ.md", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Contains(skill.GetTestResources()!, r => r.Name.Equals("assets/data.json", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -306,8 +306,8 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
// Assert
|
||||
Assert.Single(skills);
|
||||
var skill = skills[0];
|
||||
Assert.Single(skill.Resources!);
|
||||
Assert.Equal("references/data.json", skill.Resources![0].Name);
|
||||
Assert.Single(skill.GetTestResources()!);
|
||||
Assert.Equal("references/data.json", skill.GetTestResources()![0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -329,8 +329,8 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
// Assert
|
||||
Assert.Single(skills);
|
||||
var skill = skills[0];
|
||||
Assert.Single(skill.Resources!);
|
||||
Assert.Equal("references/notes.md", skill.Resources![0].Name);
|
||||
Assert.Single(skill.GetTestResources()!);
|
||||
Assert.Equal("references/notes.md", skill.GetTestResources()![0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -355,9 +355,9 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
// Assert — only the file directly in references/ is discovered; the nested file is not
|
||||
Assert.Single(skills);
|
||||
var skill = skills[0];
|
||||
Assert.Single(skill.Resources!);
|
||||
Assert.Contains(skill.Resources!, r => r.Name.Equals("references/top.md", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.DoesNotContain(skill.Resources!, r => r.Name.Contains("deep.md", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Single(skill.GetTestResources()!);
|
||||
Assert.Contains(skill.GetTestResources()!, r => r.Name.Equals("references/top.md", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.DoesNotContain(skill.GetTestResources()!, r => r.Name.Contains("deep.md", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -380,8 +380,8 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
// Assert — only .custom files should be discovered, not .json
|
||||
Assert.Single(skills);
|
||||
var skill = skills[0];
|
||||
Assert.Single(skill.Resources!);
|
||||
Assert.Equal("references/data.custom", skill.Resources![0].Name);
|
||||
Assert.Single(skill.GetTestResources()!);
|
||||
Assert.Equal("references/data.custom", skill.GetTestResources()![0].Name);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -406,7 +406,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
|
||||
// Assert — default extensions include .md
|
||||
var skills = await source.GetSkillsAsync();
|
||||
Assert.Single(skills[0].Resources!);
|
||||
Assert.Single(skills[0].GetTestResources()!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -442,7 +442,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
|
||||
// Assert — root-level files are NOT discovered unless "." is in ResourceDirectories
|
||||
Assert.Single(skills);
|
||||
Assert.Empty(skills[0].Resources!);
|
||||
Assert.Empty(skills[0].GetTestResources()!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -465,9 +465,9 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
// Assert — both root-level resource files (and SKILL.md excluded) should be discovered
|
||||
Assert.Single(skills);
|
||||
var skill = skills[0];
|
||||
Assert.Equal(2, skill.Resources!.Count);
|
||||
Assert.Contains(skill.Resources!, r => r.Name.Equals("guide.md", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Contains(skill.Resources!, r => r.Name.Equals("config.json", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Equal(2, skill.GetTestResources()!.Count);
|
||||
Assert.Contains(skill.GetTestResources()!, r => r.Name.Equals("guide.md", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Contains(skill.GetTestResources()!, r => r.Name.Equals("config.json", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -488,7 +488,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
|
||||
// Assert — non-spec directories are not scanned by default
|
||||
Assert.Single(skills);
|
||||
Assert.Empty(skills[0].Resources!);
|
||||
Assert.Empty(skills[0].GetTestResources()!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -514,8 +514,8 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
// Assert — only docs/ is scanned; references/ is NOT scanned
|
||||
Assert.Single(skills);
|
||||
var skill = skills[0];
|
||||
Assert.Single(skill.Resources!);
|
||||
Assert.Equal("docs/readme.md", skill.Resources![0].Name);
|
||||
Assert.Single(skill.GetTestResources()!);
|
||||
Assert.Equal("docs/readme.md", skill.GetTestResources()![0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -530,7 +530,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
|
||||
// Assert
|
||||
Assert.Single(skills);
|
||||
Assert.Empty(skills[0].Resources!);
|
||||
Assert.Empty(skills[0].GetTestResources()!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -588,7 +588,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
File.WriteAllText(Path.Combine(refsDir, "doc.md"), "Document content here.");
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||||
var skills = await source.GetSkillsAsync();
|
||||
var resource = skills[0].Resources!.First(r => r.Name == "references/doc.md");
|
||||
var resource = skills[0].GetTestResources()!.First(r => r.Name == "references/doc.md");
|
||||
|
||||
// Act
|
||||
var content = await resource.ReadAsync();
|
||||
@@ -672,8 +672,8 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
// Assert — skill should still load, the symlinked references/ is skipped, assets/legit.md is found
|
||||
var skill = skills.FirstOrDefault(s => s.Frontmatter.Name == "symlink-escape-skill");
|
||||
Assert.NotNull(skill);
|
||||
Assert.Single(skill.Resources!);
|
||||
Assert.Equal("assets/legit.md", skill.Resources![0].Name);
|
||||
Assert.Single(skill.GetTestResources()!);
|
||||
Assert.Equal("assets/legit.md", skill.GetTestResources()![0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -714,8 +714,8 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
// Assert — only assets/legit.md is found; the symlinked references/ directory is skipped entirely
|
||||
var skill = skills.FirstOrDefault(s => s.Frontmatter.Name == "symlink-directory-skip");
|
||||
Assert.NotNull(skill);
|
||||
Assert.Single(skill.Resources!);
|
||||
Assert.Equal("assets/legit.md", skill.Resources![0].Name);
|
||||
Assert.Single(skill.GetTestResources()!);
|
||||
Assert.Equal("assets/legit.md", skill.GetTestResources()![0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -751,7 +751,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
// Assert — skill loads but scripts from the symlinked directory are not discovered
|
||||
var skill = skills.FirstOrDefault(s => s.Frontmatter.Name == "symlink-script-skip");
|
||||
Assert.NotNull(skill);
|
||||
Assert.Empty(skill.Scripts!);
|
||||
Assert.Null(await skill.GetScriptAsync("any-script"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -791,7 +791,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
// Assert — the symlinked intermediate segment causes the directory to be skipped
|
||||
var skill = skills.FirstOrDefault(s => s.Frontmatter.Name == "symlink-intermediate");
|
||||
Assert.NotNull(skill);
|
||||
Assert.Empty(skill.Resources!);
|
||||
Assert.Empty(skill.GetTestResources()!);
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -1020,8 +1020,8 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
|
||||
// Assert — only one copy of the resource despite two equivalent directory entries
|
||||
Assert.Single(skills);
|
||||
Assert.Single(skills[0].Resources!);
|
||||
Assert.Equal("references/FAQ.md", skills[0].Resources![0].Name);
|
||||
Assert.Single(skills[0].GetTestResources()!);
|
||||
Assert.Equal("references/FAQ.md", skills[0].GetTestResources()![0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -1043,8 +1043,8 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
|
||||
// Assert — trailing slash variant deduplicated
|
||||
Assert.Single(skills);
|
||||
Assert.Single(skills[0].Resources!);
|
||||
Assert.Equal("references/data.json", skills[0].Resources![0].Name);
|
||||
Assert.Single(skills[0].GetTestResources()!);
|
||||
Assert.Equal("references/data.json", skills[0].GetTestResources()![0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -1066,8 +1066,9 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
|
||||
// Assert — backslash variant deduplicated
|
||||
Assert.Single(skills);
|
||||
Assert.Single(skills[0].Scripts!);
|
||||
Assert.Equal("scripts/run.py", skills[0].Scripts![0].Name);
|
||||
var script = await skills[0].GetScriptAsync("scripts/run.py");
|
||||
Assert.NotNull(script);
|
||||
Assert.Equal("scripts/run.py", script!.Name);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -1093,8 +1094,8 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
|
||||
// Assert — the resource is discovered with a name identical to using the directory without "./"
|
||||
Assert.Single(skills);
|
||||
Assert.Single(skills[0].Resources!);
|
||||
Assert.Equal($"{directoryWithoutDotSlash}/data.json", skills[0].Resources![0].Name);
|
||||
Assert.Single(skills[0].GetTestResources()!);
|
||||
Assert.Equal($"{directoryWithoutDotSlash}/data.json", skills[0].GetTestResources()![0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -1117,8 +1118,8 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
// Assert — resource file inside the deeply nested directory is discovered
|
||||
Assert.Single(skills);
|
||||
var skill = skills[0];
|
||||
Assert.Single(skill.Resources!);
|
||||
Assert.Equal("f1/f2/f3/data.json", skill.Resources![0].Name);
|
||||
Assert.Single(skill.GetTestResources()!);
|
||||
Assert.Equal("f1/f2/f3/data.json", skill.GetTestResources()![0].Name);
|
||||
}
|
||||
|
||||
private string CreateSkillDirectory(string name, string description, string body)
|
||||
@@ -1188,8 +1189,9 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
// Assert — script at the skill root should be discovered
|
||||
var skill = skills.FirstOrDefault(s => s.Frontmatter.Name == "root-script-skill");
|
||||
Assert.NotNull(skill);
|
||||
Assert.Single(skill.Scripts!);
|
||||
Assert.Equal("run.py", skill.Scripts![0].Name);
|
||||
var script = await skill.GetScriptAsync("run.py");
|
||||
Assert.NotNull(script);
|
||||
Assert.Equal("run.py", script!.Name);
|
||||
}
|
||||
|
||||
#if NET
|
||||
@@ -1229,8 +1231,8 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
// Assert — only legit.md should be discovered; the symlinked leak.md is skipped
|
||||
var skill = skills.FirstOrDefault(s => s.Frontmatter.Name == "symlink-file-skill");
|
||||
Assert.NotNull(skill);
|
||||
Assert.Single(skill.Resources!);
|
||||
Assert.Equal("references/legit.md", skill.Resources![0].Name);
|
||||
Assert.Single(skill.GetTestResources()!);
|
||||
Assert.Equal("references/legit.md", skill.GetTestResources()![0].Name);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
+301
@@ -0,0 +1,301 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests that verify the Hosted-AgentSkills sample patterns: ZIP extraction with
|
||||
/// zip-slip guard, skill name validation, and AgentSkillsProvider loading from
|
||||
/// downloaded skill directories (the Foundry download → extract → wire-into-provider flow).
|
||||
/// </summary>
|
||||
public sealed class HostedAgentSkillsPatternTests : IDisposable
|
||||
{
|
||||
private readonly string _testRoot;
|
||||
private readonly TestAIAgent _agent = new();
|
||||
|
||||
public HostedAgentSkillsPatternTests()
|
||||
{
|
||||
this._testRoot = Path.Combine(Path.GetTempPath(), "hosted-skills-tests-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(this._testRoot);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(this._testRoot))
|
||||
{
|
||||
Directory.Delete(this._testRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
// ── ZIP extraction tests ──────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void SafeExtractZip_ValidArchive_ExtractsToDestination()
|
||||
{
|
||||
// Arrange
|
||||
string destDir = Path.Combine(this._testRoot, "valid-extract");
|
||||
Directory.CreateDirectory(destDir);
|
||||
byte[] zip = CreateZipWithEntry("SKILL.md", "---\nname: test\ndescription: Test\n---\nBody.");
|
||||
|
||||
// Act
|
||||
using var archive = new ZipArchive(new MemoryStream(zip), ZipArchiveMode.Read);
|
||||
SafeExtractZip(archive, destDir);
|
||||
|
||||
// Assert
|
||||
Assert.True(File.Exists(Path.Combine(destDir, "SKILL.md")));
|
||||
string content = File.ReadAllText(Path.Combine(destDir, "SKILL.md"));
|
||||
Assert.Contains("name: test", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SafeExtractZip_ZipSlipAttempt_ThrowsInvalidOperationException()
|
||||
{
|
||||
// Arrange
|
||||
string destDir = Path.Combine(this._testRoot, "zipslip-test");
|
||||
Directory.CreateDirectory(destDir);
|
||||
byte[] zip = CreateZipWithEntry("../../../evil.txt", "malicious content");
|
||||
|
||||
// Act & Assert
|
||||
using var archive = new ZipArchive(new MemoryStream(zip), ZipArchiveMode.Read);
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => SafeExtractZip(archive, destDir));
|
||||
Assert.Contains("outside of", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SafeExtractZip_SiblingPrefixAttack_ThrowsInvalidOperationException()
|
||||
{
|
||||
// Arrange — sibling path that starts with the dest dir name
|
||||
string destDir = Path.Combine(this._testRoot, "target");
|
||||
Directory.CreateDirectory(destDir);
|
||||
byte[] zip = CreateZipWithEntry("../target-evil/payload.txt", "exploit");
|
||||
|
||||
// Act & Assert
|
||||
using var archive = new ZipArchive(new MemoryStream(zip), ZipArchiveMode.Read);
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => SafeExtractZip(archive, destDir));
|
||||
Assert.Contains("outside of", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SafeExtractZip_DirectoryEntry_CreatesDirectory()
|
||||
{
|
||||
// Arrange
|
||||
string destDir = Path.Combine(this._testRoot, "dir-entry");
|
||||
Directory.CreateDirectory(destDir);
|
||||
byte[] zip = CreateZipWithDirectoryEntry("subdir/");
|
||||
|
||||
// Act
|
||||
using var archive = new ZipArchive(new MemoryStream(zip), ZipArchiveMode.Read);
|
||||
SafeExtractZip(archive, destDir);
|
||||
|
||||
// Assert
|
||||
Assert.True(Directory.Exists(Path.Combine(destDir, "subdir")));
|
||||
}
|
||||
|
||||
// ── Skill name validation tests ──────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("../escape")]
|
||||
[InlineData("path/traversal")]
|
||||
[InlineData("path\\traversal")]
|
||||
[InlineData("has.dots")]
|
||||
public void ValidateSkillName_InvalidNames_Rejected(string name)
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.True(IsInvalidSkillName(name), $"Expected '{name}' to be rejected.");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("support-style")]
|
||||
[InlineData("escalation-policy")]
|
||||
[InlineData("my-skill-123")]
|
||||
public void ValidateSkillName_ValidNames_Accepted(string name)
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.False(IsInvalidSkillName(name), $"Expected '{name}' to be accepted.");
|
||||
}
|
||||
|
||||
// ── AgentSkillsProvider integration with downloaded skill directories ─────
|
||||
|
||||
[Fact]
|
||||
public async Task AgentSkillsProvider_WithDownloadedSkills_AdvertisesAndLoadsAsync()
|
||||
{
|
||||
// Arrange — simulate the Foundry download + extract flow
|
||||
string downloadDir = Path.Combine(this._testRoot, "downloaded_skills");
|
||||
Directory.CreateDirectory(downloadDir);
|
||||
|
||||
CreateDownloadedSkill(downloadDir, "support-style",
|
||||
"---\nname: support-style\ndescription: Contoso Outdoors customer-support tone and formatting guidelines.\n---\n\n# Contoso Outdoors Support Style\n\nYou are speaking on behalf of Contoso Outdoors.\n\n## Canary\n\nInclude STYLE-CANARY-3318.");
|
||||
CreateDownloadedSkill(downloadDir, "escalation-policy",
|
||||
"---\nname: escalation-policy\ndescription: When and how to escalate Contoso Outdoors customer-support tickets.\n---\n\n# Escalation Policy\n\nProvide ESC-CANARY-7742.");
|
||||
|
||||
var provider = new AgentSkillsProvider(downloadDir, scriptRunner: null);
|
||||
var inputContext = new AIContext
|
||||
{
|
||||
Instructions = "You are a customer-support assistant for Contoso Outdoors."
|
||||
};
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext);
|
||||
|
||||
// Act
|
||||
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
|
||||
// Assert — skills are advertised in instructions
|
||||
Assert.NotNull(result.Instructions);
|
||||
Assert.Contains("support-style", result.Instructions);
|
||||
Assert.Contains("escalation-policy", result.Instructions);
|
||||
Assert.Contains("Contoso Outdoors customer-support tone", result.Instructions);
|
||||
|
||||
// Assert — load_skill tool is available
|
||||
Assert.NotNull(result.Tools);
|
||||
var toolNames = result.Tools!.Select(t => t.Name).ToList();
|
||||
Assert.Contains("load_skill", toolNames);
|
||||
// All tools are always included regardless of whether skills have resources or scripts
|
||||
Assert.Contains("read_skill_resource", toolNames);
|
||||
Assert.Contains("run_skill_script", toolNames);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadSkill_ReturnsFullContentWithCanaryAsync()
|
||||
{
|
||||
// Arrange
|
||||
string downloadDir = Path.Combine(this._testRoot, "canary_skills");
|
||||
Directory.CreateDirectory(downloadDir);
|
||||
CreateDownloadedSkill(downloadDir, "support-style",
|
||||
"---\nname: support-style\ndescription: Contoso tone guidelines.\n---\n\nInclude STYLE-CANARY-3318 at the bottom.");
|
||||
|
||||
var provider = new AgentSkillsProvider(downloadDir, scriptRunner: null);
|
||||
var inputContext = new AIContext();
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext);
|
||||
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
|
||||
var loadSkillTool = result.Tools!.First(t => t.Name == "load_skill") as AIFunction;
|
||||
Assert.NotNull(loadSkillTool);
|
||||
|
||||
// Act
|
||||
var content = await loadSkillTool!.InvokeAsync(
|
||||
new AIFunctionArguments(new System.Collections.Generic.Dictionary<string, object?> { ["skillName"] = "support-style" }));
|
||||
|
||||
// Assert
|
||||
var text = content!.ToString()!;
|
||||
Assert.Contains("STYLE-CANARY-3318", text);
|
||||
Assert.Contains("name: support-style", text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadSkill_UnknownName_ReturnsErrorAsync()
|
||||
{
|
||||
// Arrange
|
||||
string downloadDir = Path.Combine(this._testRoot, "error_skills");
|
||||
Directory.CreateDirectory(downloadDir);
|
||||
CreateDownloadedSkill(downloadDir, "support-style",
|
||||
"---\nname: support-style\ndescription: Test\n---\nBody.");
|
||||
|
||||
var provider = new AgentSkillsProvider(downloadDir, scriptRunner: null);
|
||||
var inputContext = new AIContext();
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext);
|
||||
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
|
||||
var loadSkillTool = result.Tools!.First(t => t.Name == "load_skill") as AIFunction;
|
||||
|
||||
// Act
|
||||
var content = await loadSkillTool!.InvokeAsync(
|
||||
new AIFunctionArguments(new System.Collections.Generic.Dictionary<string, object?> { ["skillName"] = "nonexistent-skill" }));
|
||||
|
||||
// Assert
|
||||
var text = content!.ToString()!;
|
||||
Assert.Contains("Error", text);
|
||||
Assert.Contains("not found", text);
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Creates a downloaded skill directory with a SKILL.md file — simulating what
|
||||
/// the Foundry download + ZIP extract flow produces.
|
||||
/// </summary>
|
||||
private static void CreateDownloadedSkill(string parentDir, string name, string content)
|
||||
{
|
||||
string skillDir = Path.Combine(parentDir, name);
|
||||
Directory.CreateDirectory(skillDir);
|
||||
File.WriteAllText(Path.Combine(skillDir, "SKILL.md"), content);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a ZIP archive in memory containing a single file entry.
|
||||
/// </summary>
|
||||
private static byte[] CreateZipWithEntry(string entryName, string content)
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true))
|
||||
{
|
||||
var entry = archive.CreateEntry(entryName);
|
||||
using var writer = new StreamWriter(entry.Open());
|
||||
writer.Write(content);
|
||||
}
|
||||
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a ZIP archive in memory containing a single directory entry.
|
||||
/// </summary>
|
||||
private static byte[] CreateZipWithDirectoryEntry(string directoryName)
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true))
|
||||
{
|
||||
// Directory entries in ZIPs have an empty name portion and end with /
|
||||
archive.CreateEntry(directoryName);
|
||||
}
|
||||
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mirrors the zip-slip guard from the Hosted-AgentSkills sample Program.cs.
|
||||
/// </summary>
|
||||
private static void SafeExtractZip(ZipArchive archive, string destinationDir)
|
||||
{
|
||||
string destRoot = Path.GetFullPath(destinationDir);
|
||||
string destRootWithSep = Path.EndsInDirectorySeparator(destRoot)
|
||||
? destRoot
|
||||
: destRoot + Path.DirectorySeparatorChar;
|
||||
|
||||
var comparison = OperatingSystem.IsWindows()
|
||||
? StringComparison.OrdinalIgnoreCase
|
||||
: StringComparison.Ordinal;
|
||||
|
||||
foreach (ZipArchiveEntry entry in archive.Entries)
|
||||
{
|
||||
string entryPath = Path.GetFullPath(Path.Combine(destRoot, entry.FullName));
|
||||
if (!entryPath.StartsWith(destRootWithSep, comparison)
|
||||
&& !string.Equals(entryPath, destRoot, comparison))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Refusing to extract unsafe path '{entry.FullName}' outside of '{destRoot}'.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(entry.Name))
|
||||
{
|
||||
Directory.CreateDirectory(entryPath);
|
||||
}
|
||||
else
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(entryPath)!);
|
||||
entry.ExtractToFile(entryPath, overwrite: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mirrors the skill name validation from the Hosted-AgentSkills sample Program.cs.
|
||||
/// </summary>
|
||||
private static bool IsInvalidSkillName(string name) =>
|
||||
name.Contains('.') || name.Contains('/') || name.Contains('\\') || Path.IsPathRooted(name);
|
||||
}
|
||||
@@ -31,13 +31,7 @@ internal sealed class TestAgentSkill : AgentSkill
|
||||
public override AgentSkillFrontmatter Frontmatter => this._frontmatter;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string Content => this._content;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override IReadOnlyList<AgentSkillResource>? Resources => null;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override IReadOnlyList<AgentSkillScript>? Scripts => null;
|
||||
public override ValueTask<string> GetContentAsync(CancellationToken cancellationToken = default) => new(this._content);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
+11
-11
@@ -73,7 +73,7 @@ public class AgentModeProviderTests
|
||||
{
|
||||
// Arrange
|
||||
var (tools, state) = await CreateToolsWithStateAsync();
|
||||
AIFunction setMode = GetTool(tools, "AgentMode_Set");
|
||||
AIFunction setMode = GetTool(tools, "mode_set");
|
||||
|
||||
// Act
|
||||
await setMode.InvokeAsync(new AIFunctionArguments() { ["mode"] = "execute" });
|
||||
@@ -90,7 +90,7 @@ public class AgentModeProviderTests
|
||||
{
|
||||
// Arrange
|
||||
var (tools, _) = await CreateToolsWithStateAsync();
|
||||
AIFunction setMode = GetTool(tools, "AgentMode_Set");
|
||||
AIFunction setMode = GetTool(tools, "mode_set");
|
||||
|
||||
// Act
|
||||
object? result = await setMode.InvokeAsync(new AIFunctionArguments() { ["mode"] = "execute" });
|
||||
@@ -107,8 +107,8 @@ public class AgentModeProviderTests
|
||||
{
|
||||
// Arrange
|
||||
var (tools, provider, session) = await CreateToolsWithProviderAndSessionAsync();
|
||||
AIFunction setMode = GetTool(tools, "AgentMode_Set");
|
||||
AIFunction getMode = GetTool(tools, "AgentMode_Get");
|
||||
AIFunction setMode = GetTool(tools, "mode_set");
|
||||
AIFunction getMode = GetTool(tools, "mode_get");
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentException>(async () =>
|
||||
@@ -131,7 +131,7 @@ public class AgentModeProviderTests
|
||||
{
|
||||
// Arrange
|
||||
var (tools, _) = await CreateToolsWithStateAsync();
|
||||
AIFunction getMode = GetTool(tools, "AgentMode_Get");
|
||||
AIFunction getMode = GetTool(tools, "mode_get");
|
||||
|
||||
// Act
|
||||
object? result = await getMode.InvokeAsync(new AIFunctionArguments());
|
||||
@@ -148,8 +148,8 @@ public class AgentModeProviderTests
|
||||
{
|
||||
// Arrange
|
||||
var (tools, _) = await CreateToolsWithStateAsync();
|
||||
AIFunction setMode = GetTool(tools, "AgentMode_Set");
|
||||
AIFunction getMode = GetTool(tools, "AgentMode_Get");
|
||||
AIFunction setMode = GetTool(tools, "mode_set");
|
||||
AIFunction getMode = GetTool(tools, "mode_get");
|
||||
|
||||
// Act
|
||||
await setMode.InvokeAsync(new AIFunctionArguments() { ["mode"] = "execute" });
|
||||
@@ -236,7 +236,7 @@ public class AgentModeProviderTests
|
||||
|
||||
// Act
|
||||
AIContext result = await provider.InvokingAsync(context);
|
||||
AIFunction getMode = GetTool(result.Tools!, "AgentMode_Get");
|
||||
AIFunction getMode = GetTool(result.Tools!, "mode_get");
|
||||
object? modeResult = await getMode.InvokeAsync(new AIFunctionArguments());
|
||||
|
||||
// Assert
|
||||
@@ -264,12 +264,12 @@ public class AgentModeProviderTests
|
||||
|
||||
// Act — first invocation changes mode
|
||||
AIContext result1 = await provider.InvokingAsync(context);
|
||||
AIFunction setMode = GetTool(result1.Tools!, "AgentMode_Set");
|
||||
AIFunction setMode = GetTool(result1.Tools!, "mode_set");
|
||||
await setMode.InvokeAsync(new AIFunctionArguments() { ["mode"] = "execute" });
|
||||
|
||||
// Second invocation should see the updated mode
|
||||
AIContext result2 = await provider.InvokingAsync(context);
|
||||
AIFunction getMode = GetTool(result2.Tools!, "AgentMode_Get");
|
||||
AIFunction getMode = GetTool(result2.Tools!, "mode_get");
|
||||
object? modeResult = await getMode.InvokeAsync(new AIFunctionArguments());
|
||||
|
||||
// Assert
|
||||
@@ -579,7 +579,7 @@ public class AgentModeProviderTests
|
||||
|
||||
// First call to initialize
|
||||
AIContext result1 = await provider.InvokingAsync(context);
|
||||
AIFunction setMode = GetTool(result1.Tools!, "AgentMode_Set");
|
||||
AIFunction setMode = GetTool(result1.Tools!, "mode_set");
|
||||
|
||||
// Change mode via the tool (agent-initiated)
|
||||
await setMode.InvokeAsync(new AIFunctionArguments() { ["mode"] = "execute" });
|
||||
|
||||
@@ -51,7 +51,7 @@ public class TodoProviderTests
|
||||
{
|
||||
// Arrange
|
||||
var (tools, state) = await CreateToolsWithStateAsync();
|
||||
AIFunction addTodos = GetTool(tools, "TodoList_Add");
|
||||
AIFunction addTodos = GetTool(tools, "todos_add");
|
||||
|
||||
// Act
|
||||
await addTodos.InvokeAsync(new AIFunctionArguments()
|
||||
@@ -75,7 +75,7 @@ public class TodoProviderTests
|
||||
{
|
||||
// Arrange
|
||||
var (tools, state) = await CreateToolsWithStateAsync();
|
||||
AIFunction addTodos = GetTool(tools, "TodoList_Add");
|
||||
AIFunction addTodos = GetTool(tools, "todos_add");
|
||||
|
||||
// Act
|
||||
await addTodos.InvokeAsync(new AIFunctionArguments()
|
||||
@@ -111,8 +111,8 @@ public class TodoProviderTests
|
||||
{
|
||||
// Arrange
|
||||
var (tools, state) = await CreateToolsWithStateAsync();
|
||||
AIFunction addTodos = GetTool(tools, "TodoList_Add");
|
||||
AIFunction completeTodos = GetTool(tools, "TodoList_Complete");
|
||||
AIFunction addTodos = GetTool(tools, "todos_add");
|
||||
AIFunction completeTodos = GetTool(tools, "todos_complete");
|
||||
await addTodos.InvokeAsync(new AIFunctionArguments() { ["todos"] = new List<TodoItemInput> { new() { Title = "Test", Description = null } } });
|
||||
|
||||
// Act
|
||||
@@ -131,8 +131,8 @@ public class TodoProviderTests
|
||||
{
|
||||
// Arrange
|
||||
var (tools, state) = await CreateToolsWithStateAsync();
|
||||
AIFunction addTodos = GetTool(tools, "TodoList_Add");
|
||||
AIFunction completeTodos = GetTool(tools, "TodoList_Complete");
|
||||
AIFunction addTodos = GetTool(tools, "todos_add");
|
||||
AIFunction completeTodos = GetTool(tools, "todos_complete");
|
||||
await addTodos.InvokeAsync(new AIFunctionArguments()
|
||||
{
|
||||
["todos"] = new List<TodoItemInput> { new() { Title = "First" }, new() { Title = "Second" }, new() { Title = "Third" } },
|
||||
@@ -156,7 +156,7 @@ public class TodoProviderTests
|
||||
{
|
||||
// Arrange
|
||||
var (tools, _) = await CreateToolsWithStateAsync();
|
||||
AIFunction completeTodos = GetTool(tools, "TodoList_Complete");
|
||||
AIFunction completeTodos = GetTool(tools, "todos_complete");
|
||||
|
||||
// Act
|
||||
object? result = await completeTodos.InvokeAsync(new AIFunctionArguments() { ["items"] = new List<TodoCompleteInput> { new() { Id = 999, Reason = "Done" } } });
|
||||
@@ -173,8 +173,8 @@ public class TodoProviderTests
|
||||
{
|
||||
// Arrange
|
||||
var (tools, state) = await CreateToolsWithStateAsync();
|
||||
AIFunction addTodos = GetTool(tools, "TodoList_Add");
|
||||
AIFunction completeTodos = GetTool(tools, "TodoList_Complete");
|
||||
AIFunction addTodos = GetTool(tools, "todos_add");
|
||||
AIFunction completeTodos = GetTool(tools, "todos_complete");
|
||||
await addTodos.InvokeAsync(new AIFunctionArguments() { ["todos"] = new List<TodoItemInput> { new() { Title = "Research topic" } } });
|
||||
|
||||
// Act
|
||||
@@ -200,8 +200,8 @@ public class TodoProviderTests
|
||||
{
|
||||
// Arrange
|
||||
var (tools, state) = await CreateToolsWithStateAsync();
|
||||
AIFunction addTodos = GetTool(tools, "TodoList_Add");
|
||||
AIFunction removeTodos = GetTool(tools, "TodoList_Remove");
|
||||
AIFunction addTodos = GetTool(tools, "todos_add");
|
||||
AIFunction removeTodos = GetTool(tools, "todos_remove");
|
||||
await addTodos.InvokeAsync(new AIFunctionArguments() { ["todos"] = new List<TodoItemInput> { new() { Title = "Test", Description = null } } });
|
||||
|
||||
// Act
|
||||
@@ -220,8 +220,8 @@ public class TodoProviderTests
|
||||
{
|
||||
// Arrange
|
||||
var (tools, state) = await CreateToolsWithStateAsync();
|
||||
AIFunction addTodos = GetTool(tools, "TodoList_Add");
|
||||
AIFunction removeTodos = GetTool(tools, "TodoList_Remove");
|
||||
AIFunction addTodos = GetTool(tools, "todos_add");
|
||||
AIFunction removeTodos = GetTool(tools, "todos_remove");
|
||||
await addTodos.InvokeAsync(new AIFunctionArguments()
|
||||
{
|
||||
["todos"] = new List<TodoItemInput> { new() { Title = "First" }, new() { Title = "Second" }, new() { Title = "Third" } },
|
||||
@@ -244,7 +244,7 @@ public class TodoProviderTests
|
||||
{
|
||||
// Arrange
|
||||
var (tools, _) = await CreateToolsWithStateAsync();
|
||||
AIFunction removeTodos = GetTool(tools, "TodoList_Remove");
|
||||
AIFunction removeTodos = GetTool(tools, "todos_remove");
|
||||
|
||||
// Act
|
||||
object? result = await removeTodos.InvokeAsync(new AIFunctionArguments() { ["ids"] = new List<int> { 999 } });
|
||||
@@ -265,9 +265,9 @@ public class TodoProviderTests
|
||||
{
|
||||
// Arrange
|
||||
var (tools, _) = await CreateToolsWithStateAsync();
|
||||
AIFunction addTodos = GetTool(tools, "TodoList_Add");
|
||||
AIFunction completeTodos = GetTool(tools, "TodoList_Complete");
|
||||
AIFunction getRemainingTodos = GetTool(tools, "TodoList_GetRemaining");
|
||||
AIFunction addTodos = GetTool(tools, "todos_add");
|
||||
AIFunction completeTodos = GetTool(tools, "todos_complete");
|
||||
AIFunction getRemainingTodos = GetTool(tools, "todos_get_remaining");
|
||||
await addTodos.InvokeAsync(new AIFunctionArguments()
|
||||
{
|
||||
["todos"] = new List<TodoItemInput> { new() { Title = "Done", Description = null }, new() { Title = "Pending", Description = null } },
|
||||
@@ -295,9 +295,9 @@ public class TodoProviderTests
|
||||
{
|
||||
// Arrange
|
||||
var (tools, _) = await CreateToolsWithStateAsync();
|
||||
AIFunction addTodos = GetTool(tools, "TodoList_Add");
|
||||
AIFunction completeTodos = GetTool(tools, "TodoList_Complete");
|
||||
AIFunction getAllTodos = GetTool(tools, "TodoList_GetAll");
|
||||
AIFunction addTodos = GetTool(tools, "todos_add");
|
||||
AIFunction completeTodos = GetTool(tools, "todos_complete");
|
||||
AIFunction getAllTodos = GetTool(tools, "todos_get_all");
|
||||
await addTodos.InvokeAsync(new AIFunctionArguments()
|
||||
{
|
||||
["todos"] = new List<TodoItemInput> { new() { Title = "Done", Description = null }, new() { Title = "Pending", Description = null } },
|
||||
@@ -332,12 +332,12 @@ public class TodoProviderTests
|
||||
|
||||
// Act — first invocation adds a todo
|
||||
AIContext result1 = await provider.InvokingAsync(context);
|
||||
AIFunction addTodos = (AIFunction)result1.Tools!.First(t => t is AIFunction f && f.Name == "TodoList_Add");
|
||||
AIFunction addTodos = (AIFunction)result1.Tools!.First(t => t is AIFunction f && f.Name == "todos_add");
|
||||
await addTodos.InvokeAsync(new AIFunctionArguments() { ["todos"] = new List<TodoItemInput> { new() { Title = "Persisted", Description = null } } });
|
||||
|
||||
// Second invocation should see the same state
|
||||
AIContext result2 = await provider.InvokingAsync(context);
|
||||
AIFunction getAllTodos = (AIFunction)result2.Tools!.First(t => t is AIFunction f && f.Name == "TodoList_GetAll");
|
||||
AIFunction getAllTodos = (AIFunction)result2.Tools!.First(t => t is AIFunction f && f.Name == "todos_get_all");
|
||||
object? allResult = await getAllTodos.InvokeAsync(new AIFunctionArguments());
|
||||
|
||||
// Assert
|
||||
@@ -364,7 +364,7 @@ public class TodoProviderTests
|
||||
var context = new AIContextProvider.InvokingContext(agent, session, new AIContext());
|
||||
#pragma warning restore MAAI001
|
||||
AIContext result = await provider.InvokingAsync(context);
|
||||
AIFunction addTodos = GetTool(result.Tools!, "TodoList_Add");
|
||||
AIFunction addTodos = GetTool(result.Tools!, "todos_add");
|
||||
await addTodos.InvokeAsync(new AIFunctionArguments()
|
||||
{
|
||||
["todos"] = new List<TodoItemInput> { new() { Title = "First", Description = null }, new() { Title = "Second", Description = null } },
|
||||
@@ -393,8 +393,8 @@ public class TodoProviderTests
|
||||
var context = new AIContextProvider.InvokingContext(agent, session, new AIContext());
|
||||
#pragma warning restore MAAI001
|
||||
AIContext result = await provider.InvokingAsync(context);
|
||||
AIFunction addTodos = GetTool(result.Tools!, "TodoList_Add");
|
||||
AIFunction completeTodos = GetTool(result.Tools!, "TodoList_Complete");
|
||||
AIFunction addTodos = GetTool(result.Tools!, "todos_add");
|
||||
AIFunction completeTodos = GetTool(result.Tools!, "todos_complete");
|
||||
await addTodos.InvokeAsync(new AIFunctionArguments()
|
||||
{
|
||||
["todos"] = new List<TodoItemInput> { new() { Title = "Done", Description = null }, new() { Title = "Pending", Description = null } },
|
||||
@@ -556,8 +556,8 @@ public class TodoProviderTests
|
||||
|
||||
// First invocation — add some todos (one with a description to cover that branch)
|
||||
AIContext result1 = await provider.InvokingAsync(context);
|
||||
AIFunction addTodos = (AIFunction)result1.Tools!.First(t => t is AIFunction f && f.Name == "TodoList_Add");
|
||||
AIFunction completeTodos = (AIFunction)result1.Tools!.First(t => t is AIFunction f && f.Name == "TodoList_Complete");
|
||||
AIFunction addTodos = (AIFunction)result1.Tools!.First(t => t is AIFunction f && f.Name == "todos_add");
|
||||
AIFunction completeTodos = (AIFunction)result1.Tools!.First(t => t is AIFunction f && f.Name == "todos_complete");
|
||||
await addTodos.InvokeAsync(new AIFunctionArguments()
|
||||
{
|
||||
["todos"] = new List<TodoItemInput>
|
||||
@@ -622,7 +622,7 @@ public class TodoProviderTests
|
||||
|
||||
// First invocation — add a todo
|
||||
AIContext result1 = await provider.InvokingAsync(context);
|
||||
AIFunction addTodos = (AIFunction)result1.Tools!.First(t => t is AIFunction f && f.Name == "TodoList_Add");
|
||||
AIFunction addTodos = (AIFunction)result1.Tools!.First(t => t is AIFunction f && f.Name == "todos_add");
|
||||
await addTodos.InvokeAsync(new AIFunctionArguments()
|
||||
{
|
||||
["todos"] = new List<TodoItemInput> { new() { Title = "Task A" } },
|
||||
@@ -687,7 +687,7 @@ public class TodoProviderTests
|
||||
|
||||
// Add a todo
|
||||
AIContext result1 = await provider.InvokingAsync(context);
|
||||
AIFunction addTodos = (AIFunction)result1.Tools!.First(t => t is AIFunction f && f.Name == "TodoList_Add");
|
||||
AIFunction addTodos = (AIFunction)result1.Tools!.First(t => t is AIFunction f && f.Name == "todos_add");
|
||||
await addTodos.InvokeAsync(new AIFunctionArguments()
|
||||
{
|
||||
["todos"] = new List<TodoItemInput> { new() { Title = "Original" } },
|
||||
@@ -725,8 +725,8 @@ public class TodoProviderTests
|
||||
var context = new AIContextProvider.InvokingContext(agent, session, new AIContext());
|
||||
#pragma warning restore MAAI001
|
||||
AIContext result = await provider.InvokingAsync(context);
|
||||
AIFunction addTodos = GetTool(result.Tools!, "TodoList_Add");
|
||||
AIFunction getAllTodos = GetTool(result.Tools!, "TodoList_GetAll");
|
||||
AIFunction addTodos = GetTool(result.Tools!, "todos_add");
|
||||
AIFunction getAllTodos = GetTool(result.Tools!, "todos_get_all");
|
||||
|
||||
// Act — launch multiple concurrent adds
|
||||
var tasks = Enumerable.Range(0, 10).Select(i =>
|
||||
@@ -760,9 +760,9 @@ public class TodoProviderTests
|
||||
var context = new AIContextProvider.InvokingContext(agent, session, new AIContext());
|
||||
#pragma warning restore MAAI001
|
||||
AIContext result = await provider.InvokingAsync(context);
|
||||
AIFunction addTodos = GetTool(result.Tools!, "TodoList_Add");
|
||||
AIFunction completeTodos = GetTool(result.Tools!, "TodoList_Complete");
|
||||
AIFunction getAllTodos = GetTool(result.Tools!, "TodoList_GetAll");
|
||||
AIFunction addTodos = GetTool(result.Tools!, "todos_add");
|
||||
AIFunction completeTodos = GetTool(result.Tools!, "todos_complete");
|
||||
AIFunction getAllTodos = GetTool(result.Tools!, "todos_get_all");
|
||||
|
||||
// Add initial items
|
||||
await addTodos.InvokeAsync(new AIFunctionArguments()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user