Compare commits

...
Author SHA1 Message Date
Jacob AlberandGitHub c2b4c14a9f Merge branch 'main' into dev/dotnet_workflow/handoff_hitl 2026-04-13 10:17:20 -04:00
westeyandGitHub 39b560f83c Add missing path to verify-samples run checkout (#5194) 2026-04-13 11:00:31 +00:00
Jacob AlberandGitHub 52c40e47a1 Merge branch 'main' into dev/dotnet_workflow/handoff_hitl 2026-04-10 10:54:25 -04:00
3e864cdb4c .NET: Update version to 1.1.0 (#5204)
* Update version to 1.1.0

* Apply suggestion from @Copilot

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

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-10 15:28:00 +01:00
14d2ab3262 Standardize file skills terminology on 'directory' (#5205)
Rename authored identifiers, XML docs, log messages, and comments
from 'folder' to 'directory' across the file skills codebase for
consistency with the agentskills.io specification and .NET conventions.

Public API changes (experimental):
- ScriptFolders → ScriptDirectories
- ResourceFolders → ResourceDirectories

.NET BCL API calls (Directory.Exists, Path.GetDirectoryName, etc.)
were already using 'directory' and are unchanged.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-10 15:27:45 +01:00
Jacob AlberandGitHub c0538349bb Merge branch 'main' into dev/dotnet_workflow/handoff_hitl 2026-04-10 07:39:08 -04:00
Jacob Alber 1326d79a26 fix: Remove '$' from exception strings 2026-04-10 07:38:18 -04:00
e5f7b9c260 .NET: Support reflection for discovery of resources and scripts in class-based skills (#5183)
* support reflection for discovery of resources and scripts in class-based skills

* fix format issues

* refactor samples to use reflection

* Validate resource member signatures during discovery

Add discovery-time validation in AgentClassSkill.DiscoverResources() to
fail fast when [AgentSkillResource] is applied to members with incompatible
signatures:

- Reject indexer properties (getter has parameters)
- Reject methods with parameters other than IServiceProvider or
  CancellationToken

Throws InvalidOperationException with actionable error messages instead of
allowing silent runtime failures when ReadAsync invokes the AIFunction with
no named arguments.

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

* prevent duplicates

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-10 11:56:28 +01:00
Jacob Alber c3f099958b feat: Refactor Handoff Orchestration and add HITL support
* Change HandoffAgentExecutor to use factory-based instantiation
* Extract shared request collection logic in AIAgentUnservicedRequestsCollector
* Refactor HandoffAgentExecutor to use the "ContinueTurn" pattern as in AIAgentHostExecutor
2026-04-08 15:32:39 -04:00
39 changed files with 2445 additions and 1145 deletions
+2 -1
View File
@@ -48,7 +48,8 @@ jobs:
.
.github
dotnet
workflow-samples
python
declarative-agents
- name: Setup dotnet
uses: actions/setup-dotnet@v5.2.0
+8 -8
View File
@@ -1,21 +1,21 @@
<Project>
<PropertyGroup>
<!-- Central version prefix - applies to all nuget packages. -->
<VersionPrefix>1.0.0</VersionPrefix>
<RCNumber>6</RCNumber>
<VersionPrefix>1.1.0</VersionPrefix>
<RCNumber>1</RCNumber>
<PackageVersion Condition="'$(IsReleaseCandidate)' == 'true'">$(VersionPrefix)-rc$(RCNumber)</PackageVersion>
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).260402.1</PackageVersion>
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' == ''">$(VersionPrefix)-preview.260402.1</PackageVersion>
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).260410.1</PackageVersion>
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' == ''">$(VersionPrefix)-preview.260410.1</PackageVersion>
<PackageVersion Condition="'$(IsReleased)' == 'true'">$(VersionPrefix)</PackageVersion>
<GitTag>1.0.0</GitTag>
<GitTag>1.1.0</GitTag>
<Configurations>Debug;Release;Publish</Configurations>
<IsPackable>true</IsPackable>
<!-- Package validation. Baseline Version should be the latest version available on NuGet. -->
<PackageValidationBaselineVersion>1.0.0-rc5</PackageValidationBaselineVersion>
<!-- Enable validation for RC packages and GA packages -->
<EnablePackageValidation Condition="'$(IsReleaseCandidate)' == 'true' OR '$(IsReleased)' == 'true'">true</EnablePackageValidation>
<PackageValidationBaselineVersion>1.0.0</PackageValidationBaselineVersion>
<!-- Enable validation for GA packages -->
<EnablePackageValidation Condition="'$(IsReleased)' == 'true'">true</EnablePackageValidation>
<!-- Validate assembly attributes only for Publish builds -->
<NoWarn Condition="'$(Configuration)' != 'Publish'">$(NoWarn);CP0003</NoWarn>
<!-- Do not validate reference assemblies -->
@@ -6,7 +6,7 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<NoWarn>$(NoWarn);MAAI001</NoWarn>
<NoWarn>$(NoWarn);MAAI001;IDE0051</NoWarn>
</PropertyGroup>
<ItemGroup>
@@ -1,8 +1,9 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates how to define Agent Skills as C# classes using AgentClassSkill.
// Class-based skills bundle all components into a single class implementation.
// This sample demonstrates how to define Agent Skills as C# classes using AgentClassSkill
// with attributes for automatic script and resource discovery.
using System.ComponentModel;
using System.Text.Json;
using Azure.AI.OpenAI;
using Azure.Identity;
@@ -44,17 +45,16 @@ AgentResponse response = await agent.RunAsync(
Console.WriteLine($"Agent: {response.Text}");
/// <summary>
/// A unit-converter skill defined as a C# class.
/// A unit-converter skill defined as a C# class using attributes for discovery.
/// </summary>
/// <remarks>
/// Class-based skills bundle all components (name, description, body, resources, scripts)
/// into a single class.
/// 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.
/// </remarks>
internal sealed class UnitConverterSkill : AgentClassSkill
internal sealed class UnitConverterSkill : AgentClassSkill<UnitConverterSkill>
{
private IReadOnlyList<AgentSkillResource>? _resources;
private IReadOnlyList<AgentSkillScript>? _scripts;
/// <inheritdoc/>
public override AgentSkillFrontmatter Frontmatter { get; } = new(
"unit-converter",
@@ -69,31 +69,40 @@ internal sealed class UnitConverterSkill : AgentClassSkill
3. Present the result clearly with both units.
""";
/// <inheritdoc/>
public override IReadOnlyList<AgentSkillResource>? Resources => this._resources ??=
[
CreateResource(
"conversion-table",
"""
# Conversion Tables
/// <summary>
/// Gets the <see cref="JsonSerializerOptions"/> used to marshal parameters and return values
/// for scripts and resources.
/// </summary>
/// <remarks>
/// This override is not necessary for this sample, but can be used to provide custom
/// serialization options, for example a source-generated <c>JsonTypeInfoResolver</c>
/// for Native AOT compatibility.
/// </remarks>
protected override JsonSerializerOptions? SerializerOptions => null;
Formula: **result = value × factor**
/// <summary>
/// A conversion table resource providing multiplication factors.
/// </summary>
[AgentSkillResource("conversion-table")]
[Description("Lookup table of multiplication factors for common unit conversions.")]
public string ConversionTable => """
# Conversion Tables
| From | To | Factor |
|-------------|-------------|----------|
| miles | kilometers | 1.60934 |
| kilometers | miles | 0.621371 |
| pounds | kilograms | 0.453592 |
| kilograms | pounds | 2.20462 |
"""),
];
Formula: **result = value × factor**
/// <inheritdoc/>
public override IReadOnlyList<AgentSkillScript>? Scripts => this._scripts ??=
[
CreateScript("convert", ConvertUnits),
];
| From | To | Factor |
|-------------|-------------|----------|
| miles | kilometers | 1.60934 |
| kilometers | miles | 0.621371 |
| pounds | kilograms | 0.453592 |
| kilograms | pounds | 2.20462 |
""";
/// <summary>
/// Converts a value by the given factor.
/// </summary>
[AgentSkillScript("convert")]
[Description("Multiplies a value by a conversion factor and returns the result as JSON.")]
private static string ConvertUnits(double value, double factor)
{
double result = Math.Round(value * factor, 4);
@@ -1,12 +1,16 @@
# Class-Based Agent Skills Sample
This sample demonstrates how to define **Agent Skills as C# classes** using `AgentClassSkill`.
This sample demonstrates how to define **Agent Skills as C# classes** using `AgentClassSkill`
with **attributes** for automatic script and resource discovery.
## What it demonstrates
- Creating skills as classes that extend `AgentClassSkill`
- Bundling name, description, body, resources, and scripts into a single class
- Using `[AgentSkillResource]` on properties to define resources
- Using `[AgentSkillScript]` on methods to define scripts
- Automatic discovery (no need to override `Resources`/`Scripts`)
- Using the `AgentSkillsProvider` constructor with class-based skills
- Overriding `SerializerOptions` for Native AOT compatibility
## Skills Included
@@ -6,7 +6,7 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<NoWarn>$(NoWarn);MAAI001</NoWarn>
<NoWarn>$(NoWarn);MAAI001;IDE0051</NoWarn>
</PropertyGroup>
<ItemGroup>
@@ -8,11 +8,12 @@
// Three different skill sources are registered here:
// 1. File-based: unit-converter (miles↔km, pounds↔kg) from SKILL.md on disk
// 2. Code-defined: volume-converter (gallons↔liters) using AgentInlineSkill
// 3. Class-based: temperature-converter (°F↔°C↔K) using AgentClassSkill
// 3. Class-based: temperature-converter (°F↔°C↔K) using AgentClassSkill with attributes
//
// For simpler, single-source scenarios, see the earlier steps in this sample series
// (e.g., Step01 for file-based, Step02 for code-defined, Step03 for class-based).
using System.ComponentModel;
using System.Text.Json;
using Azure.AI.OpenAI;
using Azure.Identity;
@@ -89,13 +90,15 @@ AgentResponse response = await agent.RunAsync(
Console.WriteLine($"Agent: {response.Text}");
/// <summary>
/// A temperature-converter skill defined as a C# class.
/// A temperature-converter skill defined as a C# class using attributes for discovery.
/// </summary>
internal sealed class TemperatureConverterSkill : AgentClassSkill
/// <remarks>
/// 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.
/// </remarks>
internal sealed class TemperatureConverterSkill : AgentClassSkill<TemperatureConverterSkill>
{
private IReadOnlyList<AgentSkillResource>? _resources;
private IReadOnlyList<AgentSkillScript>? _scripts;
/// <inheritdoc/>
public override AgentSkillFrontmatter Frontmatter { get; } = new(
"temperature-converter",
@@ -110,29 +113,27 @@ internal sealed class TemperatureConverterSkill : AgentClassSkill
3. Present the result clearly with both temperature scales.
""";
/// <inheritdoc/>
public override IReadOnlyList<AgentSkillResource>? Resources => this._resources ??=
[
CreateResource(
"temperature-conversion-formulas",
"""
# Temperature Conversion Formulas
/// <summary>
/// A reference table of temperature conversion formulas.
/// </summary>
[AgentSkillResource("temperature-conversion-formulas")]
[Description("Formulas for converting between Fahrenheit, Celsius, and Kelvin.")]
public string ConversionFormulas => """
# Temperature Conversion Formulas
| From | To | Formula |
|-------------|-------------|---------------------------|
| Fahrenheit | Celsius | °C = (°F 32) × 5/9 |
| Celsius | Fahrenheit | °F = (°C × 9/5) + 32 |
| Celsius | Kelvin | K = °C + 273.15 |
| Kelvin | Celsius | °C = K 273.15 |
"""),
];
/// <inheritdoc/>
public override IReadOnlyList<AgentSkillScript>? Scripts => this._scripts ??=
[
CreateScript("convert-temperature", ConvertTemperature),
];
| From | To | Formula |
|-------------|-------------|---------------------------|
| Fahrenheit | Celsius | °C = (°F 32) × 5/9 |
| Celsius | Fahrenheit | °F = (°C × 9/5) + 32 |
| Celsius | Kelvin | K = °C + 273.15 |
| Kelvin | Celsius | °C = K 273.15 |
""";
/// <summary>
/// Converts a temperature value between scales.
/// </summary>
[AgentSkillScript("convert-temperature")]
[Description("Converts a temperature value from one scale to another.")]
private static string ConvertTemperature(double value, string from, string to)
{
double result = (from.ToUpperInvariant(), to.ToUpperInvariant()) switch
@@ -6,7 +6,7 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<NoWarn>$(NoWarn);MAAI001;CA1812</NoWarn>
<NoWarn>$(NoWarn);MAAI001;CA1812;IDE0051</NoWarn>
</PropertyGroup>
<ItemGroup>
@@ -13,6 +13,7 @@
// showing that DI works identically regardless of how the skill is defined.
// When prompted with a question spanning both domains, the agent uses both skills.
using System.ComponentModel;
using System.Text.Json;
using Azure.AI.OpenAI;
using Azure.Identity;
@@ -62,8 +63,8 @@ var distanceSkill = new AgentInlineSkill(
// Approach 2: Class-Based Skill with DI (AgentClassSkill)
// =====================================================================
// Handles weight conversions (pounds ↔ kilograms).
// Resources and scripts are encapsulated in a class. Factory methods
// CreateResource and CreateScript accept delegates with IServiceProvider.
// Resources and scripts are discovered via reflection using attributes.
// Methods with an IServiceProvider parameter receive DI automatically.
//
// Alternatively, class-based skills can accept dependencies through their
// constructor. Register the skill class itself in the ServiceCollection and
@@ -113,14 +114,13 @@ Console.WriteLine($"Agent: {response.Text}");
/// </summary>
/// <remarks>
/// This skill resolves <see cref="ConversionService"/> from the DI container
/// in both its resource and script functions. This enables clean separation of
/// concerns and testability while retaining the class-based skill pattern.
/// in both its resource and script methods. Methods with an <see cref="IServiceProvider"/>
/// parameter are automatically injected by the framework. Properties and methods annotated
/// with <see cref="AgentSkillResourceAttribute"/> and <see cref="AgentSkillScriptAttribute"/>
/// are automatically discovered via reflection.
/// </remarks>
internal sealed class WeightConverterSkill : AgentClassSkill
internal sealed class WeightConverterSkill : AgentClassSkill<WeightConverterSkill>
{
private IReadOnlyList<AgentSkillResource>? _resources;
private IReadOnlyList<AgentSkillScript>? _scripts;
/// <inheritdoc/>
public override AgentSkillFrontmatter Frontmatter { get; } = new(
"weight-converter",
@@ -135,25 +135,27 @@ internal sealed class WeightConverterSkill : AgentClassSkill
3. Present the result clearly with both units.
""";
/// <inheritdoc/>
public override IReadOnlyList<AgentSkillResource>? Resources => this._resources ??=
[
CreateResource("weight-table", (IServiceProvider serviceProvider) =>
{
var service = serviceProvider.GetRequiredService<ConversionService>();
return service.GetWeightTable();
}),
];
/// <summary>
/// Returns the weight conversion table from the DI-registered <see cref="ConversionService"/>.
/// </summary>
[AgentSkillResource("weight-table")]
[Description("Lookup table of multiplication factors for weight conversions.")]
private static string GetWeightTable(IServiceProvider serviceProvider)
{
var service = serviceProvider.GetRequiredService<ConversionService>();
return service.GetWeightTable();
}
/// <inheritdoc/>
public override IReadOnlyList<AgentSkillScript>? Scripts => this._scripts ??=
[
CreateScript("convert", (double value, double factor, IServiceProvider serviceProvider) =>
{
var service = serviceProvider.GetRequiredService<ConversionService>();
return service.Convert(value, factor);
}),
];
/// <summary>
/// Converts a value by the given factor using the DI-registered <see cref="ConversionService"/>.
/// </summary>
[AgentSkillScript("convert")]
[Description("Multiplies a value by a conversion factor and returns the result as JSON.")]
private static string Convert(double value, double factor, IServiceProvider serviceProvider)
{
var service = serviceProvider.GetRequiredService<ConversionService>();
return service.Convert(value, factor);
}
}
// ---------------------------------------------------------------------------
@@ -1,284 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- https://learn.microsoft.com/dotnet/fundamentals/package-validation/diagnostic-ids -->
<Suppressions xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.AsAIAgent(Azure.AI.Projects.AIProjectClient,Azure.AI.Projects.OpenAI.AgentRecord,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider)</Target>
<Left>lib/net10.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net10.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.AsAIAgent(Azure.AI.Projects.AIProjectClient,Azure.AI.Projects.OpenAI.AgentReference,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider)</Target>
<Left>lib/net10.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net10.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.AsAIAgent(Azure.AI.Projects.AIProjectClient,Azure.AI.Projects.OpenAI.AgentVersion,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider)</Target>
<Left>lib/net10.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net10.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.CreateAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,Azure.AI.Projects.AgentVersionCreationOptions,System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.Threading.CancellationToken)</Target>
<Left>lib/net10.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net10.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.CreateAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,Microsoft.Agents.AI.ChatClientAgentOptions,System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken)</Target>
<Left>lib/net10.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net10.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.CreateAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,System.String,System.String,System.String,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken)</Target>
<Left>lib/net10.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net10.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.GetAIAgentAsync(Azure.AI.Projects.AIProjectClient,Microsoft.Agents.AI.ChatClientAgentOptions,System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken)</Target>
<Left>lib/net10.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net10.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.GetAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken)</Target>
<Left>lib/net10.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net10.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.AsAIAgent(Azure.AI.Projects.AIProjectClient,Azure.AI.Projects.OpenAI.AgentRecord,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider)</Target>
<Left>lib/net472/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net472/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.AsAIAgent(Azure.AI.Projects.AIProjectClient,Azure.AI.Projects.OpenAI.AgentReference,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider)</Target>
<Left>lib/net472/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net472/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.AsAIAgent(Azure.AI.Projects.AIProjectClient,Azure.AI.Projects.OpenAI.AgentVersion,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider)</Target>
<Left>lib/net472/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net472/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.CreateAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,Azure.AI.Projects.AgentVersionCreationOptions,System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.Threading.CancellationToken)</Target>
<Left>lib/net472/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net472/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.CreateAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,Microsoft.Agents.AI.ChatClientAgentOptions,System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken)</Target>
<Left>lib/net472/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net472/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.CreateAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,System.String,System.String,System.String,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken)</Target>
<Left>lib/net472/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net472/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.GetAIAgentAsync(Azure.AI.Projects.AIProjectClient,Microsoft.Agents.AI.ChatClientAgentOptions,System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken)</Target>
<Left>lib/net472/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net472/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.GetAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken)</Target>
<Left>lib/net472/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net472/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.AsAIAgent(Azure.AI.Projects.AIProjectClient,Azure.AI.Projects.OpenAI.AgentRecord,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider)</Target>
<Left>lib/net8.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net8.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.AsAIAgent(Azure.AI.Projects.AIProjectClient,Azure.AI.Projects.OpenAI.AgentReference,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider)</Target>
<Left>lib/net8.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net8.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.AsAIAgent(Azure.AI.Projects.AIProjectClient,Azure.AI.Projects.OpenAI.AgentVersion,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider)</Target>
<Left>lib/net8.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net8.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.CreateAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,Azure.AI.Projects.AgentVersionCreationOptions,System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.Threading.CancellationToken)</Target>
<Left>lib/net8.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net8.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.CreateAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,Microsoft.Agents.AI.ChatClientAgentOptions,System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken)</Target>
<Left>lib/net8.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net8.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.CreateAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,System.String,System.String,System.String,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken)</Target>
<Left>lib/net8.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net8.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.GetAIAgentAsync(Azure.AI.Projects.AIProjectClient,Microsoft.Agents.AI.ChatClientAgentOptions,System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken)</Target>
<Left>lib/net8.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net8.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.GetAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken)</Target>
<Left>lib/net8.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net8.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.AsAIAgent(Azure.AI.Projects.AIProjectClient,Azure.AI.Projects.OpenAI.AgentRecord,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider)</Target>
<Left>lib/net9.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net9.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.AsAIAgent(Azure.AI.Projects.AIProjectClient,Azure.AI.Projects.OpenAI.AgentReference,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider)</Target>
<Left>lib/net9.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net9.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.AsAIAgent(Azure.AI.Projects.AIProjectClient,Azure.AI.Projects.OpenAI.AgentVersion,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider)</Target>
<Left>lib/net9.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net9.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.CreateAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,Azure.AI.Projects.AgentVersionCreationOptions,System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.Threading.CancellationToken)</Target>
<Left>lib/net9.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net9.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.CreateAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,Microsoft.Agents.AI.ChatClientAgentOptions,System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken)</Target>
<Left>lib/net9.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net9.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.CreateAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,System.String,System.String,System.String,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken)</Target>
<Left>lib/net9.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net9.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.GetAIAgentAsync(Azure.AI.Projects.AIProjectClient,Microsoft.Agents.AI.ChatClientAgentOptions,System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken)</Target>
<Left>lib/net9.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net9.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.GetAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken)</Target>
<Left>lib/net9.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net9.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.AsAIAgent(Azure.AI.Projects.AIProjectClient,Azure.AI.Projects.OpenAI.AgentRecord,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider)</Target>
<Left>lib/netstandard2.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/netstandard2.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.AsAIAgent(Azure.AI.Projects.AIProjectClient,Azure.AI.Projects.OpenAI.AgentReference,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider)</Target>
<Left>lib/netstandard2.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/netstandard2.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.AsAIAgent(Azure.AI.Projects.AIProjectClient,Azure.AI.Projects.OpenAI.AgentVersion,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider)</Target>
<Left>lib/netstandard2.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/netstandard2.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.CreateAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,Azure.AI.Projects.AgentVersionCreationOptions,System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.Threading.CancellationToken)</Target>
<Left>lib/netstandard2.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/netstandard2.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.CreateAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,Microsoft.Agents.AI.ChatClientAgentOptions,System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken)</Target>
<Left>lib/netstandard2.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/netstandard2.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.CreateAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,System.String,System.String,System.String,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken)</Target>
<Left>lib/netstandard2.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/netstandard2.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.GetAIAgentAsync(Azure.AI.Projects.AIProjectClient,Microsoft.Agents.AI.ChatClientAgentOptions,System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken)</Target>
<Left>lib/netstandard2.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/netstandard2.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.GetAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken)</Target>
<Left>lib/netstandard2.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/netstandard2.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
</Suppressions>
@@ -1,39 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- https://learn.microsoft.com/dotnet/fundamentals/package-validation/diagnostic-ids -->
<Suppressions xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<Suppression>
<DiagnosticId>CP0001</DiagnosticId>
<Target>T:OpenAI.Assistants.OpenAIAssistantClientExtensions</Target>
<Left>lib/net10.0/Microsoft.Agents.AI.OpenAI.dll</Left>
<Right>lib/net10.0/Microsoft.Agents.AI.OpenAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0001</DiagnosticId>
<Target>T:OpenAI.Assistants.OpenAIAssistantClientExtensions</Target>
<Left>lib/net472/Microsoft.Agents.AI.OpenAI.dll</Left>
<Right>lib/net472/Microsoft.Agents.AI.OpenAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0001</DiagnosticId>
<Target>T:OpenAI.Assistants.OpenAIAssistantClientExtensions</Target>
<Left>lib/net8.0/Microsoft.Agents.AI.OpenAI.dll</Left>
<Right>lib/net8.0/Microsoft.Agents.AI.OpenAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0001</DiagnosticId>
<Target>T:OpenAI.Assistants.OpenAIAssistantClientExtensions</Target>
<Left>lib/net9.0/Microsoft.Agents.AI.OpenAI.dll</Left>
<Right>lib/net9.0/Microsoft.Agents.AI.OpenAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0001</DiagnosticId>
<Target>T:OpenAI.Assistants.OpenAIAssistantClientExtensions</Target>
<Left>lib/netstandard2.0/Microsoft.Agents.AI.OpenAI.dll</Left>
<Right>lib/netstandard2.0/Microsoft.Agents.AI.OpenAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
</Suppressions>
@@ -1,39 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- https://learn.microsoft.com/dotnet/fundamentals/package-validation/diagnostic-ids -->
<Suppressions xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.Workflows.Declarative.AzureAgentProvider.get_OpenAIClientOptions</Target>
<Left>lib/net10.0/Microsoft.Agents.AI.Workflows.Declarative.Foundry.dll</Left>
<Right>lib/net10.0/Microsoft.Agents.AI.Workflows.Declarative.Foundry.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.Workflows.Declarative.AzureAgentProvider.get_OpenAIClientOptions</Target>
<Left>lib/net472/Microsoft.Agents.AI.Workflows.Declarative.Foundry.dll</Left>
<Right>lib/net472/Microsoft.Agents.AI.Workflows.Declarative.Foundry.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.Workflows.Declarative.AzureAgentProvider.get_OpenAIClientOptions</Target>
<Left>lib/net8.0/Microsoft.Agents.AI.Workflows.Declarative.Foundry.dll</Left>
<Right>lib/net8.0/Microsoft.Agents.AI.Workflows.Declarative.Foundry.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.Workflows.Declarative.AzureAgentProvider.get_OpenAIClientOptions</Target>
<Left>lib/net9.0/Microsoft.Agents.AI.Workflows.Declarative.Foundry.dll</Left>
<Right>lib/net9.0/Microsoft.Agents.AI.Workflows.Declarative.Foundry.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.Workflows.Declarative.AzureAgentProvider.get_OpenAIClientOptions</Target>
<Left>lib/netstandard2.0/Microsoft.Agents.AI.Workflows.Declarative.Foundry.dll</Left>
<Right>lib/netstandard2.0/Microsoft.Agents.AI.Workflows.Declarative.Foundry.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
</Suppressions>
@@ -8,6 +8,10 @@ using Microsoft.Agents.AI.Workflows.Specialized;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
using ExecutorFactoryFunc = System.Func<Microsoft.Agents.AI.Workflows.ExecutorConfig<Microsoft.Agents.AI.Workflows.ExecutorOptions>,
string,
System.Threading.Tasks.ValueTask<Microsoft.Agents.AI.Workflows.Specialized.HandoffAgentExecutor>>;
namespace Microsoft.Agents.AI.Workflows;
internal static class DiagnosticConstants
@@ -233,6 +237,57 @@ public class HandoffWorkflowBuilderCore<TBuilder> where TBuilder : HandoffWorkfl
return (TBuilder)this;
}
private Dictionary<string, ExecutorBinding> CreateExecutorBindings(WorkflowBuilder builder)
{
HandoffAgentExecutorOptions options = new(this.HandoffInstructions,
this._emitAgentResponseEvents,
this._emitAgentResponseUpdateEvents,
this._toolCallFilteringBehavior);
// There are two types of ids being used in this method, and it is critical that we are clear about
// which one we are using, and where.
// AgentId...: comes from AIAgent.Id, is often an unreadable machine identifier (e.g. a Guid), and is used to address
// the handoffs
// ExecutorId: uses AIAgent.GetDescriptiveId() to use a friendlier name in telemetry, and is used for ExecutorBinding,
// which are subsequently used in building the workflow
// The outgoing dictionary maps from AgentId => ExecutorBinding
return this._allAgents.ToDictionary(keySelector: a => a.Id, elementSelector: CreateFactoryBinding);
ExecutorBinding CreateFactoryBinding(AIAgent agent)
{
if (!this._targets.TryGetValue(agent, out HashSet<HandoffTarget>? handoffs))
{
handoffs = new();
}
// Use the ExecutorId as the placeholder id for a (possibly) future-bound factory
builder.AddSwitch(HandoffAgentExecutor.IdFor(agent), (SwitchBuilder sb) =>
{
foreach (HandoffTarget handoff in handoffs)
{
sb.AddCase<HandoffState>(state => state?.RequestedHandoffTargetAgentId == handoff.Target.Id, // Use AgentId for target matching
HandoffAgentExecutor.IdFor(handoff.Target)); // Use ExecutorId in for routing at the workflow level
}
sb.WithDefault(HandoffEndExecutor.ExecutorId);
});
ExecutorFactoryFunc factory =
(config, sessionId) => new(
new HandoffAgentExecutor(agent,
handoffs,
options));
// Make sure to use ExecutorId when binding the executor, not AgentId
ExecutorBinding binding = factory.BindExecutor(HandoffAgentExecutor.IdFor(agent));
builder.BindExecutor(binding);
return binding;
}
}
/// <summary>
/// Builds a <see cref="Workflow"/> composed of agents that operate via handoffs, with the next
/// agent to process messages selected by the current agent.
@@ -240,17 +295,12 @@ public class HandoffWorkflowBuilderCore<TBuilder> where TBuilder : HandoffWorkfl
/// <returns>The workflow built based on the handoffs in the builder.</returns>
public Workflow Build()
{
HandoffsStartExecutor start = new(this._returnToPrevious);
HandoffsEndExecutor end = new(this._returnToPrevious);
HandoffStartExecutor start = new(this._returnToPrevious);
HandoffEndExecutor end = new(this._returnToPrevious);
WorkflowBuilder builder = new(start);
HandoffAgentExecutorOptions options = new(this.HandoffInstructions,
this._emitAgentResponseEvents,
this._emitAgentResponseUpdateEvents,
this._toolCallFilteringBehavior);
// Create an AgentExecutor for each agent.
Dictionary<string, HandoffAgentExecutor> executors = this._allAgents.ToDictionary(a => a.Id, a => new HandoffAgentExecutor(a, options));
// Create an factory-based ExecutorBinding for each agent.
Dictionary<string, ExecutorBinding> executors = this.CreateExecutorBindings(builder);
// Connect the start executor to the initial agent (or use dynamic routing when ReturnToPrevious is enabled).
if (this._returnToPrevious)
@@ -263,7 +313,7 @@ public class HandoffWorkflowBuilderCore<TBuilder> where TBuilder : HandoffWorkfl
if (agent.Id != initialAgentId)
{
string agentId = agent.Id;
sb.AddCase<HandoffState>(state => state?.CurrentAgentId == agentId, executors[agentId]);
sb.AddCase<HandoffState>(state => state?.PreviousAgentId == agentId, executors[agentId]);
}
}
@@ -275,13 +325,6 @@ public class HandoffWorkflowBuilderCore<TBuilder> where TBuilder : HandoffWorkfl
builder.AddEdge(start, executors[this._initialAgent.Id]);
}
// Initialize each executor with its handoff targets to the other executors.
foreach (var agent in this._allAgents)
{
executors[agent.Id].Initialize(builder, end, executors,
this._targets.TryGetValue(agent, out HashSet<HandoffTarget>? targets) ? targets : []);
}
// Build the workflow.
return builder.WithOutputFrom(end).Build();
}
@@ -19,6 +19,9 @@ internal static class TurnExtensions
public static bool ShouldEmitStreamingEvents(bool? turnTokenSetting, bool? agentSetting)
=> turnTokenSetting ?? agentSetting ?? false;
public static bool ShouldEmitStreamingEvents(this HandoffState handoffState, bool? agentSetting)
=> handoffState.TurnToken.ShouldEmitStreamingEvents(agentSetting);
}
internal sealed class AIAgentHostExecutor : ChatProtocolExecutor
@@ -81,7 +84,11 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor
// resumes can be processed in one invocation.
return this.ProcessTurnMessagesAsync(async (pendingMessages, ctx, ct) =>
{
pendingMessages.Add(new ChatMessage(ChatRole.User, [response]));
pendingMessages.Add(new ChatMessage(ChatRole.User, [response])
{
CreatedAt = DateTimeOffset.UtcNow,
MessageId = Guid.NewGuid().ToString("N"),
});
await this.ContinueTurnAsync(pendingMessages, ctx, this._currentTurnEmitEvents ?? false, ct).ConfigureAwait(false);
@@ -104,7 +111,12 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor
// resumes can be processed in one invocation.
return this.ProcessTurnMessagesAsync(async (pendingMessages, ctx, ct) =>
{
pendingMessages.Add(new ChatMessage(ChatRole.Tool, [result]));
pendingMessages.Add(new ChatMessage(ChatRole.Tool, [result])
{
AuthorName = this._agent.Name ?? this._agent.Id,
CreatedAt = DateTimeOffset.UtcNow,
MessageId = Guid.NewGuid().ToString("N"),
});
await this.ContinueTurnAsync(pendingMessages, ctx, this._currentTurnEmitEvents ?? false, ct).ConfigureAwait(false);
@@ -186,16 +198,13 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor
TurnExtensions.ShouldEmitStreamingEvents(turnTokenSetting: emitEvents, this._options.EmitAgentUpdateEvents),
cancellationToken);
private async ValueTask<AgentResponse> InvokeAgentAsync(IEnumerable<ChatMessage> messages, IWorkflowContext context, bool emitEvents, CancellationToken cancellationToken = default)
private async ValueTask<AgentResponse> InvokeAgentAsync(IEnumerable<ChatMessage> messages, IWorkflowContext context, bool emitUpdateEvents, CancellationToken cancellationToken = default)
{
#pragma warning disable MEAI001
Dictionary<string, ToolApprovalRequestContent> userInputRequests = new();
Dictionary<string, FunctionCallContent> functionCalls = new();
AgentResponse response;
AIAgentUnservicedRequestsCollector collector = new(this._userInputHandler, this._functionCallHandler);
if (emitEvents)
if (emitUpdateEvents)
{
#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.
// Run the agent in streaming mode only when agent run update events are to be emitted.
IAsyncEnumerable<AgentResponseUpdate> agentStream = this._agent.RunStreamingAsync(
messages,
@@ -206,7 +215,7 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor
await foreach (AgentResponseUpdate update in agentStream.ConfigureAwait(false))
{
await context.YieldOutputAsync(update, cancellationToken).ConfigureAwait(false);
ExtractUnservicedRequests(update.Contents);
collector.ProcessAgentResponseUpdate(update);
updates.Add(update);
}
@@ -220,7 +229,7 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor
cancellationToken: cancellationToken)
.ConfigureAwait(false);
ExtractUnservicedRequests(response.Messages.SelectMany(message => message.Contents));
collector.ProcessAgentResponse(response);
}
if (this._options.EmitAgentResponseEvents)
@@ -228,45 +237,8 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor
await context.YieldOutputAsync(response, cancellationToken).ConfigureAwait(false);
}
if (userInputRequests.Count > 0 || functionCalls.Count > 0)
{
Task userInputTask = this._userInputHandler?.ProcessRequestContentsAsync(userInputRequests, context, cancellationToken) ?? Task.CompletedTask;
Task functionCallTask = this._functionCallHandler?.ProcessRequestContentsAsync(functionCalls, context, cancellationToken) ?? Task.CompletedTask;
await Task.WhenAll(userInputTask, functionCallTask)
.ConfigureAwait(false);
}
await collector.SubmitAsync(context, cancellationToken).ConfigureAwait(false);
return response;
void ExtractUnservicedRequests(IEnumerable<AIContent> contents)
{
foreach (AIContent content in contents)
{
if (content is ToolApprovalRequestContent userInputRequest)
{
// It is an error to simultaneously have multiple outstanding user input requests with the same ID.
userInputRequests.Add(userInputRequest.RequestId, userInputRequest);
}
else if (content is ToolApprovalResponseContent userInputResponse)
{
// If the set of messages somehow already has a corresponding user input response, remove it.
_ = userInputRequests.Remove(userInputResponse.RequestId);
}
else if (content is FunctionCallContent functionCall)
{
// For function calls, we emit an event to notify the workflow.
//
// possibility 1: this will be handled inline by the agent abstraction
// possibility 2: this will not be handled inline by the agent abstraction
functionCalls.Add(functionCall.CallId, functionCall);
}
else if (content is FunctionResultContent functionResult)
{
_ = functionCalls.Remove(functionResult.CallId);
}
}
}
#pragma warning restore MEAI001
}
}
@@ -0,0 +1,78 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Specialized;
internal sealed class AIAgentUnservicedRequestsCollector(AIContentExternalHandler<ToolApprovalRequestContent, ToolApprovalResponseContent>? userInputHandler,
AIContentExternalHandler<FunctionCallContent, FunctionResultContent>? functionCallHandler)
{
private readonly Dictionary<string, ToolApprovalRequestContent> _userInputRequests = [];
private readonly Dictionary<string, FunctionCallContent> _functionCalls = [];
public Task SubmitAsync(IWorkflowContext context, CancellationToken cancellationToken)
{
Task userInputTask = userInputHandler != null && this._userInputRequests.Count > 0
? userInputHandler.ProcessRequestContentsAsync(this._userInputRequests, context, cancellationToken)
: Task.CompletedTask;
Task functionCallTask = functionCallHandler != null && this._functionCalls.Count > 0
? functionCallHandler.ProcessRequestContentsAsync(this._functionCalls, context, cancellationToken)
: Task.CompletedTask;
return Task.WhenAll(userInputTask, functionCallTask);
}
public void ProcessAgentResponseUpdate(AgentResponseUpdate update, Func<FunctionCallContent, bool>? functionCallFilter = null)
=> this.ProcessAIContents(update.Contents, functionCallFilter);
public void ProcessAgentResponse(AgentResponse response)
=> this.ProcessAIContents(response.Messages.SelectMany(message => message.Contents));
public void ProcessAIContents(IEnumerable<AIContent> contents, Func<FunctionCallContent, bool>? functionCallFilter = null)
{
foreach (AIContent content in contents)
{
if (content is ToolApprovalRequestContent userInputRequest)
{
if (this._userInputRequests.ContainsKey(userInputRequest.RequestId))
{
throw new InvalidOperationException($"ToolApprovalRequestContent with duplicate RequestId: {userInputRequest.RequestId}");
}
// It is an error to simultaneously have multiple outstanding user input requests with the same ID.
this._userInputRequests.Add(userInputRequest.RequestId, userInputRequest);
}
else if (content is ToolApprovalResponseContent userInputResponse)
{
// If the set of messages somehow already has a corresponding user input response, remove it.
_ = this._userInputRequests.Remove(userInputResponse.RequestId);
}
else if (content is FunctionCallContent functionCall)
{
// For function calls, we emit an event to notify the workflow.
//
// possibility 1: this will be handled inline by the agent abstraction
// possibility 2: this will not be handled inline by the agent abstraction
if (functionCallFilter == null || functionCallFilter(functionCall))
{
if (this._functionCalls.ContainsKey(functionCall.CallId))
{
throw new InvalidOperationException($"FunctionCallContent with duplicate CallId: {functionCall.CallId}");
}
this._functionCalls.Add(functionCall.CallId, functionCall);
}
}
else if (content is FunctionResultContent functionResult)
{
_ = this._functionCalls.Remove(functionResult.CallId);
}
}
}
}
@@ -3,7 +3,6 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Text.Json;
@@ -166,128 +165,331 @@ internal sealed class HandoffMessagesFilter
}
}
internal struct AgentInvocationResult(AgentResponse agentResponse, string? handoffTargetId)
{
public AgentResponse Response => agentResponse;
public string? HandoffTargetId => handoffTargetId;
[MemberNotNullWhen(true, nameof(HandoffTargetId))]
public bool IsHandoffRequested => this.HandoffTargetId != null;
}
internal record HandoffAgentHostState(HandoffState? CurrentTurnState, List<ChatMessage> FilteredIncomingMessages, List<ChatMessage> TurnMessages)
{
public HandoffState PrepareHandoff(AgentInvocationResult invocationResult, string currentAgentId)
{
if (this.CurrentTurnState == null)
{
throw new InvalidOperationException("Cannot create a handoff request: Out of turn.");
}
IEnumerable<ChatMessage> allMessages = [.. this.CurrentTurnState.Messages, .. this.TurnMessages, .. invocationResult.Response.Messages];
return new(this.CurrentTurnState.TurnToken, invocationResult.HandoffTargetId, allMessages.ToList(), currentAgentId);
}
}
/// <summary>Executor used to represent an agent in a handoffs workflow, responding to <see cref="HandoffState"/> events.</summary>
[Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)]
internal sealed class HandoffAgentExecutor(
AIAgent agent,
HandoffAgentExecutorOptions options) : Executor<HandoffState, HandoffState>(agent.GetDescriptiveId(), declareCrossRunShareable: true), IResettableExecutor
internal sealed class HandoffAgentExecutor :
StatefulExecutor<HandoffAgentHostState, HandoffState>
{
private static readonly JsonElement s_handoffSchema = AIFunctionFactory.Create(
([Description("The reason for the handoff")] string? reasonForHandoff) => { }).JsonSchema;
private readonly AIAgent _agent = agent;
public static string IdFor(AIAgent agent) => agent.GetDescriptiveId();
private readonly AIAgent _agent;
private readonly ChatClientAgentRunOptions? _agentOptions;
private readonly HandoffAgentExecutorOptions _options;
private readonly HashSet<string> _handoffFunctionNames = [];
private readonly Dictionary<string, string> _handoffFunctionToAgentId = [];
private ChatClientAgentRunOptions? _agentOptions;
public void Initialize(
WorkflowBuilder builder,
Executor end,
Dictionary<string, HandoffAgentExecutor> executors,
HashSet<HandoffTarget> handoffs) =>
builder.AddSwitch(this, sb =>
{
if (handoffs.Count != 0)
{
Debug.Assert(this._agentOptions is null);
this._agentOptions = new()
{
ChatOptions = new()
{
AllowMultipleToolCalls = false,
Instructions = options.HandoffInstructions,
Tools = [],
},
};
private static HandoffAgentHostState InitialStateFactory() => new(null, [], []);
int index = 0;
foreach (HandoffTarget handoff in handoffs)
{
index++;
var handoffFunc = AIFunctionFactory.CreateDeclaration($"{HandoffWorkflowBuilder.FunctionPrefix}{index}", handoff.Reason, s_handoffSchema);
this._handoffFunctionNames.Add(handoffFunc.Name);
this._handoffFunctionToAgentId[handoffFunc.Name] = handoff.Target.Id;
this._agentOptions.ChatOptions.Tools.Add(handoffFunc);
sb.AddCase<HandoffState>(state => state?.InvokedHandoff == handoffFunc.Name, executors[handoff.Target.Id]);
}
}
sb.WithDefault(end);
});
public override async ValueTask<HandoffState> HandleAsync(HandoffState message, IWorkflowContext context, CancellationToken cancellationToken = default)
public HandoffAgentExecutor(AIAgent agent, HashSet<HandoffTarget> handoffs, HandoffAgentExecutorOptions options)
: base(IdFor(agent), InitialStateFactory)
{
string? requestedHandoff = null;
List<AgentResponseUpdate> updates = [];
List<ChatMessage> allMessages = message.Messages;
this._agent = agent;
this._options = options;
List<ChatMessage>? roleChanges = allMessages.ChangeAssistantToUserForOtherParticipants(this._agent.Name ?? this._agent.Id);
this._agentOptions = CreateAgentHandoffContext(this._options.HandoffInstructions, handoffs, this._handoffFunctionNames, this._handoffFunctionToAgentId);
}
// If a handoff was invoked by a previous agent, filter out the handoff function
// call and tool result messages before sending to the underlying agent. These
// are internal workflow mechanics that confuse the target model into ignoring the
// original user question.
HandoffMessagesFilter handoffMessagesFilter = new(options.ToolCallFilteringBehavior);
IEnumerable<ChatMessage> messagesForAgent = message.InvokedHandoff is not null
? handoffMessagesFilter.FilterMessages(allMessages)
: allMessages;
private static ChatClientAgentRunOptions? CreateAgentHandoffContext(string? handoffInstructions, HashSet<HandoffTarget> handoffs, HashSet<string> functionNames, Dictionary<string, string> functionToAgentId)
{
ChatClientAgentRunOptions? result = null;
await foreach (var update in this._agent.RunStreamingAsync(messagesForAgent,
options: this._agentOptions,
cancellationToken: cancellationToken)
.ConfigureAwait(false))
if (handoffs.Count != 0)
{
await AddUpdateAsync(update, cancellationToken).ConfigureAwait(false);
foreach (var fcc in update.Contents.OfType<FunctionCallContent>()
.Where(fcc => this._handoffFunctionNames.Contains(fcc.Name)))
result = new()
{
requestedHandoff = fcc.Name;
await AddUpdateAsync(
new AgentResponseUpdate
{
AgentId = this._agent.Id,
AuthorName = this._agent.Name ?? this._agent.Id,
Contents = [new FunctionResultContent(fcc.CallId, "Transferred.")],
CreatedAt = DateTimeOffset.UtcNow,
MessageId = Guid.NewGuid().ToString("N"),
Role = ChatRole.Tool,
},
cancellationToken
)
.ConfigureAwait(false);
ChatOptions = new()
{
AllowMultipleToolCalls = false,
Instructions = handoffInstructions,
Tools = [],
},
};
int index = 0;
foreach (HandoffTarget handoff in handoffs)
{
index++;
var handoffFunc = AIFunctionFactory.CreateDeclaration($"{HandoffWorkflowBuilder.FunctionPrefix}{index}", handoff.Reason, s_handoffSchema);
functionNames.Add(handoffFunc.Name);
functionToAgentId[handoffFunc.Name] = handoff.Target.Id;
result.ChatOptions.Tools.Add(handoffFunc);
}
}
AgentResponse agentResponse = updates.ToAgentResponse();
return result;
}
if (options.EmitAgentResponseEvents)
private AIContentExternalHandler<ToolApprovalRequestContent, ToolApprovalResponseContent>? _userInputHandler;
private AIContentExternalHandler<FunctionCallContent, FunctionResultContent>? _functionCallHandler;
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
{
return this.ConfigureUserInputHandling(base.ConfigureProtocol(protocolBuilder))
.SendsMessage<HandoffState>();
}
private ProtocolBuilder ConfigureUserInputHandling(ProtocolBuilder protocolBuilder)
{
this._userInputHandler = new AIContentExternalHandler<ToolApprovalRequestContent, ToolApprovalResponseContent>(
ref protocolBuilder,
portId: $"{this.Id}_UserInput",
intercepted: false,
handler: this.HandleUserInputResponseAsync);
this._functionCallHandler = new AIContentExternalHandler<FunctionCallContent, FunctionResultContent>(
ref protocolBuilder,
portId: $"{this.Id}_FunctionCall",
intercepted: false, // TODO: Use this instead of manual function handling for handoff?
handler: this.HandleFunctionResultAsync);
return protocolBuilder;
}
private ValueTask HandleUserInputResponseAsync(
ToolApprovalResponseContent response,
IWorkflowContext context,
CancellationToken cancellationToken)
{
if (!this._userInputHandler!.MarkRequestAsHandled(response.RequestId))
{
await context.YieldOutputAsync(agentResponse, cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException($"No pending ToolApprovalRequest found with id '{response.RequestId}'.");
}
allMessages.AddRange(agentResponse.Messages);
// Merge the external response with any already-buffered regular messages so mixed-content
// resumes can be processed in one invocation.
return this.InvokeWithStateAsync((state, ctx, ct) =>
{
state.TurnMessages.Add(new ChatMessage(ChatRole.User, [response])
{
CreatedAt = DateTimeOffset.UtcNow,
MessageId = Guid.NewGuid().ToString("N"),
});
return this.ContinueTurnAsync(state, ctx, ct);
}, context, skipCache: false, cancellationToken);
}
private ValueTask HandleFunctionResultAsync(
FunctionResultContent result,
IWorkflowContext context,
CancellationToken cancellationToken)
{
if (!this._functionCallHandler!.MarkRequestAsHandled(result.CallId))
{
throw new InvalidOperationException($"No pending FunctionCall found with id '{result.CallId}'.");
}
// Merge the external response with any already-buffered regular messages so mixed-content
// resumes can be processed in one invocation.
return this.InvokeWithStateAsync((state, ctx, ct) =>
{
state.TurnMessages.Add(
new ChatMessage(ChatRole.Tool, [result])
{
AuthorName = this._agent.Name ?? this._agent.Id,
CreatedAt = DateTimeOffset.UtcNow,
MessageId = Guid.NewGuid().ToString("N"),
});
return this.ContinueTurnAsync(state, ctx, ct);
}, context, skipCache: false, cancellationToken);
}
private async ValueTask<HandoffAgentHostState?> ContinueTurnAsync(HandoffAgentHostState state, IWorkflowContext context, CancellationToken cancellationToken)
{
List<ChatMessage>? roleChanges = state.FilteredIncomingMessages.ChangeAssistantToUserForOtherParticipants(this._agent.Name ?? this._agent.Id);
bool emitUpdateEvents = state.CurrentTurnState!.ShouldEmitStreamingEvents(this._options.EmitAgentResponseUpdateEvents);
AgentInvocationResult result = await this.InvokeAgentAsync([.. state.FilteredIncomingMessages, .. state.TurnMessages], context, emitUpdateEvents, cancellationToken)
.ConfigureAwait(false);
if (this.HasOutstandingRequests && result.IsHandoffRequested)
{
throw new InvalidOperationException("Cannot request a handoff while holding pending requests.");
}
roleChanges.ResetUserToAssistantForChangedRoles();
string currentAgentId = requestedHandoff is not null && this._handoffFunctionToAgentId.TryGetValue(requestedHandoff, out string? targetAgentId)
? targetAgentId
: this._agent.Id;
return new(message.TurnToken, requestedHandoff, allMessages, currentAgentId);
async Task AddUpdateAsync(AgentResponseUpdate update, CancellationToken cancellationToken)
// We send on the HandoffState even if handoff is not requested because we might be terminating the processing, but this only
// happens if we have no outstanding requests.
if (!this.HasOutstandingRequests)
{
updates.Add(update);
if (message.TurnToken.ShouldEmitStreamingEvents(options.EmitAgentResponseUpdateEvents))
HandoffState outgoingState = state.PrepareHandoff(result, this._agent.Id);
await context.SendMessageAsync(outgoingState, cancellationToken).ConfigureAwait(false);
// reset the state for the next handoff (return-to-current is modeled as a new handoff turn, as opposed to "HITL", which
// can be a bit confusing.)
return null;
}
state.TurnMessages.AddRange(result.Response.Messages);
return state;
}
public override ValueTask HandleAsync(HandoffState message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
return this.InvokeWithStateAsync(InvokeContinueTurnAsync, context, skipCache: false, cancellationToken);
ValueTask<HandoffAgentHostState?> InvokeContinueTurnAsync(HandoffAgentHostState state, IWorkflowContext context, CancellationToken cancellationToken)
{
// Check that we are not getting this message while in the middle of a turn
if (state.CurrentTurnState != null)
{
await context.YieldOutputAsync(update, cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException("Cannot have multiple simultaneous conversations in Handoff Orchestration.");
}
// If a handoff was invoked by a previous agent, filter out the handoff function
// call and tool result messages before sending to the underlying agent. These
// are internal workflow mechanics that confuse the target model into ignoring the
// original user question.
HandoffMessagesFilter handoffMessagesFilter = new(this._options.ToolCallFilteringBehavior);
IEnumerable<ChatMessage> messagesForAgent = message.RequestedHandoffTargetAgentId is not null
? handoffMessagesFilter.FilterMessages(message.Messages)
: message.Messages;
// This works because the runtime guarantees that a given executor instance will process messages serially,
// though there is no global cross-executor ordering guarantee (and in turn, no canonical message delivery order)
state = new(message, messagesForAgent.ToList(), []);
return this.ContinueTurnAsync(state, context, cancellationToken);
}
}
public ValueTask ResetAsync() => default;
private const string UserInputRequestStateKey = nameof(_userInputHandler);
private const string FunctionCallRequestStateKey = nameof(_functionCallHandler);
protected internal override async ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
{
Task userInputRequestsTask = this._userInputHandler?.OnCheckpointingAsync(UserInputRequestStateKey, context, cancellationToken).AsTask() ?? Task.CompletedTask;
Task functionCallRequestsTask = this._functionCallHandler?.OnCheckpointingAsync(FunctionCallRequestStateKey, context, cancellationToken).AsTask() ?? Task.CompletedTask;
Task baseTask = base.OnCheckpointingAsync(context, cancellationToken).AsTask();
await Task.WhenAll(userInputRequestsTask, functionCallRequestsTask, baseTask).ConfigureAwait(false);
}
protected internal override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
{
Task userInputRestoreTask = this._userInputHandler?.OnCheckpointRestoredAsync(UserInputRequestStateKey, context, cancellationToken).AsTask() ?? Task.CompletedTask;
Task functionCallRestoreTask = this._functionCallHandler?.OnCheckpointRestoredAsync(FunctionCallRequestStateKey, context, cancellationToken).AsTask() ?? Task.CompletedTask;
await Task.WhenAll(userInputRestoreTask, functionCallRestoreTask).ConfigureAwait(false);
await base.OnCheckpointRestoredAsync(context, cancellationToken).ConfigureAwait(false);
}
private bool HasOutstandingRequests => (this._userInputHandler?.HasPendingRequests == true)
|| (this._functionCallHandler?.HasPendingRequests == true);
private async ValueTask<AgentInvocationResult> InvokeAgentAsync(IEnumerable<ChatMessage> messages, IWorkflowContext context, bool emitUpdateEvents, CancellationToken cancellationToken = default)
{
AgentResponse response;
AIAgentUnservicedRequestsCollector collector = new(this._userInputHandler, this._functionCallHandler);
IAsyncEnumerable<AgentResponseUpdate> agentStream = this._agent.RunStreamingAsync(
messages,
options: this._agentOptions,
cancellationToken: cancellationToken);
string? requestedHandoff = null;
List<AgentResponseUpdate> updates = [];
List<FunctionCallContent> candidateRequests = [];
await foreach (AgentResponseUpdate update in agentStream.ConfigureAwait(false))
{
await AddUpdateAsync(update, cancellationToken).ConfigureAwait(false);
collector.ProcessAgentResponseUpdate(update, CollectHandoffRequestsFilter);
bool CollectHandoffRequestsFilter(FunctionCallContent candidateHandoffRequest)
{
bool isHandoffRequest = this._handoffFunctionNames.Contains(candidateHandoffRequest.Name);
if (isHandoffRequest)
{
candidateRequests.Add(candidateHandoffRequest);
}
return !isHandoffRequest;
}
}
if (candidateRequests.Count > 1)
{
string message = $"Duplicate handoff requests in single turn ([{string.Join(", ", candidateRequests.Select(request => request.Name))}]). Using last ({candidateRequests.Last().Name})";
await context.AddEventAsync(new WorkflowWarningEvent(message), cancellationToken).ConfigureAwait(false);
}
if (candidateRequests.Count > 0)
{
FunctionCallContent handoffRequest = candidateRequests[candidateRequests.Count - 1];
requestedHandoff = handoffRequest.Name;
await AddUpdateAsync(
new AgentResponseUpdate
{
AgentId = this._agent.Id,
AuthorName = this._agent.Name ?? this._agent.Id,
Contents = [new FunctionResultContent(handoffRequest.CallId, "Transferred.")],
CreatedAt = DateTimeOffset.UtcNow,
MessageId = Guid.NewGuid().ToString("N"),
Role = ChatRole.Tool,
},
cancellationToken
)
.ConfigureAwait(false);
}
response = updates.ToAgentResponse();
if (this._options.EmitAgentResponseEvents)
{
await context.YieldOutputAsync(response, cancellationToken).ConfigureAwait(false);
}
await collector.SubmitAsync(context, cancellationToken).ConfigureAwait(false);
return new(response, LookupHandoffTarget(requestedHandoff));
ValueTask AddUpdateAsync(AgentResponseUpdate update, CancellationToken cancellationToken)
{
updates.Add(update);
return emitUpdateEvents ? context.YieldOutputAsync(update, cancellationToken) : default;
}
string? LookupHandoffTarget(string? requestedHandoff)
=> requestedHandoff != null
? this._handoffFunctionToAgentId.TryGetValue(requestedHandoff, out string? targetId) ? targetId : null
: null;
}
}
@@ -8,7 +8,7 @@ using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Specialized;
/// <summary>Executor used at the end of a handoff workflow to raise a final completed event.</summary>
internal sealed class HandoffsEndExecutor(bool returnToPrevious) : Executor(ExecutorId, declareCrossRunShareable: true), IResettableExecutor
internal sealed class HandoffEndExecutor(bool returnToPrevious) : Executor(ExecutorId, declareCrossRunShareable: true), IResettableExecutor
{
public const string ExecutorId = "HandoffEnd";
@@ -21,9 +21,9 @@ internal sealed class HandoffsEndExecutor(bool returnToPrevious) : Executor(Exec
{
if (returnToPrevious)
{
await context.QueueStateUpdateAsync<string?>(HandoffConstants.CurrentAgentTrackerKey,
handoff.CurrentAgentId,
HandoffConstants.CurrentAgentTrackerScope,
await context.QueueStateUpdateAsync<string?>(HandoffConstants.PreviousAgentTrackerKey,
handoff.PreviousAgentId,
HandoffConstants.PreviousAgentTrackerScope,
cancellationToken)
.ConfigureAwait(false);
}
@@ -9,12 +9,12 @@ namespace Microsoft.Agents.AI.Workflows.Specialized;
internal static class HandoffConstants
{
internal const string CurrentAgentTrackerKey = "LastAgentId";
internal const string CurrentAgentTrackerScope = "HandoffOrchestration";
internal const string PreviousAgentTrackerKey = "LastAgentId";
internal const string PreviousAgentTrackerScope = "HandoffOrchestration";
}
/// <summary>Executor used at the start of a handoffs workflow to accumulate messages and emit them as HandoffState upon receiving a turn token.</summary>
internal sealed class HandoffsStartExecutor(bool returnToPrevious) : ChatProtocolExecutor(ExecutorId, DefaultOptions, declareCrossRunShareable: true), IResettableExecutor
internal sealed class HandoffStartExecutor(bool returnToPrevious) : ChatProtocolExecutor(ExecutorId, DefaultOptions, declareCrossRunShareable: true), IResettableExecutor
{
internal const string ExecutorId = "HandoffStart";
@@ -32,15 +32,15 @@ internal sealed class HandoffsStartExecutor(bool returnToPrevious) : ChatProtoco
if (returnToPrevious)
{
return context.InvokeWithStateAsync(
async (string? currentAgentId, IWorkflowContext context, CancellationToken cancellationToken) =>
async (string? previousAgentId, IWorkflowContext context, CancellationToken cancellationToken) =>
{
HandoffState handoffState = new(new(emitEvents), null, messages, currentAgentId);
HandoffState handoffState = new(new(emitEvents), null, messages, previousAgentId);
await context.SendMessageAsync(handoffState, cancellationToken).ConfigureAwait(false);
return currentAgentId;
return previousAgentId;
},
HandoffConstants.CurrentAgentTrackerKey,
HandoffConstants.CurrentAgentTrackerScope,
HandoffConstants.PreviousAgentTrackerKey,
HandoffConstants.PreviousAgentTrackerScope,
cancellationToken);
}
@@ -7,6 +7,6 @@ namespace Microsoft.Agents.AI.Workflows.Specialized;
internal sealed record class HandoffState(
TurnToken TurnToken,
string? InvokedHandoff,
string? RequestedHandoffTargetAgentId,
List<ChatMessage> Messages,
string? CurrentAgentId = null);
string? PreviousAgentId = null);
@@ -113,6 +113,12 @@ public abstract class StatefulExecutor<TState> : Executor
{
if (!skipCache && !context.ConcurrentRunsEnabled)
{
if (this._stateCache is null)
{
this._stateCache = await context.ReadOrInitStateAsync(this.StateKey, this._initialStateFactory, this.Options.ScopeName, cancellationToken)
.ConfigureAwait(false);
}
TState newState = await invocation(this._stateCache ?? this._initialStateFactory(),
context,
cancellationToken).ConfigureAwait(false)
@@ -168,9 +174,12 @@ public abstract class StatefulExecutor<TState, TInput>(string id,
/// <inheritdoc/>
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
{
protocolBuilder.RouteBuilder.AddHandler<TInput>(this.HandleAsync);
Func<TInput, IWorkflowContext, CancellationToken, ValueTask> handlerDelegate = this.HandleAsync;
return protocolBuilder.SendsMessageTypes(sentMessageTypes ?? [])
return protocolBuilder.ConfigureRoutes(routeBuilder => routeBuilder.AddHandler(handlerDelegate))
.AddMethodAttributeTypes(handlerDelegate.Method)
.AddClassAttributeTypes(this.GetType())
.SendsMessageTypes(sentMessageTypes ?? [])
.YieldsOutputTypes(outputTypes ?? []);
}
@@ -203,19 +212,12 @@ public abstract class StatefulExecutor<TState, TInput, TOutput>(string id,
/// <inheritdoc/>
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
{
protocolBuilder.RouteBuilder.AddHandler<TInput, TOutput>(this.HandleAsync);
if (this.Options.AutoSendMessageHandlerResultObject)
{
protocolBuilder.SendsMessage<TOutput>();
}
if (this.Options.AutoYieldOutputHandlerResultObject)
{
protocolBuilder.YieldsOutput<TOutput>();
}
return protocolBuilder.SendsMessageTypes(sentMessageTypes ?? []).YieldsOutputTypes(outputTypes ?? []);
Func<TInput, IWorkflowContext, CancellationToken, ValueTask<TOutput>> handlerDelegate = this.HandleAsync;
return protocolBuilder.ConfigureRoutes(routeBuilder => routeBuilder.AddHandler(handlerDelegate))
.AddMethodAttributeTypes(handlerDelegate.Method)
.AddClassAttributeTypes(this.GetType())
.SendsMessageTypes(sentMessageTypes ?? [])
.YieldsOutputTypes(outputTypes ?? []);
}
/// <inheritdoc/>
@@ -43,27 +43,6 @@
<Right>lib/net10.0/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.ChatClientAgentOptions.get_SimulateServiceStoredChatHistory</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.ChatClientAgentOptions.set_SimulateServiceStoredChatHistory(System.Boolean)</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.Extensions.AI.ChatClientBuilderExtensions.UseServiceStoredChatHistorySimulation(Microsoft.Extensions.AI.ChatClientBuilder)</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.AgentInlineSkill.#ctor(Microsoft.Agents.AI.AgentSkillFrontmatter,System.String)</Target>
@@ -106,27 +85,6 @@
<Right>lib/net472/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.ChatClientAgentOptions.get_SimulateServiceStoredChatHistory</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.ChatClientAgentOptions.set_SimulateServiceStoredChatHistory(System.Boolean)</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.Extensions.AI.ChatClientBuilderExtensions.UseServiceStoredChatHistorySimulation(Microsoft.Extensions.AI.ChatClientBuilder)</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.AgentInlineSkill.#ctor(Microsoft.Agents.AI.AgentSkillFrontmatter,System.String)</Target>
@@ -169,27 +127,6 @@
<Right>lib/net8.0/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.ChatClientAgentOptions.get_SimulateServiceStoredChatHistory</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.ChatClientAgentOptions.set_SimulateServiceStoredChatHistory(System.Boolean)</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.Extensions.AI.ChatClientBuilderExtensions.UseServiceStoredChatHistorySimulation(Microsoft.Extensions.AI.ChatClientBuilder)</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.AgentInlineSkill.#ctor(Microsoft.Agents.AI.AgentSkillFrontmatter,System.String)</Target>
@@ -232,27 +169,6 @@
<Right>lib/net9.0/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.ChatClientAgentOptions.get_SimulateServiceStoredChatHistory</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.ChatClientAgentOptions.set_SimulateServiceStoredChatHistory(System.Boolean)</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.Extensions.AI.ChatClientBuilderExtensions.UseServiceStoredChatHistorySimulation(Microsoft.Extensions.AI.ChatClientBuilder)</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.AgentInlineSkill.#ctor(Microsoft.Agents.AI.AgentSkillFrontmatter,System.String)</Target>
@@ -295,25 +211,4 @@
<Right>lib/netstandard2.0/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.ChatClientAgentOptions.get_SimulateServiceStoredChatHistory</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.ChatClientAgentOptions.set_SimulateServiceStoredChatHistory(System.Boolean)</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.Extensions.AI.ChatClientBuilderExtensions.UseServiceStoredChatHistorySimulation(Microsoft.Extensions.AI.ChatClientBuilder)</Target>
<Left>lib/netstandard2.0/Microsoft.Agents.AI.dll</Left>
<Right>lib/netstandard2.0/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
</Suppressions>
@@ -21,7 +21,7 @@ namespace Microsoft.Agents.AI;
/// </para>
/// <list type="bullet">
/// <item><description><strong>Mixed skill types</strong> — combine file-based, code-defined (<see cref="AgentInlineSkill"/>),
/// and class-based (<see cref="AgentClassSkill"/>) skills in a single provider.</description></item>
/// and class-based (<see cref="AgentClassSkill{TSelf}"/>) skills in a single provider.</description></item>
/// <item><description><strong>Multiple file script runners</strong> — use different script runners for different
/// file skill directories via per-source <c>scriptRunner</c> parameters on
/// <see cref="UseFileSkill"/> / <see cref="UseFileSkills(IEnumerable{string}, AgentFileSkillsSourceOptions?, AgentFileSkillScriptRunner?)"/>.</description></item>
@@ -32,15 +32,15 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
private const string SkillFileName = "SKILL.md";
private const int MaxSearchDepth = 2;
// "." means the skill directory root itself (no sub-folder descent constraint)
private const string RootFolderIndicator = ".";
// "." means the skill directory root itself (no subdirectory descent constraint)
private const string RootDirectoryIndicator = ".";
private static readonly string[] s_defaultScriptExtensions = [".py", ".js", ".sh", ".ps1", ".cs", ".csx"];
private static readonly string[] s_defaultResourceExtensions = [".md", ".json", ".yaml", ".yml", ".csv", ".xml", ".txt"];
// Standard sub-folder names per https://agentskills.io/specification#directory-structure
private static readonly string[] s_defaultScriptFolders = ["scripts"];
private static readonly string[] s_defaultResourceFolders = ["references", "assets"];
// Standard subdirectory names per https://agentskills.io/specification#directory-structure
private static readonly string[] s_defaultScriptDirectories = ["scripts"];
private static readonly string[] s_defaultResourceDirectories = ["references", "assets"];
// Matches YAML frontmatter delimited by "---" lines. Group 1 = content between delimiters.
// Multiline makes ^/$ match line boundaries; Singleline makes . match newlines across the block.
@@ -63,8 +63,8 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
private readonly IEnumerable<string> _skillPaths;
private readonly HashSet<string> _allowedResourceExtensions;
private readonly HashSet<string> _allowedScriptExtensions;
private readonly IReadOnlyList<string> _scriptFolders;
private readonly IReadOnlyList<string> _resourceFolders;
private readonly IReadOnlyList<string> _scriptDirectories;
private readonly IReadOnlyList<string> _resourceDirectories;
private readonly AgentFileSkillScriptRunner? _scriptRunner;
private readonly ILogger _logger;
@@ -111,13 +111,13 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
options?.AllowedScriptExtensions ?? s_defaultScriptExtensions,
StringComparer.OrdinalIgnoreCase);
this._scriptFolders = options?.ScriptFolders is not null
? [.. ValidateAndNormalizeFolderNames(options.ScriptFolders, this._logger)]
: s_defaultScriptFolders;
this._scriptDirectories = options?.ScriptDirectories is not null
? [.. ValidateAndNormalizeDirectoryNames(options.ScriptDirectories, this._logger)]
: s_defaultScriptDirectories;
this._resourceFolders = options?.ResourceFolders is not null
? [.. ValidateAndNormalizeFolderNames(options.ResourceFolders, this._logger)]
: s_defaultResourceFolders;
this._resourceDirectories = options?.ResourceDirectories is not null
? [.. ValidateAndNormalizeDirectoryNames(options.ResourceDirectories, this._logger)]
: s_defaultResourceDirectories;
this._scriptRunner = scriptRunner;
}
@@ -303,12 +303,12 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
}
/// <summary>
/// Scans configured resource folders within a skill directory for resource files matching the configured extensions.
/// Scans configured resource directories within a skill directory for resource files matching the configured extensions.
/// </summary>
/// <remarks>
/// By default, scans <c>references/</c> and <c>assets/</c> sub-folders as specified by the
/// By default, scans <c>references/</c> and <c>assets/</c> subdirectories as specified by the
/// <see href="https://agentskills.io/specification">Agent Skills specification</see>.
/// Configure <see cref="AgentFileSkillsSourceOptions.ResourceFolders"/> to scan different or
/// Configure <see cref="AgentFileSkillsSourceOptions.ResourceDirectories"/> to scan different or
/// additional directories, including <c>"."</c> for the skill root itself.
/// Each file is validated against path-traversal and symlink-escape checks; unsafe files are skipped.
/// </remarks>
@@ -316,14 +316,14 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
{
var resources = new List<AgentFileSkillResource>();
foreach (string folder in this._resourceFolders.Distinct(StringComparer.OrdinalIgnoreCase))
foreach (string directory in this._resourceDirectories.Distinct(StringComparer.OrdinalIgnoreCase))
{
bool isRootFolder = string.Equals(folder, RootFolderIndicator, StringComparison.Ordinal);
bool isRootDirectory = string.Equals(directory, RootDirectoryIndicator, StringComparison.Ordinal);
// GetFullPath normalizes mixed separators (e.g. "C:\skill\scripts/f1" → "C:\skill\scripts\f1")
string targetDirectory = isRootFolder
string targetDirectory = isRootDirectory
? skillDirectoryFullPath
: Path.GetFullPath(Path.Combine(skillDirectoryFullPath, folder)) + Path.DirectorySeparatorChar;
: Path.GetFullPath(Path.Combine(skillDirectoryFullPath, directory)) + Path.DirectorySeparatorChar;
if (!Directory.Exists(targetDirectory))
{
@@ -331,13 +331,13 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
}
// Directory-level symlink check: skip if targetDirectory (or any intermediate
// segment) is a reparse point. The root folder is excluded — it's a caller-supplied
// segment) is a reparse point. The root directory is excluded — it's a caller-supplied
// trusted path, and the security boundary guards files within it, not the path itself.
if (!isRootFolder && HasSymlinkInPath(targetDirectory, skillDirectoryFullPath))
if (!isRootDirectory && HasSymlinkInPath(targetDirectory, skillDirectoryFullPath))
{
if (this._logger.IsEnabled(LogLevel.Warning))
{
LogResourceSymlinkFolder(this._logger, skillName, SanitizePathForLog(folder));
LogResourceSymlinkDirectory(this._logger, skillName, SanitizePathForLog(directory));
}
continue;
@@ -380,7 +380,7 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
// e.g. "references/../../../etc/shadow" → "/etc/shadow"
string resolvedFilePath = Path.GetFullPath(filePath);
// Path containment: reject if the resolved path escapes the target folder.
// Path containment: reject if the resolved path escapes the target directory.
// e.g. "/etc/shadow".StartsWith("/skills/myskill/references/") → false → skip
if (!resolvedFilePath.StartsWith(targetDirectory, StringComparison.OrdinalIgnoreCase))
{
@@ -416,12 +416,12 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
}
/// <summary>
/// Scans configured script folders within a skill directory for script files matching the configured extensions.
/// Scans configured script directories within a skill directory for script files matching the configured extensions.
/// </summary>
/// <remarks>
/// By default, scans the <c>scripts/</c> sub-folder as specified by the
/// By default, scans the <c>scripts/</c> subdirectory as specified by the
/// <see href="https://agentskills.io/specification">Agent Skills specification</see>.
/// Configure <see cref="AgentFileSkillsSourceOptions.ScriptFolders"/> to scan different or
/// Configure <see cref="AgentFileSkillsSourceOptions.ScriptDirectories"/> to scan different or
/// additional directories, including <c>"."</c> for the skill root itself.
/// Each file is validated against path-traversal and symlink-escape checks; unsafe files are skipped.
/// </remarks>
@@ -429,14 +429,14 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
{
var scripts = new List<AgentFileSkillScript>();
foreach (string folder in this._scriptFolders.Distinct(StringComparer.OrdinalIgnoreCase))
foreach (string directory in this._scriptDirectories.Distinct(StringComparer.OrdinalIgnoreCase))
{
bool isRootFolder = string.Equals(folder, RootFolderIndicator, StringComparison.Ordinal);
bool isRootDirectory = string.Equals(directory, RootDirectoryIndicator, StringComparison.Ordinal);
// GetFullPath normalizes mixed separators (e.g. "C:\skill\scripts/f1" → "C:\skill\scripts\f1")
string targetDirectory = isRootFolder
string targetDirectory = isRootDirectory
? skillDirectoryFullPath
: Path.GetFullPath(Path.Combine(skillDirectoryFullPath, folder)) + Path.DirectorySeparatorChar;
: Path.GetFullPath(Path.Combine(skillDirectoryFullPath, directory)) + Path.DirectorySeparatorChar;
if (!Directory.Exists(targetDirectory))
{
@@ -444,13 +444,13 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
}
// Directory-level symlink check: skip if targetDirectory (or any intermediate
// segment) is a reparse point. The root folder is excluded — it's a caller-supplied
// segment) is a reparse point. The root directory is excluded — it's a caller-supplied
// trusted path, and the security boundary guards files within it, not the path itself.
if (!isRootFolder && HasSymlinkInPath(targetDirectory, skillDirectoryFullPath))
if (!isRootDirectory && HasSymlinkInPath(targetDirectory, skillDirectoryFullPath))
{
if (this._logger.IsEnabled(LogLevel.Warning))
{
LogScriptSymlinkFolder(this._logger, skillName, SanitizePathForLog(folder));
LogScriptSymlinkDirectory(this._logger, skillName, SanitizePathForLog(directory));
}
continue;
@@ -480,7 +480,7 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
// e.g. "scripts/../../../etc/shadow" → "/etc/shadow"
string resolvedFilePath = Path.GetFullPath(filePath);
// Path containment: reject if the resolved path escapes the target folder.
// Path containment: reject if the resolved path escapes the target directory.
// e.g. "/etc/shadow".StartsWith("/skills/myskill/scripts/") → false → skip
if (!resolvedFilePath.StartsWith(targetDirectory, StringComparison.OrdinalIgnoreCase))
{
@@ -541,8 +541,8 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
}
/// <summary>
/// Normalizes a relative path or folder name by stripping a leading "./"/".\",
/// trimming trailing directory separators, and replacing backslashes with forward
/// Normalizes a relative path or directory name by stripping a leading "./"/".\",
/// trimming trailing separators, and replacing backslashes with forward
/// slashes.
/// </summary>
private static string NormalizePath(string path)
@@ -602,36 +602,36 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
}
}
private static IEnumerable<string> ValidateAndNormalizeFolderNames(IEnumerable<string> folders, ILogger logger)
private static IEnumerable<string> ValidateAndNormalizeDirectoryNames(IEnumerable<string> directories, ILogger logger)
{
foreach (string folder in folders)
foreach (string directory in directories)
{
if (string.IsNullOrWhiteSpace(folder))
if (string.IsNullOrWhiteSpace(directory))
{
throw new ArgumentException("Folder names must not be null or whitespace.", nameof(folders));
throw new ArgumentException("Directory names must not be null or whitespace.", nameof(directories));
}
// "." is valid — it means the skill root directory.
if (string.Equals(folder, RootFolderIndicator, StringComparison.Ordinal))
if (string.Equals(directory, RootDirectoryIndicator, StringComparison.Ordinal))
{
yield return folder;
yield return directory;
continue;
}
// Reject absolute paths and any path segments that escape upward.
if (Path.IsPathRooted(folder) || ContainsParentTraversalSegment(folder))
if (Path.IsPathRooted(directory) || ContainsParentTraversalSegment(directory))
{
LogFolderNameSkippedInvalid(logger, folder);
LogDirectoryNameSkippedInvalid(logger, directory);
continue;
}
yield return NormalizePath(folder);
yield return NormalizePath(directory);
}
}
private static bool ContainsParentTraversalSegment(string folder)
private static bool ContainsParentTraversalSegment(string directory)
{
foreach (string segment in folder.Split('/', '\\'))
foreach (string segment in directory.Split('/', '\\'))
{
if (segment == "..")
{
@@ -666,8 +666,8 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
[LoggerMessage(LogLevel.Warning, "Skipping resource in skill '{SkillName}': '{ResourcePath}' is a symlink that resolves outside the skill directory")]
private static partial void LogResourceSymlinkEscape(ILogger logger, string skillName, string resourcePath);
[LoggerMessage(LogLevel.Warning, "Skipping resource folder '{FolderName}' in skill '{SkillName}': folder path contains a symlink")]
private static partial void LogResourceSymlinkFolder(ILogger logger, string skillName, string folderName);
[LoggerMessage(LogLevel.Warning, "Skipping resource directory '{DirectoryName}' in skill '{SkillName}': directory path contains a symlink")]
private static partial void LogResourceSymlinkDirectory(ILogger logger, string skillName, string directoryName);
[LoggerMessage(LogLevel.Debug, "Skipping file '{FilePath}' in skill '{SkillName}': extension '{Extension}' is not in the allowed list")]
private static partial void LogResourceSkippedExtension(ILogger logger, string skillName, string filePath, string extension);
@@ -678,9 +678,9 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
[LoggerMessage(LogLevel.Warning, "Skipping script in skill '{SkillName}': '{ScriptPath}' is a symlink that resolves outside the skill directory")]
private static partial void LogScriptSymlinkEscape(ILogger logger, string skillName, string scriptPath);
[LoggerMessage(LogLevel.Warning, "Skipping script folder '{FolderName}' in skill '{SkillName}': folder path contains a symlink")]
private static partial void LogScriptSymlinkFolder(ILogger logger, string skillName, string folderName);
[LoggerMessage(LogLevel.Warning, "Skipping script directory '{DirectoryName}' in skill '{SkillName}': directory path contains a symlink")]
private static partial void LogScriptSymlinkDirectory(ILogger logger, string skillName, string directoryName);
[LoggerMessage(LogLevel.Warning, "Skipping invalid folder name '{FolderName}': must be a relative path with no '..' segments")]
private static partial void LogFolderNameSkippedInvalid(ILogger logger, string folderName);
[LoggerMessage(LogLevel.Warning, "Skipping invalid directory name '{DirectoryName}': must be a relative path with no '..' segments")]
private static partial void LogDirectoryNameSkippedInvalid(ILogger logger, string directoryName);
}
@@ -32,7 +32,7 @@ public sealed class AgentFileSkillsSourceOptions
public IEnumerable<string>? AllowedScriptExtensions { get; set; }
/// <summary>
/// Gets or sets relative folder paths to scan for script files within each skill directory.
/// Gets or sets relative directory paths to scan for script files within each skill directory.
/// Values may be single-segment names (e.g., <c>"scripts"</c>) or multi-segment relative
/// paths (e.g., <c>"sub/scripts"</c>). Use <c>"."</c> to include files directly at the
/// skill root. Leading <c>"./"</c> prefixes, trailing separators, and backslashes are
@@ -42,10 +42,10 @@ public sealed class AgentFileSkillsSourceOptions
/// <see href="https://agentskills.io/specification">Agent Skills specification</see>).
/// When set, replaces the defaults entirely.
/// </summary>
public IEnumerable<string>? ScriptFolders { get; set; }
public IEnumerable<string>? ScriptDirectories { get; set; }
/// <summary>
/// Gets or sets relative folder paths to scan for resource files within each skill directory.
/// Gets or sets relative directory paths to scan for resource files within each skill directory.
/// Values may be single-segment names (e.g., <c>"references"</c>) or multi-segment relative
/// paths (e.g., <c>"sub/resources"</c>). Use <c>"."</c> to include files directly at the
/// skill root. Leading <c>"./"</c> prefixes, trailing separators, and backslashes are
@@ -55,5 +55,5 @@ public sealed class AgentFileSkillsSourceOptions
/// <see href="https://agentskills.io/specification">Agent Skills specification</see>).
/// When set, replaces the defaults entirely.
/// </summary>
public IEnumerable<string>? ResourceFolders { get; set; }
public IEnumerable<string>? ResourceDirectories { get; set; }
}
@@ -1,8 +1,12 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using System.Reflection;
using System.Text.Json;
using System.Threading;
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
@@ -11,17 +15,55 @@ namespace Microsoft.Agents.AI;
/// <summary>
/// Abstract base class for defining skills as C# classes that bundle all components together.
/// </summary>
/// <typeparam name="TSelf">
/// The concrete skill type. This type parameter is annotated with
/// <see cref="DynamicallyAccessedMembersAttribute"/> to ensure that the IL trimmer and Native AOT compiler
/// preserve the members needed for attribute-based discovery.
/// </typeparam>
/// <remarks>
/// <para>
/// Inherit from this class to create a self-contained skill definition. Override the abstract
/// properties to provide name, description, and instructions. Use <see cref="CreateResource(string, object, string?)"/>,
/// <see cref="CreateResource(string, Delegate, string?, JsonSerializerOptions?)"/>, and <see cref="CreateScript"/> to define
/// inline resources and scripts.
/// properties to provide name, description, and instructions.
/// </para>
/// <para>
/// Scripts and resources can be defined in two ways:
/// <list type="bullet">
/// <item>
/// <b>Attribute-based (recommended):</b> Annotate methods with <see cref="AgentSkillScriptAttribute"/> to define scripts,
/// and properties or methods with <see cref="AgentSkillResourceAttribute"/> to define resources. These are automatically
/// 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.
/// </item>
/// </list>
/// </para>
/// <para>
/// <b>Multi-level inheritance limitation:</b> Discovery reflects only on <typeparamref name="TSelf"/>,
/// so if a further-derived subclass adds new attributed members, they will not be discovered unless
/// that subclass also uses the CRTP pattern
/// (e.g., <c>class SpecialSkill : AgentClassSkill&lt;SpecialSkill&gt;</c>).
/// </para>
/// </remarks>
/// <example>
/// <code>
/// public class PdfFormatterSkill : AgentClassSkill
/// // Attribute-based approach (recommended, AOT-compatible):
/// public class PdfFormatterSkill : AgentClassSkill&lt;PdfFormatterSkill&gt;
/// {
/// public override AgentSkillFrontmatter Frontmatter { get; } = new("pdf-formatter", "Format documents as PDF.");
/// protected override string Instructions =&gt; "Use this skill to format documents...";
///
/// [AgentSkillResource("template")]
/// public string Template =&gt; "Use this template...";
///
/// [AgentSkillScript("format-pdf")]
/// private static string FormatPdf(string content) =&gt; content;
/// }
///
/// // Explicit override approach (AOT-compatible):
/// public class ExplicitPdfFormatterSkill : AgentClassSkill&lt;ExplicitPdfFormatterSkill&gt;
/// {
/// private IReadOnlyList&lt;AgentSkillResource&gt;? _resources;
/// private IReadOnlyList&lt;AgentSkillScript&gt;? _scripts;
@@ -44,15 +86,41 @@ namespace Microsoft.Agents.AI;
/// </code>
/// </example>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public abstract class AgentClassSkill : AgentSkill
public abstract class AgentClassSkill<
[DynamicallyAccessedMembers(
DynamicallyAccessedMemberTypes.PublicProperties |
DynamicallyAccessedMemberTypes.NonPublicProperties |
DynamicallyAccessedMemberTypes.PublicMethods |
DynamicallyAccessedMemberTypes.NonPublicMethods)] TSelf>
: AgentSkill
where TSelf : AgentClassSkill<TSelf>
{
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;
/// <summary>
/// Gets the raw instructions text for this skill.
/// </summary>
protected abstract string Instructions { get; }
/// <summary>
/// Gets the <see cref="JsonSerializerOptions"/> used to marshal parameters and return values
/// for scripts and resources.
/// </summary>
/// <remarks>
/// Override this property to provide custom serialization options. This value is used by
/// reflection-discovered scripts and resources, and also as a fallback by <see cref="CreateScript"/>
/// and <see cref="CreateResource(string, Delegate, string?, JsonSerializerOptions?)"/> when no
/// explicit <see cref="JsonSerializerOptions"/> is passed to those methods.
/// The default value is <see langword="null"/>, which causes <see cref="AIJsonUtilities.DefaultOptions"/> to be used.
/// </remarks>
protected virtual JsonSerializerOptions? SerializerOptions => null;
/// <inheritdoc/>
/// <remarks>
/// Returns a synthesized XML document containing name, description, instructions, resources, and scripts.
@@ -65,6 +133,48 @@ public abstract class AgentClassSkill : AgentSkill
this.Resources,
this.Scripts);
/// <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
{
get
{
if (!this._resourcesDiscovered)
{
this._reflectedResources = this.DiscoverResources();
this._resourcesDiscovered = true;
}
return this._reflectedResources;
}
}
/// <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
{
get
{
if (!this._scriptsDiscovered)
{
this._reflectedScripts = this.DiscoverScripts();
this._scriptsDiscovered = true;
}
return this._reflectedScripts;
}
}
/// <summary>
/// Creates a skill resource backed by a static value.
/// </summary>
@@ -72,7 +182,7 @@ public abstract class AgentClassSkill : AgentSkill
/// <param name="value">The static resource value.</param>
/// <param name="description">An optional description of the resource.</param>
/// <returns>A new <see cref="AgentSkillResource"/> instance.</returns>
protected static AgentSkillResource CreateResource(string name, object value, string? description = null)
protected AgentSkillResource CreateResource(string name, object value, string? description = null)
=> new AgentInlineSkillResource(name, value, description);
/// <summary>
@@ -83,11 +193,11 @@ public abstract class AgentClassSkill : AgentSkill
/// <param name="description">An optional description of the resource.</param>
/// <param name="serializerOptions">
/// Optional <see cref="JsonSerializerOptions"/> used to marshal the delegate's parameters and return value.
/// When <see langword="null"/>, <see cref="AIJsonUtilities.DefaultOptions"/> is used.
/// When <see langword="null"/>, falls back to <see cref="SerializerOptions"/>.
/// </param>
/// <returns>A new <see cref="AgentSkillResource"/> instance.</returns>
protected static AgentSkillResource CreateResource(string name, Delegate method, string? description = null, JsonSerializerOptions? serializerOptions = null)
=> new AgentInlineSkillResource(name, method, description, serializerOptions);
protected AgentSkillResource CreateResource(string name, Delegate method, string? description = null, JsonSerializerOptions? serializerOptions = null)
=> new AgentInlineSkillResource(name, method, description, serializerOptions ?? this.SerializerOptions);
/// <summary>
/// Creates a skill script backed by a delegate.
@@ -97,9 +207,129 @@ public abstract class AgentClassSkill : AgentSkill
/// <param name="description">An optional description of the script.</param>
/// <param name="serializerOptions">
/// Optional <see cref="JsonSerializerOptions"/> used to marshal the delegate's parameters and return value.
/// When <see langword="null"/>, <see cref="AIJsonUtilities.DefaultOptions"/> is used.
/// When <see langword="null"/>, falls back to <see cref="SerializerOptions"/>.
/// </param>
/// <returns>A new <see cref="AgentSkillScript"/> instance.</returns>
protected static AgentSkillScript CreateScript(string name, Delegate method, string? description = null, JsonSerializerOptions? serializerOptions = null)
=> new AgentInlineSkillScript(name, method, description, serializerOptions);
protected AgentSkillScript CreateScript(string name, Delegate method, string? description = null, JsonSerializerOptions? serializerOptions = null)
=> new AgentInlineSkillScript(name, method, description, serializerOptions ?? this.SerializerOptions);
private List<AgentSkillResource>? DiscoverResources()
{
List<AgentSkillResource>? resources = null;
var selfType = typeof(TSelf);
// Discover resources from properties annotated with [AgentSkillResource].
foreach (var property in selfType.GetProperties(DiscoveryBindingFlags))
{
var attr = property.GetCustomAttribute<AgentSkillResourceAttribute>();
if (attr is null)
{
continue;
}
var getter = property.GetGetMethod(nonPublic: true);
if (getter is null)
{
continue;
}
// Indexer properties have getter parameters and cannot be used as resources
// because ReadAsync invokes the underlying AIFunction with no named arguments.
if (getter.GetParameters().Length > 0)
{
throw new InvalidOperationException(
$"Property '{property.Name}' on type '{selfType.Name}' is an indexer and cannot be used as a skill resource. " +
"Remove the [AgentSkillResource] attribute or use a non-indexer property.");
}
var name = attr.Name ?? property.Name;
if (resources?.Exists(r => r.Name == name) == true)
{
throw new InvalidOperationException($"Skill '{this.Frontmatter.Name}' already has a resource named '{name}'. Ensure each [AgentSkillResource] has a unique name.");
}
resources ??= [];
resources.Add(new AgentInlineSkillResource(
name: name,
method: getter,
target: getter.IsStatic ? null : this,
description: property.GetCustomAttribute<DescriptionAttribute>()?.Description,
serializerOptions: this.SerializerOptions));
}
// Discover resources from methods annotated with [AgentSkillResource].
foreach (var method in selfType.GetMethods(DiscoveryBindingFlags))
{
var attr = method.GetCustomAttribute<AgentSkillResourceAttribute>();
if (attr is null)
{
continue;
}
ValidateResourceMethodParameters(method, selfType);
var name = attr.Name ?? method.Name;
if (resources?.Exists(r => r.Name == name) == true)
{
throw new InvalidOperationException($"Skill '{this.Frontmatter.Name}' already has a resource named '{name}'. Ensure each [AgentSkillResource] has a unique name.");
}
resources ??= [];
resources.Add(new AgentInlineSkillResource(
name: name,
method: method,
target: method.IsStatic ? null : this,
description: method.GetCustomAttribute<DescriptionAttribute>()?.Description,
serializerOptions: this.SerializerOptions));
}
return resources;
}
private static void ValidateResourceMethodParameters(MethodInfo method, Type skillType)
{
foreach (var param in method.GetParameters())
{
if (param.ParameterType != typeof(IServiceProvider) &&
param.ParameterType != typeof(CancellationToken))
{
throw new InvalidOperationException(
$"Method '{method.Name}' on type '{skillType.Name}' has parameter '{param.Name}' of type " +
$"'{param.ParameterType}' which cannot be supplied when reading a resource. " +
"Resource methods may only accept IServiceProvider and/or CancellationToken parameters. " +
"Remove the [AgentSkillResource] attribute or change the method signature.");
}
}
}
private List<AgentSkillScript>? DiscoverScripts()
{
List<AgentSkillScript>? scripts = null;
foreach (var method in typeof(TSelf).GetMethods(DiscoveryBindingFlags))
{
var attr = method.GetCustomAttribute<AgentSkillScriptAttribute>();
if (attr is null)
{
continue;
}
var name = attr.Name ?? method.Name;
if (scripts?.Exists(s => s.Name == name) == true)
{
throw new InvalidOperationException($"Skill '{this.Frontmatter.Name}' already has a script named '{name}'. Ensure each [AgentSkillScript] has a unique name.");
}
scripts ??= [];
scripts.Add(new AgentInlineSkillScript(
name: name,
method: method,
target: method.IsStatic ? null : this,
description: method.GetCustomAttribute<DescriptionAttribute>()?.Description,
serializerOptions: this.SerializerOptions));
}
return scripts;
}
}
@@ -2,6 +2,7 @@
using System;
using System.Diagnostics.CodeAnalysis;
using System.Reflection;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
@@ -54,6 +55,28 @@ internal sealed class AgentInlineSkillResource : AgentSkillResource
this._function = AIFunctionFactory.Create(method, options);
}
/// <summary>
/// Initializes a new instance of the <see cref="AgentInlineSkillResource"/> class from a <see cref="MethodInfo"/>.
/// The method is invoked via an <see cref="AIFunction"/> each time <see cref="ReadAsync"/> is called,
/// producing a dynamic (computed) value.
/// </summary>
/// <param name="name">The resource name.</param>
/// <param name="method">A method that produces the resource value when requested.</param>
/// <param name="target">The target instance for instance methods, or <see langword="null"/> for static methods.</param>
/// <param name="description">An optional description of the resource.</param>
/// <param name="serializerOptions">
/// Optional <see cref="JsonSerializerOptions"/> used to marshal the method's parameters and return value.
/// When <see langword="null"/>, <see cref="AIJsonUtilities.DefaultOptions"/> is used.
/// </param>
public AgentInlineSkillResource(string name, MethodInfo method, object? target, string? description = null, JsonSerializerOptions? serializerOptions = null)
: base(name, description)
{
Throw.IfNull(method);
var options = new AIFunctionFactoryOptions { Name = this.Name, SerializerOptions = serializerOptions };
this._function = AIFunctionFactory.Create(method, target, options);
}
/// <inheritdoc/>
public override async Task<object?> ReadAsync(IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default)
{
@@ -2,6 +2,7 @@
using System;
using System.Diagnostics.CodeAnalysis;
using System.Reflection;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
@@ -39,6 +40,27 @@ internal sealed class AgentInlineSkillScript : AgentSkillScript
this._function = AIFunctionFactory.Create(method, options);
}
/// <summary>
/// Initializes a new instance of the <see cref="AgentInlineSkillScript"/> class from a <see cref="MethodInfo"/>.
/// The method's parameters and return type are automatically marshaled via <see cref="AIFunctionFactory"/>.
/// </summary>
/// <param name="name">The script name.</param>
/// <param name="method">The method to execute when the script is invoked.</param>
/// <param name="target">The target instance for instance methods, or <see langword="null"/> for static methods.</param>
/// <param name="description">An optional description of the script.</param>
/// <param name="serializerOptions">
/// Optional <see cref="JsonSerializerOptions"/> used to marshal the method's parameters and return value.
/// When <see langword="null"/>, <see cref="AIJsonUtilities.DefaultOptions"/> is used.
/// </param>
public AgentInlineSkillScript(string name, MethodInfo method, object? target, string? description = null, JsonSerializerOptions? serializerOptions = null)
: base(Throw.IfNullOrWhitespace(name), description)
{
Throw.IfNull(method);
var options = new AIFunctionFactoryOptions { Name = this.Name, SerializerOptions = serializerOptions };
this._function = AIFunctionFactory.Create(method, target, options);
}
/// <summary>
/// Gets the JSON schema describing the parameters accepted by this script, or <see langword="null"/> if not available.
/// </summary>
@@ -0,0 +1,73 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
/// <summary>
/// Marks a property or method as a skill resource that is automatically discovered by <see cref="AgentClassSkill{TSelf}"/>.
/// </summary>
/// <remarks>
/// <para>
/// Apply this attribute to properties or methods in an <see cref="AgentClassSkill{TSelf}"/> subclass to register
/// them as skill resources.
/// </para>
/// <para>
/// To provide a description for the resource, apply <see cref="DescriptionAttribute"/>
/// to the same member.
/// </para>
/// <para>
/// When applied to a <b>property</b>, the property getter is invoked each time the resource is read,
/// enabling dynamic (computed) resources. When applied to a <b>method</b>, the method is invoked each time
/// the resource is read, also enabling dynamic resources. Methods with an
/// <see cref="IServiceProvider"/> parameter support dependency injection.
/// </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
/// <see cref="AgentClassSkill{TSelf}.CreateResource(string, object, string?)"/> instead.
/// </para>
/// </remarks>
/// <example>
/// <code>
/// public class MySkill : AgentClassSkill&lt;MySkill&gt;
/// {
/// public override AgentSkillFrontmatter Frontmatter { get; } = new("my-skill", "A skill.");
/// protected override string Instructions =&gt; "Use this skill to do something.";
///
/// [AgentSkillResource("reference-data")]
/// [Description("Some reference content for the skill.")]
/// public string ReferenceData =&gt; "Some reference content.";
/// }
/// </code>
/// </example>
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Method, AllowMultiple = false, Inherited = false)]
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class AgentSkillResourceAttribute : Attribute
{
/// <summary>
/// Initializes a new instance of the <see cref="AgentSkillResourceAttribute"/> class.
/// The resource name defaults to the property or method name.
/// </summary>
public AgentSkillResourceAttribute()
{
}
/// <summary>
/// Initializes a new instance of the <see cref="AgentSkillResourceAttribute"/> class
/// with an explicit resource name.
/// </summary>
/// <param name="name">The resource name used to identify this resource.</param>
public AgentSkillResourceAttribute(string name)
{
this.Name = name;
}
/// <summary>
/// Gets the resource name, or <see langword="null"/> to use the member name.
/// </summary>
public string? Name { get; }
}
@@ -0,0 +1,72 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
/// <summary>
/// Marks a method as a skill script that is automatically discovered by <see cref="AgentClassSkill{TSelf}"/>.
/// </summary>
/// <remarks>
/// <para>
/// Apply this attribute to methods in an <see cref="AgentClassSkill{TSelf}"/> subclass to register them as
/// skill scripts. The method's parameters and return type are automatically marshaled via
/// <c>AIFunctionFactory</c>.
/// </para>
/// <para>
/// To provide a description for the script, apply <see cref="DescriptionAttribute"/>
/// to the same method.
/// </para>
/// <para>
/// Methods can be instance or static, and may have any visibility (public, private, etc.).
/// Methods with an <see cref="IServiceProvider"/> parameter support dependency injection.
/// </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
/// <see cref="AgentClassSkill{TSelf}.CreateScript"/> instead.
/// </para>
/// </remarks>
/// <example>
/// <code>
/// public class MySkill : AgentClassSkill&lt;MySkill&gt;
/// {
/// public override AgentSkillFrontmatter Frontmatter { get; } = new("my-skill", "A skill.");
/// protected override string Instructions =&gt; "Use this skill to do something.";
///
/// [AgentSkillScript("do-something")]
/// [Description("Converts the input to upper case.")]
/// private static string DoSomething(string input) =&gt; input.ToUpperInvariant();
/// }
/// </code>
/// </example>
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)]
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class AgentSkillScriptAttribute : Attribute
{
/// <summary>
/// Initializes a new instance of the <see cref="AgentSkillScriptAttribute"/> class.
/// The script name defaults to the method name.
/// </summary>
public AgentSkillScriptAttribute()
{
}
/// <summary>
/// Initializes a new instance of the <see cref="AgentSkillScriptAttribute"/> class
/// with an explicit script name.
/// </summary>
/// <param name="name">The script name used to identify this script.</param>
public AgentSkillScriptAttribute(string name)
{
this.Name = name;
}
/// <summary>
/// Gets the script name, or <see langword="null"/> to use the method name.
/// </summary>
public string? Name { get; }
}
File diff suppressed because it is too large Load Diff
@@ -116,8 +116,8 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable
[Fact]
public async Task GetSkillsAsync_ScriptsOutsideScriptsDir_AreNotDiscoveredAsync()
{
// Arrange — scripts outside configured folders are not discovered; only files directly
// inside the configured folder are picked up (no subdirectory recursion)
// Arrange — scripts outside configured directories are not discovered; only files directly
// inside the configured directory are picked up (no subdirectory recursion)
string skillDir = CreateSkillDir(this._testRoot, "root-scripts", "Root scripts skill", "Body.");
CreateFile(skillDir, "convert.py", "print('root')");
CreateFile(skillDir, "tools/helper.sh", "echo 'helper'");
@@ -126,7 +126,7 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable
// Act
var skills = await source.GetSkillsAsync(CancellationToken.None);
// Assert — neither file is in the default scripts/ folder, so no scripts are discovered
// Assert — neither file is in the default scripts/ directory, so no scripts are discovered
Assert.Single(skills);
Assert.Empty(skills[0].Scripts!);
}
@@ -229,18 +229,18 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable
}
[Fact]
public async Task GetSkillsAsync_ScriptFoldersWithNestedPath_DiscoversScriptsAsync()
public async Task GetSkillsAsync_ScriptDirectoriesWithNestedPath_DiscoversScriptsAsync()
{
// Arrange — ScriptFolders configured with a multi-segment relative path (f1/f2/f3)
string skillDir = CreateSkillDir(this._testRoot, "nested-script-skill", "Nested script folder", "Body.");
// Arrange — ScriptDirectories configured with a multi-segment relative path (f1/f2/f3)
string skillDir = CreateSkillDir(this._testRoot, "nested-script-skill", "Nested script directory", "Body.");
CreateFile(skillDir, "f1/f2/f3/run.py", "print('nested')");
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
new AgentFileSkillsSourceOptions { ScriptFolders = ["f1/f2/f3"] });
new AgentFileSkillsSourceOptions { ScriptDirectories = ["f1/f2/f3"] });
// Act
var skills = await source.GetSkillsAsync(CancellationToken.None);
// Assert — script file inside the deeply nested folder is discovered
// 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);
@@ -250,29 +250,29 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable
[InlineData("./scripts")]
[InlineData("./scripts/f1")]
[InlineData("./scripts/f1", "./f2")]
public async Task GetSkillsAsync_ScriptFolderWithDotSlashPrefix_DiscoversScriptsAsync(params string[] folders)
public async Task GetSkillsAsync_ScriptDirectoryWithDotSlashPrefix_DiscoversScriptsAsync(params string[] directories)
{
// Arrange — "./"-prefixed folders are equivalent to their counterparts without the prefix;
// Arrange — "./"-prefixed directories are equivalent to their counterparts without the prefix;
// the leading "./" is transparently normalized by Path.GetFullPath during file enumeration.
string skillDir = CreateSkillDir(this._testRoot, "dotslash-script-skill", "Dot-slash prefix", "Body.");
foreach (string folder in folders)
foreach (string directory in directories)
{
string folderWithoutDotSlash = folder.Substring(2); // strip "./"
CreateFile(skillDir, $"{folderWithoutDotSlash}/run.py", "print('dotslash')");
string directoryWithoutDotSlash = directory.Substring(2); // strip "./"
CreateFile(skillDir, $"{directoryWithoutDotSlash}/run.py", "print('dotslash')");
}
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
new AgentFileSkillsSourceOptions { ScriptFolders = folders });
new AgentFileSkillsSourceOptions { ScriptDirectories = directories });
// Act
var skills = await source.GetSkillsAsync(CancellationToken.None);
// Assert — scripts are discovered with names identical to using folders without "./"
// Assert — scripts are discovered with names identical to using directories without "./"
Assert.Single(skills);
Assert.Equal(folders.Length, skills[0].Scripts!.Count);
foreach (string folder in folders)
Assert.Equal(directories.Length, skills[0].Scripts!.Count);
foreach (string directory in directories)
{
string expectedName = $"{folder.Substring(2)}/run.py";
string expectedName = $"{directory.Substring(2)}/run.py";
Assert.Contains(skills[0].Scripts!, s => s.Name == expectedName);
}
}
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
@@ -167,4 +168,58 @@ public sealed class AgentInlineSkillResourceTests
// Assert
Assert.Equal("value", result);
}
[Fact]
public void Constructor_MethodInfo_SetsNameAndDescription()
{
// Arrange
var method = typeof(AgentInlineSkillResourceTests).GetMethod(nameof(StaticResourceHelper), BindingFlags.NonPublic | BindingFlags.Static)!;
// Act
var resource = new AgentInlineSkillResource("method-resource", method, target: null, description: "A method resource.");
// Assert
Assert.Equal("method-resource", resource.Name);
Assert.Equal("A method resource.", resource.Description);
}
[Fact]
public async Task ReadAsync_MethodInfo_StaticMethod_ReturnsValueAsync()
{
// Arrange
var method = typeof(AgentInlineSkillResourceTests).GetMethod(nameof(StaticResourceHelper), BindingFlags.NonPublic | BindingFlags.Static)!;
var resource = new AgentInlineSkillResource("static-method-res", method, target: null);
// Act
var result = await resource.ReadAsync();
// Assert
Assert.Equal("static-resource-value", result?.ToString());
}
[Fact]
public async Task ReadAsync_MethodInfo_InstanceMethod_ReturnsValueAsync()
{
// Arrange
var method = typeof(AgentInlineSkillResourceTests).GetMethod(nameof(InstanceResourceHelper), BindingFlags.NonPublic | BindingFlags.Instance)!;
var resource = new AgentInlineSkillResource("instance-method-res", method, target: this);
// Act
var result = await resource.ReadAsync();
// Assert
Assert.Equal("instance-resource-value", result?.ToString());
}
[Fact]
public void Constructor_MethodInfo_NullMethod_Throws()
{
// Act & Assert
Assert.Throws<ArgumentNullException>(() =>
new AgentInlineSkillResource("my-res", null!, target: null));
}
private static string StaticResourceHelper() => "static-resource-value";
private string InstanceResourceHelper() => "instance-resource-value";
}
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Reflection;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
@@ -152,4 +153,77 @@ public sealed class AgentInlineSkillScriptTests
// Assert
Assert.Equal("hello world", result?.ToString());
}
[Fact]
public void Constructor_MethodInfo_SetsNameAndDescription()
{
// Arrange
var method = typeof(AgentInlineSkillScriptTests).GetMethod(nameof(StaticScriptHelper), BindingFlags.NonPublic | BindingFlags.Static)!;
// Act
var script = new AgentInlineSkillScript("method-script", method, target: null, description: "A method script.");
// Assert
Assert.Equal("method-script", script.Name);
Assert.Equal("A method script.", script.Description);
}
[Fact]
public async Task RunAsync_MethodInfo_StaticMethod_InvokesAndReturnsAsync()
{
// Arrange
var method = typeof(AgentInlineSkillScriptTests).GetMethod(nameof(StaticScriptHelper), BindingFlags.NonPublic | BindingFlags.Static)!;
var script = new AgentInlineSkillScript("static-method-script", method, target: null);
var skill = new AgentInlineSkill("test-skill", "Test.", "Instructions.");
var args = new AIFunctionArguments { ["input"] = "hello" };
// Act
var result = await script.RunAsync(skill, args, CancellationToken.None);
// Assert
Assert.Equal("HELLO", result?.ToString());
}
[Fact]
public async Task RunAsync_MethodInfo_InstanceMethod_InvokesAndReturnsAsync()
{
// Arrange
var method = typeof(AgentInlineSkillScriptTests).GetMethod(nameof(InstanceScriptHelper), BindingFlags.NonPublic | BindingFlags.Instance)!;
var script = new AgentInlineSkillScript("instance-method-script", method, target: this);
var skill = new AgentInlineSkill("test-skill", "Test.", "Instructions.");
var args = new AIFunctionArguments { ["input"] = "test" };
// Act
var result = await script.RunAsync(skill, args, CancellationToken.None);
// Assert
Assert.Equal("test-suffix", result?.ToString());
}
[Fact]
public void Constructor_MethodInfo_NullMethod_Throws()
{
// Act & Assert
Assert.Throws<ArgumentNullException>(() =>
new AgentInlineSkillScript("my-script", null!, target: null));
}
[Fact]
public void ParametersSchema_MethodInfo_ContainsParameterNames()
{
// Arrange
var method = typeof(AgentInlineSkillScriptTests).GetMethod(nameof(StaticScriptHelper), BindingFlags.NonPublic | BindingFlags.Static)!;
var script = new AgentInlineSkillScript("param-script", method, target: null);
// Act
var schema = script.ParametersSchema;
// Assert
Assert.NotNull(schema);
Assert.Contains("input", schema!.Value.GetRawText());
}
private static string StaticScriptHelper(string input) => input.ToUpperInvariant();
private string InstanceScriptHelper(string input) => input + "-suffix";
}
@@ -0,0 +1,29 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
/// <summary>
/// Unit tests for <see cref="AgentSkillResourceAttribute"/>.
/// </summary>
public sealed class AgentSkillResourceAttributeTests
{
[Fact]
public void DefaultConstructor_NameIsNull()
{
// Arrange & Act
var attr = new AgentSkillResourceAttribute();
// Assert
Assert.Null(attr.Name);
}
[Fact]
public void NamedConstructor_SetsName()
{
// Arrange & Act
var attr = new AgentSkillResourceAttribute("my-resource");
// Assert
Assert.Equal("my-resource", attr.Name);
}
}
@@ -0,0 +1,29 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
/// <summary>
/// Unit tests for <see cref="AgentSkillScriptAttribute"/>.
/// </summary>
public sealed class AgentSkillScriptAttributeTests
{
[Fact]
public void DefaultConstructor_NameIsNull()
{
// Arrange & Act
var attr = new AgentSkillScriptAttribute();
// Assert
Assert.Null(attr.Name);
}
[Fact]
public void NamedConstructor_SetsName()
{
// Arrange & Act
var attr = new AgentSkillScriptAttribute("my-script");
// Assert
Assert.Equal("my-script", attr.Name);
}
}
@@ -871,7 +871,7 @@ public sealed class AgentSkillsProviderTests : IDisposable
public async Task Constructor_ClassSkillsEnumerable_ProvidesSkillsAsync()
{
// Arrange
var skills = new List<AgentClassSkill>
var skills = new List<AgentSkill>
{
new TestClassSkill("enum-class-a", "Class A", "Instructions A."),
new TestClassSkill("enum-class-b", "Class B", "Instructions B."),
@@ -928,7 +928,7 @@ public sealed class AgentSkillsProviderTests : IDisposable
}
}
private sealed class TestClassSkill : AgentClassSkill
private sealed class TestClassSkill : AgentClassSkill<TestClassSkill>
{
private readonly string _instructions;
@@ -199,7 +199,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
[Fact]
public async Task GetSkillsAsync_FilesWithMatchingExtensions_DiscoveredAsResourcesAsync()
{
// Arrange — create resource files in spec-defined sub-folders
// Arrange — create resource files in spec-defined subdirectories
string skillDir = Path.Combine(this._testRoot, "resource-skill");
string refsDir = Path.Combine(skillDir, "references");
string assetsDir = Path.Combine(skillDir, "assets");
@@ -226,7 +226,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
[Fact]
public async Task GetSkillsAsync_FilesWithNonMatchingExtensions_NotDiscoveredAsync()
{
// Arrange — create a file with an extension not in the default list inside a spec folder
// Arrange — create a file with an extension not in the default list inside a spec directory
string skillDir = Path.Combine(this._testRoot, "ext-skill");
string refsDir = Path.Combine(skillDir, "references");
Directory.CreateDirectory(refsDir);
@@ -300,7 +300,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
[Fact]
public async Task GetSkillsAsync_CustomResourceExtensions_UsedForDiscoveryAsync()
{
// Arrange — use a source with custom extensions; files placed in spec folder
// Arrange — use a source with custom extensions; files placed in spec directory
string skillDir = Path.Combine(this._testRoot, "custom-ext-skill");
string refsDir = Path.Combine(skillDir, "references");
Directory.CreateDirectory(refsDir);
@@ -364,7 +364,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
[Fact]
public async Task GetSkillsAsync_ResourceInSkillRoot_NotDiscoveredByDefaultAsync()
{
// Arrange — resource files directly in the skill directory (not in a spec sub-folder)
// Arrange — resource files directly in the skill directory (not in a spec subdirectory)
string skillDir = Path.Combine(this._testRoot, "root-resource-skill");
Directory.CreateDirectory(skillDir);
File.WriteAllText(Path.Combine(skillDir, "guide.md"), "guide content");
@@ -377,15 +377,15 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
// Act
var skills = await source.GetSkillsAsync();
// Assert — root-level files are NOT discovered unless "." is in ResourceFolders
// Assert — root-level files are NOT discovered unless "." is in ResourceDirectories
Assert.Single(skills);
Assert.Empty(skills[0].Resources!);
}
[Fact]
public async Task GetSkillsAsync_ResourceInSkillRoot_DiscoveredWhenRootFolderConfiguredAsync()
public async Task GetSkillsAsync_ResourceInSkillRoot_DiscoveredWhenRootDirectoryConfiguredAsync()
{
// Arrange — "." in ResourceFolders opts into root-level resource discovery
// Arrange — "." in ResourceDirectories opts into root-level resource discovery
string skillDir = Path.Combine(this._testRoot, "root-opt-in-skill");
Directory.CreateDirectory(skillDir);
File.WriteAllText(Path.Combine(skillDir, "guide.md"), "guide content");
@@ -394,7 +394,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
Path.Combine(skillDir, "SKILL.md"),
"---\nname: root-opt-in-skill\ndescription: Root opt-in\n---\nBody.");
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
new AgentFileSkillsSourceOptions { ResourceFolders = ["references", "assets", "."] });
new AgentFileSkillsSourceOptions { ResourceDirectories = ["references", "assets", "."] });
// Act
var skills = await source.GetSkillsAsync();
@@ -408,31 +408,31 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
}
[Fact]
public async Task GetSkillsAsync_ResourceInNonSpecFolder_NotDiscoveredByDefaultAsync()
public async Task GetSkillsAsync_ResourceInNonSpecDirectory_NotDiscoveredByDefaultAsync()
{
// Arrange — resource in a non-spec folder (neither references/ nor assets/)
// Arrange — resource in a non-spec directory (neither references/ nor assets/)
string skillDir = Path.Combine(this._testRoot, "non-spec-skill");
string customDir = Path.Combine(skillDir, "docs");
Directory.CreateDirectory(customDir);
File.WriteAllText(Path.Combine(customDir, "readme.md"), "docs content");
File.WriteAllText(
Path.Combine(skillDir, "SKILL.md"),
"---\nname: non-spec-skill\ndescription: Non-spec folder\n---\nBody.");
"---\nname: non-spec-skill\ndescription: Non-spec directory\n---\nBody.");
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
// Act
var skills = await source.GetSkillsAsync();
// Assert — non-spec folders are not scanned by default
// Assert — non-spec directories are not scanned by default
Assert.Single(skills);
Assert.Empty(skills[0].Resources!);
}
[Fact]
public async Task GetSkillsAsync_CustomResourceFolders_ReplacesDefaultsAsync()
public async Task GetSkillsAsync_CustomResourceDirectories_ReplacesDefaultsAsync()
{
// Arrange — custom ResourceFolders replaces the spec defaults
string skillDir = Path.Combine(this._testRoot, "custom-folder-skill");
// Arrange — custom ResourceDirectories replaces the spec defaults
string skillDir = Path.Combine(this._testRoot, "custom-directory-skill");
string customDir = Path.Combine(skillDir, "docs");
string refsDir = Path.Combine(skillDir, "references");
Directory.CreateDirectory(customDir);
@@ -441,9 +441,9 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
File.WriteAllText(Path.Combine(refsDir, "ref.md"), "ref content");
File.WriteAllText(
Path.Combine(skillDir, "SKILL.md"),
"---\nname: custom-folder-skill\ndescription: Custom folder\n---\nBody.");
"---\nname: custom-directory-skill\ndescription: Custom directory\n---\nBody.");
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
new AgentFileSkillsSourceOptions { ResourceFolders = ["docs"] });
new AgentFileSkillsSourceOptions { ResourceDirectories = ["docs"] });
// Act
var skills = await source.GetSkillsAsync();
@@ -518,7 +518,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
[Fact]
public async Task ReadSkillResourceAsync_ValidResource_ReturnsContentAsync()
{
// Arrange — create a skill with a resource file discovered from the references folder
// Arrange — create a skill with a resource file discovered from the references directory
string skillDir = this.CreateSkillDirectory("read-skill", "A skill", "See docs for details.");
string refsDir = Path.Combine(skillDir, "references");
Directory.CreateDirectory(refsDir);
@@ -614,12 +614,12 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
}
[Fact]
public async Task GetSkillsAsync_SymlinkedResourceFolder_SkipsWithoutEnumeratingAsync()
public async Task GetSkillsAsync_SymlinkedResourceDirectory_SkipsWithoutEnumeratingAsync()
{
// Arrange — references/ is a symlink pointing outside the skill directory.
// The directory-level check should skip it entirely (no file enumeration),
// so even files with valid extensions in the target are not discovered.
string skillDir = Path.Combine(this._testRoot, "symlink-folder-skip");
string skillDir = Path.Combine(this._testRoot, "symlink-directory-skip");
string assetsDir = Path.Combine(skillDir, "assets");
Directory.CreateDirectory(assetsDir);
File.WriteAllText(Path.Combine(assetsDir, "legit.md"), "legit content");
@@ -642,21 +642,21 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
File.WriteAllText(
Path.Combine(skillDir, "SKILL.md"),
"---\nname: symlink-folder-skip\ndescription: Symlinked folder skip\n---\nBody.");
"---\nname: symlink-directory-skip\ndescription: Symlinked directory skip\n---\nBody.");
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
// Act
var skills = await source.GetSkillsAsync();
// Assert — only assets/legit.md is found; the symlinked references/ folder is skipped entirely
var skill = skills.FirstOrDefault(s => s.Frontmatter.Name == "symlink-folder-skip");
// 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);
}
[Fact]
public async Task GetSkillsAsync_SymlinkedScriptFolder_SkipsWithoutEnumeratingAsync()
public async Task GetSkillsAsync_SymlinkedScriptDirectory_SkipsWithoutEnumeratingAsync()
{
// Arrange — scripts/ is a symlink pointing outside the skill directory.
// The directory-level check should skip it entirely.
@@ -679,22 +679,22 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
File.WriteAllText(
Path.Combine(skillDir, "SKILL.md"),
"---\nname: symlink-script-skip\ndescription: Symlinked script folder\n---\nBody.");
"---\nname: symlink-script-skip\ndescription: Symlinked script directory\n---\nBody.");
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
// Act
var skills = await source.GetSkillsAsync();
// Assert — skill loads but scripts from the symlinked folder are not discovered
// 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!);
}
[Fact]
public async Task GetSkillsAsync_SymlinkedIntermediateSegment_SkipsCustomFolderAsync()
public async Task GetSkillsAsync_SymlinkedIntermediateSegment_SkipsCustomDirectoryAsync()
{
// Arrange — custom resource folder "sub/resources" where "sub" is a symlink.
// Arrange — custom resource directory "sub/resources" where "sub" is a symlink.
// The directory-level HasSymlinkInPath check should detect the intermediate symlink.
string skillDir = Path.Combine(this._testRoot, "symlink-intermediate");
Directory.CreateDirectory(skillDir);
@@ -720,12 +720,12 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
var source = new AgentFileSkillsSource(
this._testRoot,
s_noOpExecutor,
new AgentFileSkillsSourceOptions { ResourceFolders = ["sub/resources"] });
new AgentFileSkillsSourceOptions { ResourceDirectories = ["sub/resources"] });
// Act
var skills = await source.GetSkillsAsync();
// Assert — the symlinked intermediate segment causes the folder to be skipped
// 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!);
@@ -900,11 +900,11 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
[InlineData("sub/../escape")]
[InlineData("/absolute")]
[InlineData("\\absolute")]
public void Constructor_InvalidFolderName_SkipsInvalidFolders(string badFolder)
public void Constructor_InvalidDirectoryName_SkipsInvalidDirectories(string badDirectory)
{
// Arrange & Act — invalid folders are skipped with a warning rather than throwing
var source1 = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ScriptFolders = [badFolder] });
var source2 = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ResourceFolders = [badFolder] });
// Arrange & Act — invalid directories are skipped with a warning rather than throwing
var source1 = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ScriptDirectories = [badDirectory] });
var source2 = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ResourceDirectories = [badDirectory] });
// Assert
Assert.NotNull(source1);
@@ -915,54 +915,54 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Constructor_NullOrWhitespaceFolderName_ThrowsArgumentException(string? badFolder)
public void Constructor_NullOrWhitespaceDirectoryName_ThrowsArgumentException(string? badDirectory)
{
// Arrange & Act & Assert — null/whitespace is a contract violation, not a config error
Assert.Throws<ArgumentException>(() => new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ScriptFolders = [badFolder!] }));
Assert.Throws<ArgumentException>(() => new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ResourceFolders = [badFolder!] }));
Assert.Throws<ArgumentException>(() => new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ScriptDirectories = [badDirectory!] }));
Assert.Throws<ArgumentException>(() => new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ResourceDirectories = [badDirectory!] }));
}
[Theory]
[InlineData("scripts")]
[InlineData("my-scripts")]
[InlineData("sub/folder")]
[InlineData("sub/directory")]
[InlineData(".")]
[InlineData("./scripts")]
[InlineData("./scripts/f1")]
[InlineData("my..scripts")]
public void Constructor_ValidFolderName_DoesNotThrow(string validFolder)
public void Constructor_ValidDirectoryName_DoesNotThrow(string validDirectory)
{
// Arrange & Act & Assert
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ScriptFolders = [validFolder] });
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ScriptDirectories = [validDirectory] });
Assert.NotNull(source);
}
[Fact]
public async Task GetSkillsAsync_DuplicateFoldersAfterNormalization_NoDuplicateResourcesAsync()
public async Task GetSkillsAsync_DuplicateDirectoriesAfterNormalization_NoDuplicateResourcesAsync()
{
// Arrange — "references" and "./references" refer to the same directory;
// after normalization they should be deduplicated so resources appear only once.
string skillDir = Path.Combine(this._testRoot, "dedup-folder-skill");
string skillDir = Path.Combine(this._testRoot, "dedup-directory-skill");
string refsDir = Path.Combine(skillDir, "references");
Directory.CreateDirectory(refsDir);
File.WriteAllText(Path.Combine(refsDir, "FAQ.md"), "FAQ content");
File.WriteAllText(
Path.Combine(skillDir, "SKILL.md"),
"---\nname: dedup-folder-skill\ndescription: Dedup test\n---\nBody.");
"---\nname: dedup-directory-skill\ndescription: Dedup test\n---\nBody.");
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
new AgentFileSkillsSourceOptions { ResourceFolders = ["references", "./references"] });
new AgentFileSkillsSourceOptions { ResourceDirectories = ["references", "./references"] });
// Act
var skills = await source.GetSkillsAsync();
// Assert — only one copy of the resource despite two equivalent folder entries
// 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);
}
[Fact]
public async Task GetSkillsAsync_TrailingSlashFolderNormalized_NoDuplicateResourcesAsync()
public async Task GetSkillsAsync_TrailingSlashDirectoryNormalized_NoDuplicateResourcesAsync()
{
// Arrange — "references/" should be normalized to "references"
string skillDir = Path.Combine(this._testRoot, "trailing-slash-skill");
@@ -973,7 +973,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
Path.Combine(skillDir, "SKILL.md"),
"---\nname: trailing-slash-skill\ndescription: Trailing slash test\n---\nBody.");
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
new AgentFileSkillsSourceOptions { ResourceFolders = ["references", "references/"] });
new AgentFileSkillsSourceOptions { ResourceDirectories = ["references", "references/"] });
// Act
var skills = await source.GetSkillsAsync();
@@ -985,7 +985,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
}
[Fact]
public async Task GetSkillsAsync_BackslashFolderNormalized_NoDuplicateScriptsAsync()
public async Task GetSkillsAsync_BackslashDirectoryNormalized_NoDuplicateScriptsAsync()
{
// Arrange — ".\\scripts" should be normalized to "scripts"
string skillDir = Path.Combine(this._testRoot, "backslash-skill");
@@ -996,7 +996,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
Path.Combine(skillDir, "SKILL.md"),
"---\nname: backslash-skill\ndescription: Backslash test\n---\nBody.");
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
new AgentFileSkillsSourceOptions { ScriptFolders = ["scripts", ".\\scripts"] });
new AgentFileSkillsSourceOptions { ScriptDirectories = ["scripts", ".\\scripts"] });
// Act
var skills = await source.GetSkillsAsync();
@@ -1010,48 +1010,48 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
[Theory]
[InlineData("./references")]
[InlineData("./assets/docs")]
public async Task GetSkillsAsync_ResourceFolderWithDotSlashPrefix_DiscoversResourcesAsync(string folder)
public async Task GetSkillsAsync_ResourceDirectoryWithDotSlashPrefix_DiscoversResourcesAsync(string directory)
{
// Arrange — "./references" and "./assets/docs" are equivalent to "references" and "assets/docs";
// the leading "./" is transparently normalized by Path.GetFullPath during file enumeration.
string folderWithoutDotSlash = folder.Substring(2); // strip "./"
string directoryWithoutDotSlash = directory.Substring(2); // strip "./"
string skillDir = Path.Combine(this._testRoot, "dotslash-res-skill");
string targetDir = Path.Combine(skillDir, folderWithoutDotSlash.Replace('/', Path.DirectorySeparatorChar));
string targetDir = Path.Combine(skillDir, directoryWithoutDotSlash.Replace('/', Path.DirectorySeparatorChar));
Directory.CreateDirectory(targetDir);
File.WriteAllText(Path.Combine(targetDir, "data.json"), "{}");
File.WriteAllText(
Path.Combine(skillDir, "SKILL.md"),
"---\nname: dotslash-res-skill\ndescription: Dot-slash prefix\n---\nBody.");
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
new AgentFileSkillsSourceOptions { ResourceFolders = [folder] });
new AgentFileSkillsSourceOptions { ResourceDirectories = [directory] });
// Act
var skills = await source.GetSkillsAsync();
// Assert — the resource is discovered with a name identical to using the folder without "./"
// Assert — the resource is discovered with a name identical to using the directory without "./"
Assert.Single(skills);
Assert.Single(skills[0].Resources!);
Assert.Equal($"{folderWithoutDotSlash}/data.json", skills[0].Resources![0].Name);
Assert.Equal($"{directoryWithoutDotSlash}/data.json", skills[0].Resources![0].Name);
}
[Fact]
public async Task GetSkillsAsync_ResourceFoldersWithNestedPath_DiscoversResourcesAsync()
public async Task GetSkillsAsync_ResourceDirectoriesWithNestedPath_DiscoversResourcesAsync()
{
// Arrange — ResourceFolders configured with a multi-segment relative path (f1/f2/f3)
string skillDir = Path.Combine(this._testRoot, "nested-folder-skill");
// Arrange — ResourceDirectories configured with a multi-segment relative path (f1/f2/f3)
string skillDir = Path.Combine(this._testRoot, "nested-directory-skill");
string nestedDir = Path.Combine(skillDir, "f1", "f2", "f3");
Directory.CreateDirectory(nestedDir);
File.WriteAllText(Path.Combine(nestedDir, "data.json"), "{}");
File.WriteAllText(
Path.Combine(skillDir, "SKILL.md"),
"---\nname: nested-folder-skill\ndescription: Nested folder\n---\nBody.");
"---\nname: nested-directory-skill\ndescription: Nested directory\n---\nBody.");
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
new AgentFileSkillsSourceOptions { ResourceFolders = ["f1/f2/f3"] });
new AgentFileSkillsSourceOptions { ResourceDirectories = ["f1/f2/f3"] });
// Act
var skills = await source.GetSkillsAsync();
// Assert — resource file inside the deeply nested folder is discovered
// Assert — resource file inside the deeply nested directory is discovered
Assert.Single(skills);
var skill = skills[0];
Assert.Single(skill.Resources!);
@@ -1107,9 +1107,9 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
}
[Fact]
public async Task GetSkillsAsync_ScriptInSkillRoot_DiscoveredWhenRootFolderConfiguredAsync()
public async Task GetSkillsAsync_ScriptInSkillRoot_DiscoveredWhenRootDirectoryConfiguredAsync()
{
// Arrange — script file directly in the skill directory with ScriptFolders = ["."]
// Arrange — script file directly in the skill directory with ScriptDirectories = ["."]
string skillDir = Path.Combine(this._testRoot, "root-script-skill");
Directory.CreateDirectory(skillDir);
File.WriteAllText(Path.Combine(skillDir, "run.py"), "print('hello')");
@@ -1117,7 +1117,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
Path.Combine(skillDir, "SKILL.md"),
"---\nname: root-script-skill\ndescription: Root script\n---\nBody.");
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
new AgentFileSkillsSourceOptions { ScriptFolders = ["."] });
new AgentFileSkillsSourceOptions { ScriptDirectories = ["."] });
// Act
var skills = await source.GetSkillsAsync();
@@ -1131,7 +1131,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
#if NET
[Fact]
public async Task GetSkillsAsync_SymlinkedFileInRealFolder_SkipsSymlinkedFileAsync()
public async Task GetSkillsAsync_SymlinkedFileInRealDirectory_SkipsSymlinkedFileAsync()
{
// Arrange — references/ is a real directory, but one file inside it is a symlink
// pointing outside the skill directory. The per-file symlink check should skip it.
@@ -9,6 +9,7 @@ using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Agents.AI.Workflows.InProc;
using Microsoft.Extensions.AI;
@@ -147,7 +148,7 @@ public class AgentWorkflowBuilderTests
for (int iter = 0; iter < 3; iter++)
{
const string UserInput = "abc";
(string updateText, List<ChatMessage>? result, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, UserInput)]);
(string updateText, List<ChatMessage>? result, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, UserInput)]);
Assert.NotNull(result);
Assert.Equal(numAgents + 1, result.Count);
@@ -225,7 +226,7 @@ public class AgentWorkflowBuilderTests
barrier.Value = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
remaining.Value = 2;
(string updateText, List<ChatMessage>? result, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]);
(string updateText, List<ChatMessage>? result, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]);
Assert.NotEmpty(updateText);
Assert.NotNull(result);
@@ -258,7 +259,7 @@ public class AgentWorkflowBuilderTests
}), description: "nop"))
.Build();
(string updateText, List<ChatMessage>? result, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]);
(string updateText, List<ChatMessage>? result, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]);
Assert.Equal("Hello from agent1", updateText);
Assert.NotNull(result);
@@ -296,7 +297,7 @@ public class AgentWorkflowBuilderTests
.WithHandoff(initialAgent, nextAgent)
.Build();
(string updateText, List<ChatMessage>? result, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]);
(string updateText, List<ChatMessage>? result, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]);
Assert.Equal("Hello from agent2", updateText);
Assert.NotNull(result);
@@ -406,7 +407,7 @@ public class AgentWorkflowBuilderTests
.WithHandoff(secondAgent, thirdAgent)
.Build();
(string updateText, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]);
(string updateText, _, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]);
Assert.Contains("Hello from agent3", updateText);
@@ -604,7 +605,7 @@ public class AgentWorkflowBuilderTests
.WithHandoff(secondAgent, thirdAgent)
.Build();
(string updateText, List<ChatMessage>? result, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]);
(string updateText, List<ChatMessage>? result, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]);
Assert.Equal("Hello from agent3", updateText);
Assert.NotNull(result);
@@ -634,6 +635,232 @@ public class AgentWorkflowBuilderTests
Assert.Contains("thirdAgent", result[5].AuthorName);
}
[Fact]
public async Task Handoffs_TwoTransfers_SecondAgentUserApproval_ResponseServedByThirdAgentAsync()
{
var initialAgent = new ChatClientAgent(new MockChatClient((messages, options) =>
{
ChatMessage message = Assert.Single(messages);
Assert.Equal("abc", Assert.IsType<TextContent>(Assert.Single(message.Contents)).Text);
string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name;
Assert.NotNull(transferFuncName);
// Only a handoff function call.
return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", transferFuncName)]));
}), name: "initialAgent");
bool secondAgentInvoked = false;
const string SomeOtherFunctionCallId = "call2first";
AIFunction someOtherFunction = new ApprovalRequiredAIFunction(AIFunctionFactory.Create(SomeOtherFunction));
var secondAgent = new ChatClientAgent(new MockChatClient((messages, options) =>
{
if (!secondAgentInvoked)
{
secondAgentInvoked = true;
return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent(SomeOtherFunctionCallId, someOtherFunction.Name)]));
}
// Second agent should receive the conversation so far (including previous assistant + tool messages eventually).
string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name;
Assert.NotNull(transferFuncName);
return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call2", transferFuncName)]));
}), name: "secondAgent", description: "The second agent", tools: [someOtherFunction]);
var thirdAgent = new ChatClientAgent(new MockChatClient((messages, options) =>
new(new ChatMessage(ChatRole.Assistant, "Hello from agent3"))),
name: "thirdAgent",
description: "The third / final agent");
var workflow =
AgentWorkflowBuilder.CreateHandoffBuilderWith(initialAgent)
.WithHandoff(initialAgent, secondAgent)
.WithHandoff(secondAgent, thirdAgent)
.Build();
CheckpointManager checkpointManager = CheckpointManager.CreateInMemory();
const ExecutionEnvironment Environment = ExecutionEnvironment.InProcess_Lockstep;
(string updateText, List<ChatMessage>? result, CheckpointInfo? lastCheckpoint, List<RequestInfoEvent> requests) =
await RunWorkflowCheckpointedAsync(workflow, [new ChatMessage(ChatRole.User, "abc")], Environment, checkpointManager);
Assert.Null(result);
Assert.NotNull(requests);
requests.Should().HaveCount(1);
ExternalRequest request = requests[0].Request;
ToolApprovalRequestContent approvalRequest =
request.Data.As<ToolApprovalRequestContent>().Should().NotBeNull()
.And.Subject.As<ToolApprovalRequestContent>();
approvalRequest.ToolCall.CallId.Should().Be(SomeOtherFunctionCallId);
ExternalResponse response = request.CreateResponse(approvalRequest.CreateResponse(false, "Denied"));
(updateText, result, _, requests) =
await RunWorkflowCheckpointedAsync(workflow, response, Environment, checkpointManager, lastCheckpoint);
Assert.Equal("Hello from agent3", updateText);
Assert.NotNull(result);
// User + (assistant empty + tool) for each of first two agents + final assistant with text.
Assert.Equal(10, result.Count);
Assert.Equal(ChatRole.User, result[0].Role);
Assert.Equal("abc", result[0].Text);
Assert.Equal(ChatRole.Assistant, result[1].Role);
Assert.Equal("", result[1].Text);
Assert.Contains("initialAgent", result[1].AuthorName);
Assert.Equal(ChatRole.Tool, result[2].Role);
Assert.Contains("initialAgent", result[2].AuthorName);
// Non-handoff tool invocation (and user denial)
Assert.Equal(ChatRole.Assistant, result[3].Role);
Assert.Equal("", result[3].Text);
Assert.Contains("secondAgent", result[3].AuthorName);
Assert.Equal(ChatRole.User, result[4].Role);
Assert.Equal("", result[4].Text);
// Rejected tool call
Assert.Equal(ChatRole.Assistant, result[5].Role);
Assert.Equal("", result[5].Text);
Assert.Contains("secondAgent", result[5].AuthorName);
Assert.Equal(ChatRole.Tool, result[6].Role);
Assert.Contains("secondAgent", result[6].AuthorName);
// Handoff invocation
Assert.Equal(ChatRole.Assistant, result[7].Role);
Assert.Equal("", result[7].Text);
Assert.Contains("secondAgent", result[7].AuthorName);
Assert.Equal(ChatRole.Tool, result[8].Role);
Assert.Contains("secondAgent", result[8].AuthorName);
Assert.Equal(ChatRole.Assistant, result[9].Role);
Assert.Equal("Hello from agent3", result[9].Text);
Assert.Contains("thirdAgent", result[9].AuthorName);
static bool SomeOtherFunction() => true;
}
[Fact]
public async Task Handoffs_TwoTransfers_SecondAgentToolCall_ResponseServedByThirdAgentAsync()
{
var initialAgent = new ChatClientAgent(new MockChatClient((messages, options) =>
{
ChatMessage message = Assert.Single(messages);
Assert.Equal("abc", Assert.IsType<TextContent>(Assert.Single(message.Contents)).Text);
string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name;
Assert.NotNull(transferFuncName);
// Only a handoff function call.
return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", transferFuncName)]));
}), name: "initialAgent");
bool secondAgentInvoked = false;
const string SomeOtherFunctionName = "SomeOtherFunction";
const string SomeOtherFunctionCallId = "call2first";
JsonElement otherFunctionSchema = AIFunctionFactory.Create(() => true).JsonSchema;
AIFunctionDeclaration someOtherFunction = AIFunctionFactory.CreateDeclaration(SomeOtherFunctionName, "Another function", otherFunctionSchema);
var secondAgent = new ChatClientAgent(new MockChatClient((messages, options) =>
{
if (!secondAgentInvoked)
{
secondAgentInvoked = true;
return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent(SomeOtherFunctionCallId, SomeOtherFunctionName)]));
}
// Second agent should receive the conversation so far (including previous assistant + tool messages eventually).
string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name;
Assert.NotNull(transferFuncName);
return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call2", transferFuncName)]));
}), name: "secondAgent", description: "The second agent", tools: [someOtherFunction]);
var thirdAgent = new ChatClientAgent(new MockChatClient((messages, options) =>
new(new ChatMessage(ChatRole.Assistant, "Hello from agent3"))),
name: "thirdAgent",
description: "The third / final agent");
var workflow =
AgentWorkflowBuilder.CreateHandoffBuilderWith(initialAgent)
.WithHandoff(initialAgent, secondAgent)
.WithHandoff(secondAgent, thirdAgent)
.Build();
CheckpointManager checkpointManager = CheckpointManager.CreateInMemory();
const ExecutionEnvironment Environment = ExecutionEnvironment.InProcess_Lockstep;
(string updateText, List<ChatMessage>? result, CheckpointInfo? lastCheckpoint, List<RequestInfoEvent> requests) =
await RunWorkflowCheckpointedAsync(workflow, [new ChatMessage(ChatRole.User, "abc")], Environment, checkpointManager);
Assert.Null(result);
Assert.NotNull(requests);
requests.Should().HaveCount(1);
ExternalRequest request = requests[0].Request;
FunctionCallContent functionCall = request.Data.As<FunctionCallContent>().Should().NotBeNull()
.And.Subject.As<FunctionCallContent>();
functionCall.CallId.Should().Be(SomeOtherFunctionCallId);
functionCall.Name.Should().Be(SomeOtherFunctionName);
ExternalResponse response = request.CreateResponse(new FunctionResultContent(functionCall.CallId, true));
(updateText, result, _, requests) =
await RunWorkflowCheckpointedAsync(workflow, response, Environment, checkpointManager, lastCheckpoint);
Assert.Equal("Hello from agent3", updateText);
Assert.NotNull(result);
// User + (assistant empty + tool) for each of first two agents + final assistant with text.
Assert.Equal(8, result.Count);
Assert.Equal(ChatRole.User, result[0].Role);
Assert.Equal("abc", result[0].Text);
Assert.Equal(ChatRole.Assistant, result[1].Role);
Assert.Equal("", result[1].Text);
Assert.Contains("initialAgent", result[1].AuthorName);
Assert.Equal(ChatRole.Tool, result[2].Role);
Assert.Contains("initialAgent", result[2].AuthorName);
// Non-handoff tool invocation
Assert.Equal(ChatRole.Assistant, result[3].Role);
Assert.Equal("", result[3].Text);
Assert.Contains("secondAgent", result[3].AuthorName);
Assert.Equal(ChatRole.Tool, result[4].Role);
Assert.Contains("secondAgent", result[4].AuthorName);
// Handoff invocation
Assert.Equal(ChatRole.Assistant, result[5].Role);
Assert.Equal("", result[5].Text);
Assert.Contains("secondAgent", result[5].AuthorName);
Assert.Equal(ChatRole.Tool, result[6].Role);
Assert.Contains("secondAgent", result[6].AuthorName);
Assert.Equal(ChatRole.Assistant, result[7].Role);
Assert.Equal("Hello from agent3", result[7].Text);
Assert.Contains("thirdAgent", result[7].AuthorName);
}
[Theory]
[InlineData(1)]
[InlineData(2)]
@@ -651,7 +878,7 @@ public class AgentWorkflowBuilderTests
for (int iter = 0; iter < 3; iter++)
{
const string UserInput = "abc";
(string updateText, List<ChatMessage>? result, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, UserInput)]);
(string updateText, List<ChatMessage>? result, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, UserInput)]);
Assert.NotNull(result);
Assert.Equal(maxIterations + 1, result.Count);
@@ -832,7 +1059,7 @@ public class AgentWorkflowBuilderTests
Assert.Equal(1, specialistCallCount); // specialist NOT called
}
private sealed record WorkflowRunResult(string UpdateText, List<ChatMessage>? Result, CheckpointInfo? LastCheckpoint);
private sealed record WorkflowRunResult(string UpdateText, List<ChatMessage>? Result, CheckpointInfo? LastCheckpoint, List<RequestInfoEvent> PendingRequests);
private static Task<WorkflowRunResult> RunWorkflowCheckpointedAsync(
Workflow workflow, List<ChatMessage> input, ExecutionEnvironment executionEnvironment, CheckpointManager checkpointManager, CheckpointInfo? fromCheckpoint = null)
@@ -843,6 +1070,15 @@ public class AgentWorkflowBuilderTests
return RunWorkflowCheckpointedAsync(workflow, input, environment, fromCheckpoint);
}
private static Task<WorkflowRunResult> RunWorkflowCheckpointedAsync(
Workflow workflow, ExternalResponse response, ExecutionEnvironment executionEnvironment, CheckpointManager checkpointManager, CheckpointInfo? fromCheckpoint = null)
{
InProcessExecutionEnvironment environment = executionEnvironment.ToWorkflowExecutionEnvironment()
.WithCheckpointing(checkpointManager);
return RunWorkflowCheckpointedAsync(workflow, response, environment, fromCheckpoint);
}
private static async Task<WorkflowRunResult> RunWorkflowCheckpointedAsync(
Workflow workflow, List<ChatMessage> input, InProcessExecutionEnvironment environment, CheckpointInfo? fromCheckpoint = null)
{
@@ -853,15 +1089,39 @@ public class AgentWorkflowBuilderTests
await run.TrySendMessageAsync(input);
await run.TrySendMessageAsync(new TurnToken(emitEvents: true));
return await ProcessWorkflowRunAsync(run);
}
private static async Task<WorkflowRunResult> RunWorkflowCheckpointedAsync(
Workflow workflow, ExternalResponse response, InProcessExecutionEnvironment environment, CheckpointInfo? fromCheckpoint = null)
{
await using StreamingRun run =
fromCheckpoint != null ? await environment.ResumeStreamingAsync(workflow, fromCheckpoint)
: await environment.OpenStreamingAsync(workflow);
await run.SendResponseAsync(response);
return await ProcessWorkflowRunAsync(run);
}
private static async Task<WorkflowRunResult> ProcessWorkflowRunAsync(StreamingRun run)
{
StringBuilder sb = new();
WorkflowOutputEvent? output = null;
CheckpointInfo? lastCheckpoint = null;
await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false))
List<RequestInfoEvent> pendingRequests = [];
await foreach (WorkflowEvent evt in run.WatchStreamAsync(blockOnPendingRequest: false).ConfigureAwait(false))
{
switch (evt)
{
case AgentResponseUpdateEvent executorComplete:
sb.Append(executorComplete.Data);
case AgentResponseUpdateEvent responseUpdate:
sb.Append(responseUpdate.Data);
break;
case RequestInfoEvent requestInfo:
pendingRequests.Add(requestInfo);
break;
case WorkflowOutputEvent e:
@@ -878,7 +1138,7 @@ public class AgentWorkflowBuilderTests
}
}
return new(sb.ToString(), output?.As<List<ChatMessage>>(), lastCheckpoint);
return new(sb.ToString(), output?.As<List<ChatMessage>>(), lastCheckpoint, pendingRequests);
}
private static Task<WorkflowRunResult> RunWorkflowAsync(
@@ -29,7 +29,7 @@ public class HandoffAgentExecutorTests : AIAgentHostingExecutorTestsBase
emitAgentResponseUpdateEvents: executorSetting,
HandoffToolCallFilteringBehavior.None);
HandoffAgentExecutor executor = new(agent, options);
HandoffAgentExecutor executor = new(agent, [], options);
testContext.ConfigureExecutor(executor);
// Act
@@ -57,7 +57,7 @@ public class HandoffAgentExecutorTests : AIAgentHostingExecutorTestsBase
emitAgentResponseUpdateEvents: false,
HandoffToolCallFilteringBehavior.None);
HandoffAgentExecutor executor = new(agent, options);
HandoffAgentExecutor executor = new(agent, [], options);
testContext.ConfigureExecutor(executor);
// Act