mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2aa792fc36 | ||
|
|
218ad88f19 | ||
|
|
9e3983e547 | ||
|
|
383a2afca2 | ||
|
|
0402b1aac4 | ||
|
|
485af07b8c | ||
|
|
64c68ca857 | ||
|
|
98e17764a4 | ||
|
|
7bb0feca59 | ||
|
|
f183f888a3 | ||
|
|
3c31ac28b5 | ||
|
|
1b95e8585d | ||
|
|
448f46aff2 | ||
|
|
b89adb280b | ||
|
|
9ce2aafff7 | ||
|
|
913397492f | ||
|
|
952e685e17 | ||
|
|
b1fb63eb81 | ||
|
|
76fe7319e0 | ||
|
|
39b560f83c | ||
|
|
a98a585afb | ||
|
|
615ef9049f | ||
|
|
3e864cdb4c | ||
|
|
14d2ab3262 | ||
|
|
e5f7b9c260 | ||
|
|
4a36f10888 | ||
|
|
d4036c5aef | ||
|
|
790a759dbf | ||
|
|
a172313ec3 | ||
|
|
e8757cebde | ||
|
|
eea543e697 | ||
|
|
4dbe696e0e |
@@ -48,7 +48,8 @@ jobs:
|
||||
.
|
||||
.github
|
||||
dotnet
|
||||
workflow-samples
|
||||
python
|
||||
declarative-agents
|
||||
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@v5.2.0
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.ComponentModel;
|
||||
using AGUIServer;
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI.Hosting;
|
||||
using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI.Chat;
|
||||
@@ -13,11 +14,11 @@ builder.Services.AddHttpClient().AddLogging();
|
||||
builder.Services.ConfigureHttpJsonOptions(options => options.SerializerOptions.TypeInfoResolverChain.Add(AGUIServerSerializerContext.Default));
|
||||
builder.Services.AddAGUI();
|
||||
|
||||
WebApplication app = builder.Build();
|
||||
|
||||
string endpoint = builder.Configuration["AZURE_OPENAI_ENDPOINT"] ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
string deploymentName = builder.Configuration["AZURE_OPENAI_DEPLOYMENT_NAME"] ?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT_NAME is not set.");
|
||||
|
||||
const string AgentName = "AGUIAssistant";
|
||||
|
||||
// Create the AI agent with tools
|
||||
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
|
||||
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
|
||||
@@ -27,7 +28,7 @@ var agent = new AzureOpenAIClient(
|
||||
new DefaultAzureCredential())
|
||||
.GetChatClient(deploymentName)
|
||||
.AsAIAgent(
|
||||
name: "AGUIAssistant",
|
||||
name: AgentName,
|
||||
tools: [
|
||||
AIFunctionFactory.Create(
|
||||
() => DateTimeOffset.UtcNow,
|
||||
@@ -48,7 +49,15 @@ var agent = new AzureOpenAIClient(
|
||||
AGUIServerSerializerContext.Default.Options)
|
||||
]);
|
||||
|
||||
// Register the agent with the host and configure it to use an in-memory session store
|
||||
// so that conversation state is maintained across requests. In production, you may want to use a persistent session store.
|
||||
builder
|
||||
.AddAIAgent(AgentName, (_, _) => agent)
|
||||
.WithInMemorySessionStore();
|
||||
|
||||
WebApplication app = builder.Build();
|
||||
|
||||
// Map the AG-UI agent endpoint
|
||||
app.MapAGUI("/", agent);
|
||||
app.MapAGUI(AgentName, "/");
|
||||
|
||||
await app.RunAsync();
|
||||
|
||||
@@ -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>
|
||||
+77
-3
@@ -1,9 +1,12 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
@@ -21,6 +24,42 @@ namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore;
|
||||
/// </summary>
|
||||
public static class AGUIEndpointRouteBuilderExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps an AG-UI agent endpoint using an agent registered in dependency injection via <see cref="IHostedAgentBuilder"/>.
|
||||
/// </summary>
|
||||
/// <param name="endpoints">The endpoint route builder.</param>
|
||||
/// <param name="agentBuilder">The hosted agent builder that identifies the agent registration.</param>
|
||||
/// <param name="pattern">The URL pattern for the endpoint.</param>
|
||||
/// <returns>An <see cref="IEndpointConventionBuilder"/> for the mapped endpoint.</returns>
|
||||
public static IEndpointConventionBuilder MapAGUI(
|
||||
this IEndpointRouteBuilder endpoints,
|
||||
IHostedAgentBuilder agentBuilder,
|
||||
[StringSyntax("route")] string pattern)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(endpoints);
|
||||
ArgumentNullException.ThrowIfNull(agentBuilder);
|
||||
return endpoints.MapAGUI(agentBuilder.Name, pattern);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps an AG-UI agent endpoint using a named agent registered in dependency injection.
|
||||
/// </summary>
|
||||
/// <param name="endpoints">The endpoint route builder.</param>
|
||||
/// <param name="agentName">The name of the keyed agent registration to resolve from dependency injection.</param>
|
||||
/// <param name="pattern">The URL pattern for the endpoint.</param>
|
||||
/// <returns>An <see cref="IEndpointConventionBuilder"/> for the mapped endpoint.</returns>
|
||||
public static IEndpointConventionBuilder MapAGUI(
|
||||
this IEndpointRouteBuilder endpoints,
|
||||
string agentName,
|
||||
[StringSyntax("route")] string pattern)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(endpoints);
|
||||
ArgumentNullException.ThrowIfNull(agentName);
|
||||
|
||||
var agent = endpoints.ServiceProvider.GetRequiredKeyedService<AIAgent>(agentName);
|
||||
return endpoints.MapAGUI(pattern, agent);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps an AG-UI agent endpoint.
|
||||
/// </summary>
|
||||
@@ -28,11 +67,24 @@ public static class AGUIEndpointRouteBuilderExtensions
|
||||
/// <param name="pattern">The URL pattern for the endpoint.</param>
|
||||
/// <param name="aiAgent">The agent instance.</param>
|
||||
/// <returns>An <see cref="IEndpointConventionBuilder"/> for the mapped endpoint.</returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// If an <see cref="AgentSessionStore"/> is registered in dependency injection keyed by the agent's name,
|
||||
/// it will be used to persist conversation sessions across requests using the AG-UI thread ID as the
|
||||
/// conversation identifier. If no session store is registered, sessions are ephemeral (not persisted).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static IEndpointConventionBuilder MapAGUI(
|
||||
this IEndpointRouteBuilder endpoints,
|
||||
[StringSyntax("route")] string pattern,
|
||||
AIAgent aiAgent)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(endpoints);
|
||||
ArgumentNullException.ThrowIfNull(aiAgent);
|
||||
|
||||
var agentSessionStore = endpoints.ServiceProvider.GetKeyedService<AgentSessionStore>(aiAgent.Name);
|
||||
var hostAgent = new AIHostAgent(aiAgent, agentSessionStore ?? new NoopAgentSessionStore());
|
||||
|
||||
return endpoints.MapPost(pattern, async ([FromBody] RunAgentInput? input, HttpContext context, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (input is null)
|
||||
@@ -63,21 +115,43 @@ public static class AGUIEndpointRouteBuilderExtensions
|
||||
}
|
||||
};
|
||||
|
||||
var threadId = string.IsNullOrWhiteSpace(input.ThreadId) ? Guid.NewGuid().ToString("N") : input.ThreadId;
|
||||
var session = await hostAgent.GetOrCreateSessionAsync(threadId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Run the agent and convert to AG-UI events
|
||||
var events = aiAgent.RunStreamingAsync(
|
||||
var events = hostAgent.RunStreamingAsync(
|
||||
messages,
|
||||
session: session,
|
||||
options: runOptions,
|
||||
cancellationToken: cancellationToken)
|
||||
.AsChatResponseUpdatesAsync()
|
||||
.FilterServerToolsFromMixedToolInvocationsAsync(clientTools, cancellationToken)
|
||||
.AsAGUIEventStreamAsync(
|
||||
input.ThreadId,
|
||||
threadId,
|
||||
input.RunId,
|
||||
jsonSerializerOptions,
|
||||
cancellationToken);
|
||||
|
||||
// Wrap the event stream to save the session after streaming completes
|
||||
var eventsWithSessionSave = SaveSessionAfterStreamingAsync(events, hostAgent, threadId, session, cancellationToken);
|
||||
|
||||
var sseLogger = context.RequestServices.GetRequiredService<ILogger<AGUIServerSentEventsResult>>();
|
||||
return new AGUIServerSentEventsResult(events, sseLogger);
|
||||
return new AGUIServerSentEventsResult(eventsWithSessionSave, sseLogger);
|
||||
});
|
||||
}
|
||||
|
||||
private static async IAsyncEnumerable<BaseEvent> SaveSessionAfterStreamingAsync(
|
||||
IAsyncEnumerable<BaseEvent> events,
|
||||
AIHostAgent hostAgent,
|
||||
string threadId,
|
||||
AgentSession session,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
await foreach (BaseEvent evt in events.ConfigureAwait(false))
|
||||
{
|
||||
yield return evt;
|
||||
}
|
||||
|
||||
await hostAgent.SaveSessionAsync(threadId, session, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -19,6 +19,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI.Hosting\Microsoft.Agents.AI.Hosting.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -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>
|
||||
-39
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+78
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+4
-4
@@ -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);
|
||||
}
|
||||
+8
-8
@@ -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<SpecialSkill></c>).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <example>
|
||||
/// <code>
|
||||
/// public class PdfFormatterSkill : AgentClassSkill
|
||||
/// // Attribute-based approach (recommended, AOT-compatible):
|
||||
/// public class PdfFormatterSkill : AgentClassSkill<PdfFormatterSkill>
|
||||
/// {
|
||||
/// public override AgentSkillFrontmatter Frontmatter { get; } = new("pdf-formatter", "Format documents as PDF.");
|
||||
/// protected override string Instructions => "Use this skill to format documents...";
|
||||
///
|
||||
/// [AgentSkillResource("template")]
|
||||
/// public string Template => "Use this template...";
|
||||
///
|
||||
/// [AgentSkillScript("format-pdf")]
|
||||
/// private static string FormatPdf(string content) => content;
|
||||
/// }
|
||||
///
|
||||
/// // Explicit override approach (AOT-compatible):
|
||||
/// public class ExplicitPdfFormatterSkill : AgentClassSkill<ExplicitPdfFormatterSkill>
|
||||
/// {
|
||||
/// private IReadOnlyList<AgentSkillResource>? _resources;
|
||||
/// private IReadOnlyList<AgentSkillScript>? _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<MySkill>
|
||||
/// {
|
||||
/// public override AgentSkillFrontmatter Frontmatter { get; } = new("my-skill", "A skill.");
|
||||
/// protected override string Instructions => "Use this skill to do something.";
|
||||
///
|
||||
/// [AgentSkillResource("reference-data")]
|
||||
/// [Description("Some reference content for the skill.")]
|
||||
/// public string ReferenceData => "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<MySkill>
|
||||
/// {
|
||||
/// public override AgentSkillFrontmatter Frontmatter { get; } = new("my-skill", "A skill.");
|
||||
/// protected override string Instructions => "Use this skill to do something.";
|
||||
///
|
||||
/// [AgentSkillScript("do-something")]
|
||||
/// [Description("Converts the input to upper case.")]
|
||||
/// private static string DoSomething(string input) => 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; }
|
||||
}
|
||||
+1
@@ -21,6 +21,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Hosting\Microsoft.Agents.AI.Hosting.csproj" />
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj" />
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.AGUI\Microsoft.Agents.AI.AGUI.csproj" />
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
|
||||
+226
@@ -0,0 +1,226 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Net.Http;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Agents.AI.AGUI;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting.Server;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests;
|
||||
|
||||
public sealed class SessionPersistenceTests : IAsyncDisposable
|
||||
{
|
||||
private WebApplication? _app;
|
||||
private HttpClient? _client;
|
||||
|
||||
[Fact]
|
||||
public async Task MultiTurnWithSessionStore_PersistsSessionAcrossRequestsAsync()
|
||||
{
|
||||
// Arrange - use hosting DI pattern with InMemorySessionStore.
|
||||
// FakeSessionAgent tracks turn count in session StateBag so we can verify
|
||||
// that state survives the serialization round-trip through the session store.
|
||||
await this.SetupTestServerWithSessionStoreAsync();
|
||||
var chatClient = new AGUIChatClient(this._client!, "", null);
|
||||
AIAgent agent = chatClient.AsAIAgent(instructions: null, name: "assistant", description: "Sample assistant", tools: []);
|
||||
ChatClientAgentSession session = (ChatClientAgentSession)await agent.CreateSessionAsync();
|
||||
|
||||
// Act - First turn
|
||||
ChatMessage firstUserMessage = new(ChatRole.User, "First message");
|
||||
List<AgentResponseUpdate> firstTurnUpdates = [];
|
||||
await foreach (AgentResponseUpdate update in agent.RunStreamingAsync([firstUserMessage], session, new AgentRunOptions(), CancellationToken.None))
|
||||
{
|
||||
firstTurnUpdates.Add(update);
|
||||
}
|
||||
|
||||
// Act - Second turn (same thread ID to test session persistence)
|
||||
ChatMessage secondUserMessage = new(ChatRole.User, "Second message");
|
||||
List<AgentResponseUpdate> secondTurnUpdates = [];
|
||||
await foreach (AgentResponseUpdate update in agent.RunStreamingAsync([secondUserMessage], session, new AgentRunOptions(), CancellationToken.None))
|
||||
{
|
||||
secondTurnUpdates.Add(update);
|
||||
}
|
||||
|
||||
// Assert - Verify turn count proves session state was persisted.
|
||||
// If session persistence were broken, both turns would return "Turn 1"
|
||||
// because a fresh session (with turn count 0) would be created each time.
|
||||
AgentResponse firstResponse = firstTurnUpdates.ToAgentResponse();
|
||||
firstResponse.Messages.Should().HaveCount(1);
|
||||
firstResponse.Messages[0].Role.Should().Be(ChatRole.Assistant);
|
||||
firstResponse.Messages[0].Text.Should().Contain("Turn 1:");
|
||||
|
||||
AgentResponse secondResponse = secondTurnUpdates.ToAgentResponse();
|
||||
secondResponse.Messages.Should().HaveCount(1);
|
||||
secondResponse.Messages[0].Role.Should().Be(ChatRole.Assistant);
|
||||
secondResponse.Messages[0].Text.Should().Contain("Turn 2:");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MapAGUI_WithAgentName_StreamsResponseCorrectlyAsync()
|
||||
{
|
||||
// Arrange - use the MapAGUI(agentName, pattern) overload via hosting DI
|
||||
await this.SetupTestServerWithSessionStoreAsync();
|
||||
var chatClient = new AGUIChatClient(this._client!, "", null);
|
||||
AIAgent agent = chatClient.AsAIAgent(instructions: null, name: "assistant", description: "Sample assistant", tools: []);
|
||||
ChatClientAgentSession session = (ChatClientAgentSession)await agent.CreateSessionAsync();
|
||||
ChatMessage userMessage = new(ChatRole.User, "hello");
|
||||
|
||||
List<AgentResponseUpdate> updates = [];
|
||||
|
||||
// Act
|
||||
await foreach (AgentResponseUpdate update in agent.RunStreamingAsync([userMessage], session, new AgentRunOptions(), CancellationToken.None))
|
||||
{
|
||||
updates.Add(update);
|
||||
}
|
||||
|
||||
// Assert
|
||||
updates.Should().NotBeEmpty();
|
||||
updates.Should().AllSatisfy(u => u.Role.Should().Be(ChatRole.Assistant));
|
||||
|
||||
AgentResponse response = updates.ToAgentResponse();
|
||||
response.Messages.Should().HaveCount(1);
|
||||
response.Messages[0].Role.Should().Be(ChatRole.Assistant);
|
||||
response.Messages[0].Text.Should().Be("Turn 1: Hello from session agent!");
|
||||
}
|
||||
|
||||
private async Task SetupTestServerWithSessionStoreAsync()
|
||||
{
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||
builder.WebHost.UseTestServer();
|
||||
|
||||
builder.Services.AddAGUI();
|
||||
|
||||
// Register agent using hosting DI pattern with InMemorySessionStore
|
||||
builder.Services.AddAIAgent("session-test-agent", (_, name) => new FakeSessionAgent(name))
|
||||
.WithInMemorySessionStore();
|
||||
|
||||
this._app = builder.Build();
|
||||
|
||||
// Use the agentName overload of MapAGUI
|
||||
this._app.MapAGUI("session-test-agent", "/agent");
|
||||
|
||||
await this._app.StartAsync();
|
||||
|
||||
TestServer testServer = this._app.Services.GetRequiredService<IServer>() as TestServer
|
||||
?? throw new InvalidOperationException("TestServer not found");
|
||||
|
||||
this._client = testServer.CreateClient();
|
||||
this._client.BaseAddress = new Uri("http://localhost/agent");
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
this._client?.Dispose();
|
||||
if (this._app != null)
|
||||
{
|
||||
await this._app.DisposeAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated via dependency injection")]
|
||||
internal sealed class FakeSessionAgent : AIAgent
|
||||
{
|
||||
private readonly string _name;
|
||||
|
||||
public FakeSessionAgent(string name)
|
||||
{
|
||||
this._name = name;
|
||||
}
|
||||
|
||||
protected override string? IdCore => this._name;
|
||||
|
||||
public override string? Name => this._name;
|
||||
|
||||
public override string? Description => "A fake agent with session support for testing";
|
||||
|
||||
protected override ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default) =>
|
||||
new(new FakeSessionAgentSession());
|
||||
|
||||
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) =>
|
||||
new(serializedState.Deserialize<FakeSessionAgentSession>(jsonSerializerOptions)!);
|
||||
|
||||
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (session is not FakeSessionAgentSession fakeSession)
|
||||
{
|
||||
throw new InvalidOperationException($"The provided session type '{session.GetType().Name}' is not compatible with this agent.");
|
||||
}
|
||||
|
||||
return new(JsonSerializer.SerializeToElement(fakeSession, jsonSerializerOptions));
|
||||
}
|
||||
|
||||
protected override async Task<AgentResponse> RunCoreAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session = null,
|
||||
AgentRunOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
List<AgentResponseUpdate> updates = [];
|
||||
await foreach (AgentResponseUpdate update in this.RunStreamingAsync(messages, session, options, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
updates.Add(update);
|
||||
}
|
||||
|
||||
return updates.ToAgentResponse();
|
||||
}
|
||||
|
||||
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session = null,
|
||||
AgentRunOptions? options = null,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Track turn count in session state to enable persistence verification.
|
||||
// If the session store works correctly, the turn count increments across requests.
|
||||
int turnCount = 1;
|
||||
if (session != null)
|
||||
{
|
||||
var counter = session.StateBag.GetValue<TurnCounter>("turnCounter");
|
||||
turnCount = (counter?.Count ?? 0) + 1;
|
||||
session.StateBag.SetValue("turnCounter", new TurnCounter { Count = turnCount });
|
||||
}
|
||||
|
||||
string messageId = Guid.NewGuid().ToString("N");
|
||||
string prefix = $"Turn {turnCount}: ";
|
||||
|
||||
foreach (string chunk in new[] { prefix, "Hello", " ", "from", " ", "session", " ", "agent", "!" })
|
||||
{
|
||||
yield return new AgentResponseUpdate
|
||||
{
|
||||
MessageId = messageId,
|
||||
Role = ChatRole.Assistant,
|
||||
Contents = [new TextContent(chunk)]
|
||||
};
|
||||
|
||||
await Task.Yield();
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class TurnCounter
|
||||
{
|
||||
public int Count { get; set; }
|
||||
}
|
||||
|
||||
private sealed class FakeSessionAgentSession : AgentSession
|
||||
{
|
||||
public FakeSessionAgentSession()
|
||||
{
|
||||
}
|
||||
|
||||
[JsonConstructor]
|
||||
public FakeSessionAgentSession(AgentSessionStateBag stateBag) : base(stateBag)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
+191
@@ -14,6 +14,7 @@ using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
@@ -31,6 +32,7 @@ public sealed class AGUIEndpointRouteBuilderExtensionsTests
|
||||
// Arrange
|
||||
Mock<IEndpointRouteBuilder> endpointsMock = new();
|
||||
Mock<IServiceProvider> serviceProviderMock = new();
|
||||
serviceProviderMock.As<IKeyedServiceProvider>();
|
||||
|
||||
endpointsMock.Setup(e => e.ServiceProvider).Returns(serviceProviderMock.Object);
|
||||
endpointsMock.Setup(e => e.DataSources).Returns([]);
|
||||
@@ -45,6 +47,155 @@ public sealed class AGUIEndpointRouteBuilderExtensionsTests
|
||||
Assert.NotNull(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapAGUI_WithAgentName_ResolvesKeyedAgentFromDI()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IEndpointRouteBuilder> endpointsMock = new();
|
||||
Mock<IServiceProvider> serviceProviderMock = new();
|
||||
AIAgent agent = new NamedTestAgent();
|
||||
|
||||
serviceProviderMock.As<IKeyedServiceProvider>()
|
||||
.Setup(sp => sp.GetRequiredKeyedService(typeof(AIAgent), "test-agent"))
|
||||
.Returns(agent);
|
||||
|
||||
endpointsMock.Setup(e => e.ServiceProvider).Returns(serviceProviderMock.Object);
|
||||
endpointsMock.Setup(e => e.DataSources).Returns([]);
|
||||
|
||||
// Act
|
||||
IEndpointConventionBuilder? result = endpointsMock.Object.MapAGUI("test-agent", "/api/agent");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
serviceProviderMock.As<IKeyedServiceProvider>()
|
||||
.Verify(sp => sp.GetRequiredKeyedService(typeof(AIAgent), "test-agent"), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapAGUI_WithHostedAgentBuilder_ResolvesAgentByBuilderName()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IEndpointRouteBuilder> endpointsMock = new();
|
||||
Mock<IServiceProvider> serviceProviderMock = new();
|
||||
Mock<IHostedAgentBuilder> agentBuilderMock = new();
|
||||
AIAgent agent = new NamedTestAgent();
|
||||
|
||||
agentBuilderMock.Setup(b => b.Name).Returns("test-agent");
|
||||
|
||||
serviceProviderMock.As<IKeyedServiceProvider>()
|
||||
.Setup(sp => sp.GetRequiredKeyedService(typeof(AIAgent), "test-agent"))
|
||||
.Returns(agent);
|
||||
|
||||
endpointsMock.Setup(e => e.ServiceProvider).Returns(serviceProviderMock.Object);
|
||||
endpointsMock.Setup(e => e.DataSources).Returns([]);
|
||||
|
||||
// Act
|
||||
IEndpointConventionBuilder? result = endpointsMock.Object.MapAGUI(agentBuilderMock.Object, "/api/agent");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
serviceProviderMock.As<IKeyedServiceProvider>()
|
||||
.Verify(sp => sp.GetRequiredKeyedService(typeof(AIAgent), "test-agent"), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapAGUI_WithAgent_ResolvesSessionStoreFromDI()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IEndpointRouteBuilder> endpointsMock = new();
|
||||
Mock<IServiceProvider> serviceProviderMock = new();
|
||||
Mock<AgentSessionStore> sessionStoreMock = new();
|
||||
AIAgent agent = new NamedTestAgent();
|
||||
|
||||
serviceProviderMock.As<IKeyedServiceProvider>()
|
||||
.Setup(sp => sp.GetKeyedService(typeof(AgentSessionStore), "test-agent"))
|
||||
.Returns(sessionStoreMock.Object);
|
||||
|
||||
endpointsMock.Setup(e => e.ServiceProvider).Returns(serviceProviderMock.Object);
|
||||
endpointsMock.Setup(e => e.DataSources).Returns([]);
|
||||
|
||||
// Act
|
||||
IEndpointConventionBuilder? result = endpointsMock.Object.MapAGUI("/api/agent", agent);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
serviceProviderMock.As<IKeyedServiceProvider>()
|
||||
.Verify(sp => sp.GetKeyedService(typeof(AgentSessionStore), "test-agent"), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapAGUI_WithoutSessionStore_FallsBackToNoopStore()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IEndpointRouteBuilder> endpointsMock = new();
|
||||
Mock<IServiceProvider> serviceProviderMock = new();
|
||||
AIAgent agent = new TestAgent();
|
||||
|
||||
// No session store registered - IKeyedServiceProvider returns null by default
|
||||
serviceProviderMock.As<IKeyedServiceProvider>();
|
||||
|
||||
endpointsMock.Setup(e => e.ServiceProvider).Returns(serviceProviderMock.Object);
|
||||
endpointsMock.Setup(e => e.DataSources).Returns([]);
|
||||
|
||||
// Act - should not throw (falls back to NoopAgentSessionStore)
|
||||
IEndpointConventionBuilder? result = endpointsMock.Object.MapAGUI("/api/agent", agent);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapAGUI_WithNullEndpoints_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
AIAgent agent = new TestAgent();
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
AGUIEndpointRouteBuilderExtensions.MapAGUI(null!, "/api/agent", agent));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapAGUI_WithNullAgent_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IEndpointRouteBuilder> endpointsMock = new();
|
||||
Mock<IServiceProvider> serviceProviderMock = new();
|
||||
serviceProviderMock.As<IKeyedServiceProvider>();
|
||||
endpointsMock.Setup(e => e.ServiceProvider).Returns(serviceProviderMock.Object);
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
endpointsMock.Object.MapAGUI("/api/agent", (AIAgent)null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapAGUI_WithNullAgentName_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IEndpointRouteBuilder> endpointsMock = new();
|
||||
Mock<IServiceProvider> serviceProviderMock = new();
|
||||
serviceProviderMock.As<IKeyedServiceProvider>();
|
||||
endpointsMock.Setup(e => e.ServiceProvider).Returns(serviceProviderMock.Object);
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
endpointsMock.Object.MapAGUI((string)null!, "/api/agent"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapAGUI_WithNullAgentBuilder_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IEndpointRouteBuilder> endpointsMock = new();
|
||||
Mock<IServiceProvider> serviceProviderMock = new();
|
||||
endpointsMock.Setup(e => e.ServiceProvider).Returns(serviceProviderMock.Object);
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
endpointsMock.Object.MapAGUI((IHostedAgentBuilder)null!, "/api/agent"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MapAGUIAgent_WithNullOrInvalidInput_Returns400BadRequestAsync()
|
||||
{
|
||||
@@ -556,4 +707,44 @@ public sealed class AGUIEndpointRouteBuilderExtensionsTests
|
||||
yield return new AgentResponseUpdate(new ChatResponseUpdate(ChatRole.Assistant, "Test response"));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class NamedTestAgent : AIAgent
|
||||
{
|
||||
protected override string? IdCore => "test-agent";
|
||||
|
||||
public override string? Name => "test-agent";
|
||||
|
||||
public override string? Description => "Named test agent";
|
||||
|
||||
protected override ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default) =>
|
||||
new(new TestAgentSession());
|
||||
|
||||
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) =>
|
||||
new(serializedState.Deserialize<TestAgentSession>(jsonSerializerOptions)!);
|
||||
|
||||
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (session is not TestAgentSession testSession)
|
||||
{
|
||||
throw new InvalidOperationException($"The provided session type '{session.GetType().Name}' is not compatible with this agent. Only sessions of type '{nameof(TestAgentSession)}' can be serialized by this agent.");
|
||||
}
|
||||
|
||||
return new(JsonSerializer.SerializeToElement(testSession, jsonSerializerOptions));
|
||||
}
|
||||
|
||||
protected override Task<AgentResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session = null,
|
||||
AgentRunOptions? options = null,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
yield return new AgentResponseUpdate(new ChatResponseUpdate(ChatRole.Assistant, "Test response"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+18
-18
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+55
@@ -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";
|
||||
}
|
||||
|
||||
+29
@@ -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);
|
||||
}
|
||||
}
|
||||
+29
@@ -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;
|
||||
|
||||
|
||||
+65
-65
@@ -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.
|
||||
|
||||
+272
-12
@@ -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
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
],
|
||||
"words": [
|
||||
"aeiou",
|
||||
"agentserver",
|
||||
"agui",
|
||||
"aiplatform",
|
||||
"azuredocindex",
|
||||
|
||||
@@ -38,6 +38,9 @@ COPILOTSTUDIOAGENT__AGENTAPPID=""
|
||||
# Anthropic
|
||||
ANTHROPIC_API_KEY=""
|
||||
ANTHROPIC_MODEL=""
|
||||
# Google Gemini
|
||||
GEMINI_API_KEY=""
|
||||
GEMINI_MODEL=""
|
||||
# Ollama
|
||||
OLLAMA_ENDPOINT=""
|
||||
OLLAMA_MODEL=""
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
---
|
||||
name: python-feature-lifecycle
|
||||
description: >
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
fail_fast: true
|
||||
exclude: ^scripts/
|
||||
repos:
|
||||
- repo: builtin
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v6.0.0
|
||||
hooks:
|
||||
- id: check-toml
|
||||
name: Check TOML files
|
||||
@@ -34,9 +35,6 @@ repos:
|
||||
- id: no-commit-to-branch
|
||||
name: Protect main branch
|
||||
args: [--branch, main]
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v6.0.0
|
||||
hooks:
|
||||
- id: check-ast
|
||||
name: Check Valid Python Samples
|
||||
types: ["python"]
|
||||
|
||||
+30
-1
@@ -7,6 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
- **agent-framework-azure-cosmos**: [BREAKING] `CosmosCheckpointStorage` now uses restricted pickle deserialization by default, matching `FileCheckpointStorage` behavior. If your checkpoints contain application-defined types, pass them via `allowed_checkpoint_types=["my_app.models:MyState"]`. ([#5200](https://github.com/microsoft/agent-framework/issues/5200))
|
||||
|
||||
## [1.0.1] - 2026-04-09
|
||||
|
||||
### Added
|
||||
- **samples**: Add sample documentation for two separate Neo4j context providers for retrieval and memory ([#4010](https://github.com/microsoft/agent-framework/pull/4010))
|
||||
- **agent-framework-azure-cosmos**: Add Cosmos DB NoSQL checkpoint storage for Python workflows ([#4916](https://github.com/microsoft/agent-framework/pull/4916))
|
||||
|
||||
### Changed
|
||||
- **docs**: Remove pre-release flag from agent-framework installation instructions ([#5082](https://github.com/microsoft/agent-framework/pull/5082))
|
||||
- **samples**: Revise agent examples in `README.md` ([#5067](https://github.com/microsoft/agent-framework/pull/5067))
|
||||
- **repo**: Update `CHANGELOG` with v1.0.0 release ([#5069](https://github.com/microsoft/agent-framework/pull/5069))
|
||||
- **agent-framework-orchestrations**: [BREAKING] Fix handoff workflow context management and improve AG-UI demo ([#5136](https://github.com/microsoft/agent-framework/pull/5136))
|
||||
- **agent-framework-core**: Restrict persisted checkpoint deserialization by default ([#4941](https://github.com/microsoft/agent-framework/pull/4941))
|
||||
- **samples**: Bump `vite` from 7.3.1 to 7.3.2 in `/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend` ([#5132](https://github.com/microsoft/agent-framework/pull/5132))
|
||||
- **python**: Bump `cryptography` from 46.0.6 to 46.0.7 ([#5176](https://github.com/microsoft/agent-framework/pull/5176))
|
||||
- **python**: Bump `mcp` from 1.26.0 to 1.27.0 ([#5117](https://github.com/microsoft/agent-framework/pull/5117))
|
||||
- **python**: Bump `mcp[ws]` from 1.26.0 to 1.27.0 ([#5119](https://github.com/microsoft/agent-framework/pull/5119))
|
||||
|
||||
### Fixed
|
||||
- **agent-framework-core**: Raise clear handler registration error for unresolved `TypeVar` annotations ([#4944](https://github.com/microsoft/agent-framework/pull/4944))
|
||||
- **agent-framework-openai**: Fix `response_format` crash on background polling with empty text ([#5146](https://github.com/microsoft/agent-framework/pull/5146))
|
||||
- **agent-framework-foundry**: Strip tools from `FoundryAgent` request when `agent_reference` is present ([#5101](https://github.com/microsoft/agent-framework/pull/5101))
|
||||
- **agent-framework-core**: Fix test compatibility for entity key validation ([#5179](https://github.com/microsoft/agent-framework/pull/5179))
|
||||
- **agent-framework-openai**: Stop emitting duplicate reasoning content from `response.reasoning_text.done` and `response.reasoning_summary_text.done` events ([#5162](https://github.com/microsoft/agent-framework/pull/5162))
|
||||
|
||||
|
||||
## [1.0.0] - 2026-04-02
|
||||
|
||||
### Added
|
||||
@@ -870,7 +898,8 @@ Release candidate for **agent-framework-core** and **agent-framework-azure-ai**
|
||||
|
||||
For more information, see the [announcement blog post](https://devblogs.microsoft.com/foundry/introducing-microsoft-agent-framework-the-open-source-engine-for-agentic-ai-apps/).
|
||||
|
||||
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.0.0...HEAD
|
||||
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.0.1...HEAD
|
||||
[1.0.1]: https://github.com/microsoft/agent-framework/compare/python-1.0.0...python-1.0.1
|
||||
[1.0.0]: https://github.com/microsoft/agent-framework/compare/python-1.0.0rc6...python-1.0.0
|
||||
[1.0.0rc6]: https://github.com/microsoft/agent-framework/compare/python-1.0.0rc5...python-1.0.0rc6
|
||||
[1.0.0rc5]: https://github.com/microsoft/agent-framework/compare/python-1.0.0rc4...python-1.0.0rc5
|
||||
|
||||
@@ -31,6 +31,7 @@ Status is grouped into these buckets:
|
||||
| `agent-framework-durabletask` | `python/packages/durabletask` | `beta` |
|
||||
| `agent-framework-foundry` | `python/packages/foundry` | `released` |
|
||||
| `agent-framework-foundry-local` | `python/packages/foundry_local` | `beta` |
|
||||
| `agent-framework-gemini` | `python/packages/gemini` | `alpha` |
|
||||
| `agent-framework-github-copilot` | `python/packages/github_copilot` | `beta` |
|
||||
| `agent-framework-lab` | `python/packages/lab` | `beta` |
|
||||
| `agent-framework-mem0` | `python/packages/mem0` | `beta` |
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "A2A integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260402"
|
||||
version = "1.0.0b260409"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.0.0,<2",
|
||||
"agent-framework-core>=1.0.1,<2",
|
||||
"a2a-sdk>=0.3.5,<0.3.24",
|
||||
]
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ from ._client import AGUIChatClient
|
||||
from ._endpoint import add_agent_framework_fastapi_endpoint
|
||||
from ._event_converters import AGUIEventConverter
|
||||
from ._http_service import AGUIHttpService
|
||||
from ._state import state_update
|
||||
from ._types import AgentState, AGUIChatOptions, AGUIRequest, PredictStateConfig, RunMetadata
|
||||
from ._workflow import AgentFrameworkWorkflow, WorkflowFactory
|
||||
|
||||
@@ -34,5 +35,6 @@ __all__ = [
|
||||
"PredictStateConfig",
|
||||
"RunMetadata",
|
||||
"DEFAULT_TAGS",
|
||||
"state_update",
|
||||
"__version__",
|
||||
]
|
||||
|
||||
@@ -6,6 +6,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, cast
|
||||
|
||||
@@ -31,6 +32,7 @@ from ag_ui.core import (
|
||||
from agent_framework import Content
|
||||
|
||||
from ._orchestration._predictive_state import PredictiveStateHandler
|
||||
from ._state import TOOL_RESULT_STATE_KEY
|
||||
from ._utils import generate_event_id, make_json_safe
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -233,16 +235,66 @@ def _emit_tool_call(
|
||||
return events
|
||||
|
||||
|
||||
def _extract_tool_result_state(content: Content) -> dict[str, Any] | None:
|
||||
"""Extract a deterministic AG-UI state update from a tool-result ``Content``.
|
||||
|
||||
Tools using :func:`agent_framework_ag_ui.state_update` carry the state
|
||||
payload in ``additional_properties[TOOL_RESULT_STATE_KEY]`` on the inner
|
||||
text item produced by ``parse_result``. We also check the outer
|
||||
function_result content's ``additional_properties`` for robustness.
|
||||
|
||||
If multiple items carry state, they are merged in order so later items
|
||||
override earlier ones (plain ``dict.update`` semantics).
|
||||
|
||||
Returns:
|
||||
The merged state dict to apply, or ``None`` if no state update is
|
||||
present.
|
||||
"""
|
||||
merged: dict[str, Any] | None = None
|
||||
|
||||
outer_ap = getattr(content, "additional_properties", None) or {}
|
||||
outer_state = outer_ap.get(TOOL_RESULT_STATE_KEY)
|
||||
if isinstance(outer_state, dict):
|
||||
merged = dict(outer_state)
|
||||
|
||||
for item in content.items or ():
|
||||
item_ap = getattr(item, "additional_properties", None) or {}
|
||||
item_state = item_ap.get(TOOL_RESULT_STATE_KEY)
|
||||
if isinstance(item_state, dict):
|
||||
if merged is None:
|
||||
merged = dict(item_state)
|
||||
else:
|
||||
merged.update(item_state)
|
||||
|
||||
return merged
|
||||
|
||||
|
||||
def _emit_tool_result_common(
|
||||
call_id: str,
|
||||
raw_result: Any,
|
||||
flow: FlowState,
|
||||
predictive_handler: PredictiveStateHandler | None = None,
|
||||
*,
|
||||
state_update: Mapping[str, Any] | None = None,
|
||||
) -> list[BaseEvent]:
|
||||
"""Shared helper for emitting ToolCallEnd + ToolCallResult events and performing FlowState cleanup.
|
||||
|
||||
Both ``_emit_tool_result`` (standard function results) and ``_emit_mcp_tool_result``
|
||||
(MCP server tool results) delegate to this function.
|
||||
|
||||
Args:
|
||||
call_id: Tool call identifier.
|
||||
raw_result: The stringified tool result content sent back to the LLM.
|
||||
flow: Current ``FlowState``.
|
||||
predictive_handler: Optional predictive state handler driven by
|
||||
``predict_state_config``.
|
||||
state_update: Optional deterministic state snapshot produced by a tool
|
||||
returning :func:`agent_framework_ag_ui.state_update`. When present,
|
||||
it is merged into ``flow.current_state`` and a ``StateSnapshotEvent``
|
||||
is emitted after the ``ToolCallResult`` event. When both
|
||||
``predictive_handler`` and ``state_update`` are active, predictive
|
||||
updates are applied first, then the deterministic merge, and a
|
||||
single coalesced ``StateSnapshotEvent`` is emitted.
|
||||
"""
|
||||
events: list[BaseEvent] = []
|
||||
|
||||
@@ -271,8 +323,18 @@ def _emit_tool_result_common(
|
||||
|
||||
if predictive_handler:
|
||||
predictive_handler.apply_pending_updates()
|
||||
if flow.current_state:
|
||||
events.append(StateSnapshotEvent(snapshot=flow.current_state))
|
||||
|
||||
if state_update:
|
||||
flow.current_state.update(state_update)
|
||||
logger.debug(
|
||||
"Emitted deterministic tool-result StateSnapshotEvent for call_id=%s (keys=%s)",
|
||||
call_id,
|
||||
list(state_update.keys()),
|
||||
)
|
||||
|
||||
# Emit a single coalesced snapshot when either mechanism updated state.
|
||||
if (predictive_handler or state_update) and flow.current_state:
|
||||
events.append(StateSnapshotEvent(snapshot=flow.current_state))
|
||||
|
||||
flow.tool_call_id = None
|
||||
flow.tool_call_name = None
|
||||
@@ -295,7 +357,14 @@ def _emit_tool_result(
|
||||
if not content.call_id:
|
||||
return []
|
||||
raw_result = content.result if content.result is not None else ""
|
||||
return _emit_tool_result_common(content.call_id, raw_result, flow, predictive_handler)
|
||||
state_update = _extract_tool_result_state(content)
|
||||
return _emit_tool_result_common(
|
||||
content.call_id,
|
||||
raw_result,
|
||||
flow,
|
||||
predictive_handler,
|
||||
state_update=state_update,
|
||||
)
|
||||
|
||||
|
||||
def _emit_approval_request(
|
||||
@@ -460,7 +529,14 @@ def _emit_mcp_tool_result(
|
||||
logger.warning("MCP tool result content missing call_id, skipping")
|
||||
return []
|
||||
raw_output = content.output if content.output is not None else ""
|
||||
return _emit_tool_result_common(content.call_id, raw_output, flow, predictive_handler)
|
||||
state_update = _extract_tool_result_state(content)
|
||||
return _emit_tool_result_common(
|
||||
content.call_id,
|
||||
raw_output,
|
||||
flow,
|
||||
predictive_handler,
|
||||
state_update=state_update,
|
||||
)
|
||||
|
||||
|
||||
def _close_reasoning_block(flow: FlowState) -> list[BaseEvent]:
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Deterministic tool-driven AG-UI state updates.
|
||||
|
||||
Tools wired into the :mod:`agent_framework_ag_ui` endpoint can push a
|
||||
deterministic state update by returning :func:`state_update`. Unlike
|
||||
``predict_state_config`` — which emits ``StateDeltaEvent``s optimistically from
|
||||
LLM-predicted tool call arguments — ``state_update`` runs *after* the tool
|
||||
executes, so the AG-UI state always reflects the tool's actual return value.
|
||||
|
||||
See issue https://github.com/microsoft/agent-framework/issues/3167 for the
|
||||
motivating discussion.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from agent_framework import Content
|
||||
|
||||
__all__ = ["TOOL_RESULT_STATE_KEY", "state_update"]
|
||||
|
||||
|
||||
TOOL_RESULT_STATE_KEY = "__ag_ui_tool_result_state__"
|
||||
"""Reserved ``Content.additional_properties`` key used to carry a tool-driven
|
||||
state snapshot from a tool return value through to the AG-UI emitter."""
|
||||
|
||||
|
||||
def state_update(
|
||||
text: str = "",
|
||||
*,
|
||||
state: Mapping[str, Any],
|
||||
) -> Content:
|
||||
"""Build a tool return value that deterministically updates AG-UI shared state.
|
||||
|
||||
Return the result of this helper from an agent tool to push a state update
|
||||
to AG-UI clients using the actual tool output, rather than LLM-predicted
|
||||
tool arguments.
|
||||
|
||||
When the AG-UI endpoint emits the tool result, it will:
|
||||
|
||||
* Forward ``text`` to the LLM as the normal ``function_result`` content.
|
||||
* Merge ``state`` into ``FlowState.current_state``.
|
||||
* Emit a deterministic ``StateSnapshotEvent`` after the ``ToolCallResult``
|
||||
event so frontends observe the updated state deterministically. If
|
||||
predictive state is enabled, a predictive snapshot may be emitted first.
|
||||
|
||||
Example:
|
||||
.. code-block:: python
|
||||
|
||||
from agent_framework import tool
|
||||
from agent_framework_ag_ui import state_update
|
||||
|
||||
|
||||
@tool
|
||||
async def get_weather(city: str) -> Content:
|
||||
data = await _fetch_weather(city)
|
||||
return state_update(
|
||||
text=f"Weather in {city}: {data['temp']}°C {data['conditions']}",
|
||||
state={"weather": {"city": city, **data}},
|
||||
)
|
||||
|
||||
Args:
|
||||
text: Text passed back to the LLM as the ``function_result`` content.
|
||||
Defaults to an empty string for tools whose only output is a state
|
||||
update.
|
||||
state: A mapping merged into the AG-UI shared state via JSON-compatible
|
||||
``dict.update`` semantics. Nested dicts are replaced, not deep-merged.
|
||||
|
||||
Returns:
|
||||
A ``Content`` object with ``type="text"``. The state payload rides in
|
||||
``additional_properties`` under :data:`TOOL_RESULT_STATE_KEY` and is
|
||||
extracted by the AG-UI emitter.
|
||||
|
||||
Raises:
|
||||
TypeError: If ``state`` is not a ``Mapping``.
|
||||
"""
|
||||
if not isinstance(state, Mapping):
|
||||
raise TypeError(f"state_update() 'state' must be a Mapping, got {type(state).__name__}")
|
||||
return Content.from_text(
|
||||
text,
|
||||
additional_properties={TOOL_RESULT_STATE_KEY: dict(state)},
|
||||
)
|
||||
@@ -0,0 +1,92 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Deterministic tool-driven AG-UI state example.
|
||||
|
||||
This sample demonstrates how a tool can push a *deterministic* state update
|
||||
to the AG-UI frontend based on its actual return value — in contrast to
|
||||
``predict_state_config`` which fires optimistically from LLM-predicted tool
|
||||
call arguments. See issue https://github.com/microsoft/agent-framework/issues/3167.
|
||||
|
||||
The :func:`agent_framework_ag_ui.state_update` helper wraps a text result
|
||||
together with a state snapshot. When a tool returns one of these, the AG-UI
|
||||
endpoint merges the snapshot into the shared state and emits a
|
||||
``StateSnapshotEvent`` after the tool result.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from agent_framework import Agent, Content, SupportsChatGetResponse, tool
|
||||
from agent_framework.ag_ui import AgentFrameworkAgent
|
||||
|
||||
from agent_framework_ag_ui import state_update
|
||||
|
||||
# Simulated weather database — in the issue's motivating example the tool
|
||||
# would instead call a real weather API.
|
||||
_WEATHER_DB: dict[str, dict[str, Any]] = {
|
||||
"seattle": {"temperature": 11, "conditions": "rainy", "humidity": 75},
|
||||
"san francisco": {"temperature": 14, "conditions": "foggy", "humidity": 85},
|
||||
"new york city": {"temperature": 18, "conditions": "sunny", "humidity": 60},
|
||||
"miami": {"temperature": 29, "conditions": "hot and humid", "humidity": 90},
|
||||
"chicago": {"temperature": 9, "conditions": "windy", "humidity": 65},
|
||||
}
|
||||
|
||||
|
||||
@tool
|
||||
async def get_weather(location: str) -> Content:
|
||||
"""Fetch current weather for a location and push it into AG-UI shared state.
|
||||
|
||||
Unlike ``predict_state_config`` — which derives state optimistically from
|
||||
LLM-predicted tool call arguments — this tool uses ``state_update`` to
|
||||
forward the *actual* fetched weather to the frontend. The ``text`` goes
|
||||
back to the LLM as the normal tool result, and the ``state`` dict is merged
|
||||
into the AG-UI shared state.
|
||||
|
||||
Args:
|
||||
location: City name to look up.
|
||||
|
||||
Returns:
|
||||
A :class:`Content` carrying both the LLM-visible text result and a
|
||||
deterministic state snapshot.
|
||||
"""
|
||||
key = location.lower()
|
||||
data = _WEATHER_DB.get(
|
||||
key,
|
||||
{"temperature": 21, "conditions": "partly cloudy", "humidity": 50},
|
||||
)
|
||||
weather_record = {"location": location, **data}
|
||||
return state_update(
|
||||
text=(
|
||||
f"The weather in {location} is {data['conditions']} at "
|
||||
f"{data['temperature']}°C with {data['humidity']}% humidity."
|
||||
),
|
||||
state={"weather": weather_record},
|
||||
)
|
||||
|
||||
|
||||
def weather_state_agent(client: SupportsChatGetResponse[Any]) -> AgentFrameworkAgent:
|
||||
"""Create an AG-UI agent with a deterministic tool-driven state tool."""
|
||||
agent = Agent[Any](
|
||||
name="weather_state_agent",
|
||||
instructions=(
|
||||
"You are a weather assistant. When a user asks about the weather "
|
||||
"in a city, call the get_weather tool and use its output to give a "
|
||||
"friendly, concise reply. The tool also updates the shared UI state "
|
||||
"so the frontend can render a weather card from the `weather` key."
|
||||
),
|
||||
client=client,
|
||||
tools=[get_weather],
|
||||
)
|
||||
|
||||
return AgentFrameworkAgent(
|
||||
agent=agent,
|
||||
name="WeatherStateAgent",
|
||||
description="Weather agent that deterministically updates shared state from tool results.",
|
||||
state_schema={
|
||||
"weather": {
|
||||
"type": "object",
|
||||
"description": "Last fetched weather record",
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -24,6 +24,7 @@ from ..agents.subgraphs_agent import subgraphs_agent
|
||||
from ..agents.task_steps_agent import task_steps_agent_wrapped
|
||||
from ..agents.ui_generator_agent import ui_generator_agent
|
||||
from ..agents.weather_agent import weather_agent
|
||||
from ..agents.weather_state_agent import weather_state_agent
|
||||
|
||||
AnthropicClient: type[Any] | None
|
||||
try:
|
||||
@@ -141,6 +142,14 @@ add_agent_framework_fastapi_endpoint(
|
||||
path="/subgraphs",
|
||||
)
|
||||
|
||||
# Deterministic Tool-Driven State - tool returns state_update() to push snapshot
|
||||
# from actual tool output (see issue #3167).
|
||||
add_agent_framework_fastapi_endpoint(
|
||||
app=app,
|
||||
agent=weather_state_agent(client),
|
||||
path="/deterministic_state",
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
"""Run the server."""
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "agent-framework-ag-ui"
|
||||
version = "1.0.0b260402"
|
||||
version = "1.0.0b260409"
|
||||
description = "AG-UI protocol integration for Agent Framework"
|
||||
readme = "README.md"
|
||||
license-files = ["LICENSE"]
|
||||
@@ -22,7 +22,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.0.0,<2",
|
||||
"agent-framework-core>=1.0.1,<2",
|
||||
"ag-ui-protocol==0.1.13",
|
||||
"fastapi>=0.115.0,<0.133.1",
|
||||
"uvicorn[standard]>=0.30.0,<0.42.0"
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Golden event-stream tests for the deterministic tool-driven state scenario.
|
||||
|
||||
Covers issue https://github.com/microsoft/agent-framework/issues/3167 — a tool
|
||||
returning :func:`agent_framework_ag_ui.state_update` must push a deterministic
|
||||
``StateSnapshotEvent`` derived from its actual return value, orthogonal to the
|
||||
optimistic ``predict_state_config`` path. These golden tests pin the user-visible
|
||||
event stream so additive changes cannot silently regress it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from agent_framework import AgentResponseUpdate, Content
|
||||
from conftest import StubAgent
|
||||
from event_stream import EventStream
|
||||
|
||||
from agent_framework_ag_ui import AgentFrameworkAgent, state_update
|
||||
|
||||
STATE_SCHEMA = {
|
||||
"weather": {"type": "object", "description": "Last fetched weather"},
|
||||
}
|
||||
|
||||
|
||||
def _build_agent(updates: list[AgentResponseUpdate], **kwargs: Any) -> AgentFrameworkAgent:
|
||||
stub = StubAgent(updates=updates)
|
||||
kwargs.setdefault("state_schema", STATE_SCHEMA)
|
||||
return AgentFrameworkAgent(agent=stub, **kwargs)
|
||||
|
||||
|
||||
async def _run(agent: AgentFrameworkAgent, payload: dict[str, Any]) -> EventStream:
|
||||
return EventStream([event async for event in agent.run(payload)])
|
||||
|
||||
|
||||
PAYLOAD: dict[str, Any] = {
|
||||
"thread_id": "thread-det-state",
|
||||
"run_id": "run-det-state",
|
||||
"messages": [{"role": "user", "content": "What's the weather in SF?"}],
|
||||
"state": {"weather": {}},
|
||||
}
|
||||
|
||||
|
||||
def _tool_call(call_id: str, name: str, arguments: str) -> AgentResponseUpdate:
|
||||
return AgentResponseUpdate(
|
||||
contents=[Content.from_function_call(name=name, call_id=call_id, arguments=arguments)],
|
||||
role="assistant",
|
||||
)
|
||||
|
||||
|
||||
def _tool_result_with_state(call_id: str, text: str, state: dict[str, Any]) -> AgentResponseUpdate:
|
||||
"""Build a function_result update whose inner item carries a state marker.
|
||||
|
||||
This mirrors what the core framework produces when a real ``@tool`` returns
|
||||
:func:`state_update`: ``parse_result`` keeps the ``Content`` as-is, and
|
||||
``Content.from_function_result`` preserves its ``additional_properties``
|
||||
inside ``items``.
|
||||
"""
|
||||
return AgentResponseUpdate(
|
||||
contents=[
|
||||
Content.from_function_result(
|
||||
call_id=call_id,
|
||||
result=[state_update(text=text, state=state)],
|
||||
)
|
||||
],
|
||||
role="assistant",
|
||||
)
|
||||
|
||||
|
||||
# ── Golden stream tests ──
|
||||
|
||||
|
||||
async def test_deterministic_state_emits_snapshot_after_tool_result() -> None:
|
||||
"""The happy path: STATE_SNAPSHOT follows TOOL_CALL_RESULT in order."""
|
||||
updates = [
|
||||
_tool_call("call-1", "get_weather", '{"city": "SF"}'),
|
||||
_tool_result_with_state(
|
||||
"call-1",
|
||||
text="Weather in SF: 14°C foggy",
|
||||
state={"weather": {"city": "SF", "temp": 14, "conditions": "foggy"}},
|
||||
),
|
||||
AgentResponseUpdate(
|
||||
contents=[Content.from_text(text="It's 14°C and foggy in SF.")],
|
||||
role="assistant",
|
||||
),
|
||||
]
|
||||
agent = _build_agent(updates)
|
||||
stream = await _run(agent, PAYLOAD)
|
||||
|
||||
stream.assert_bookends()
|
||||
stream.assert_no_run_error()
|
||||
stream.assert_tool_calls_balanced()
|
||||
stream.assert_text_messages_balanced()
|
||||
|
||||
# Ordered subsequence: the deterministic STATE_SNAPSHOT must follow the
|
||||
# TOOL_CALL_RESULT. This is the central contract for #3167.
|
||||
stream.assert_ordered_types(
|
||||
[
|
||||
"RUN_STARTED",
|
||||
"TOOL_CALL_START",
|
||||
"TOOL_CALL_ARGS",
|
||||
"TOOL_CALL_END",
|
||||
"TOOL_CALL_RESULT",
|
||||
"STATE_SNAPSHOT",
|
||||
"RUN_FINISHED",
|
||||
]
|
||||
)
|
||||
|
||||
# The final STATE_SNAPSHOT must carry the tool-driven state.
|
||||
snapshot = stream.snapshot()
|
||||
assert snapshot["weather"] == {"city": "SF", "temp": 14, "conditions": "foggy"}
|
||||
|
||||
|
||||
async def test_deterministic_state_does_not_fire_for_plain_tool_result() -> None:
|
||||
"""Regression guard: tools returning plain strings must NOT emit a new STATE_SNAPSHOT.
|
||||
|
||||
The initial STATE_SNAPSHOT fires once from the schema + initial payload
|
||||
state. A plain (non-state_update) tool result must not add another one.
|
||||
"""
|
||||
updates = [
|
||||
_tool_call("call-1", "get_weather", '{"city": "SF"}'),
|
||||
AgentResponseUpdate(
|
||||
contents=[Content.from_function_result(call_id="call-1", result="14°C foggy")],
|
||||
role="assistant",
|
||||
),
|
||||
AgentResponseUpdate(
|
||||
contents=[Content.from_text(text="It's 14°C and foggy.")],
|
||||
role="assistant",
|
||||
),
|
||||
]
|
||||
agent = _build_agent(updates)
|
||||
stream = await _run(agent, PAYLOAD)
|
||||
|
||||
stream.assert_bookends()
|
||||
stream.assert_no_run_error()
|
||||
|
||||
snapshots = stream.get("STATE_SNAPSHOT")
|
||||
# Only the initial snapshot (from state_schema + payload state) should exist.
|
||||
# No deterministic snapshot should have been added by the plain tool result.
|
||||
assert len(snapshots) == 1, (
|
||||
f"Expected exactly 1 STATE_SNAPSHOT (initial only) for plain tool result; "
|
||||
f"got {len(snapshots)}. Snapshots: {[s.snapshot for s in snapshots]}"
|
||||
)
|
||||
|
||||
|
||||
async def test_deterministic_state_merges_into_initial_state() -> None:
|
||||
"""The tool-driven snapshot must merge into, not replace, pre-existing state keys."""
|
||||
payload = dict(PAYLOAD)
|
||||
payload["state"] = {"weather": {}, "user_preferences": {"unit": "C"}}
|
||||
|
||||
updates = [
|
||||
_tool_call("call-1", "get_weather", '{"city": "SF"}'),
|
||||
_tool_result_with_state(
|
||||
"call-1",
|
||||
text="Weather: 14°C",
|
||||
state={"weather": {"city": "SF", "temp": 14}},
|
||||
),
|
||||
]
|
||||
agent = _build_agent(updates, state_schema={**STATE_SCHEMA, "user_preferences": {"type": "object"}})
|
||||
stream = await _run(agent, payload)
|
||||
|
||||
stream.assert_bookends()
|
||||
stream.assert_no_run_error()
|
||||
|
||||
final_snapshot = stream.snapshot()
|
||||
assert final_snapshot["weather"] == {"city": "SF", "temp": 14}
|
||||
assert final_snapshot["user_preferences"] == {"unit": "C"}, (
|
||||
"Pre-existing state keys must survive the deterministic merge"
|
||||
)
|
||||
|
||||
|
||||
async def test_deterministic_state_llm_visible_text_is_clean() -> None:
|
||||
"""The LLM-visible TOOL_CALL_RESULT content must not leak the state marker key."""
|
||||
updates = [
|
||||
_tool_call("call-1", "get_weather", '{"city": "SF"}'),
|
||||
_tool_result_with_state(
|
||||
"call-1",
|
||||
text="Weather in SF: 14°C foggy",
|
||||
state={"weather": {"city": "SF", "temp": 14}},
|
||||
),
|
||||
]
|
||||
agent = _build_agent(updates)
|
||||
stream = await _run(agent, PAYLOAD)
|
||||
|
||||
result = stream.first("TOOL_CALL_RESULT")
|
||||
assert result.content == "Weather in SF: 14°C foggy"
|
||||
# The marker key must never appear in the content sent back to the LLM.
|
||||
assert "__ag_ui_tool_result_state__" not in result.content
|
||||
assert "weather" not in result.content # not as a raw state dump
|
||||
|
||||
|
||||
async def test_deterministic_state_multiple_tools_merge_in_order() -> None:
|
||||
"""Two state-updating tools in one run merge in order; later wins on key collisions."""
|
||||
updates = [
|
||||
_tool_call("call-a", "get_weather", '{"city": "SF"}'),
|
||||
_tool_result_with_state(
|
||||
"call-a",
|
||||
text="First result",
|
||||
state={"weather": {"city": "SF", "temp": 14}, "source": "primary"},
|
||||
),
|
||||
_tool_call("call-b", "get_weather_refined", '{"city": "SF"}'),
|
||||
_tool_result_with_state(
|
||||
"call-b",
|
||||
text="Refined result",
|
||||
state={"source": "refined"},
|
||||
),
|
||||
AgentResponseUpdate(
|
||||
contents=[Content.from_text(text="Here you go.")],
|
||||
role="assistant",
|
||||
),
|
||||
]
|
||||
agent = _build_agent(
|
||||
updates,
|
||||
state_schema={**STATE_SCHEMA, "source": {"type": "string"}},
|
||||
)
|
||||
stream = await _run(agent, PAYLOAD)
|
||||
|
||||
stream.assert_bookends()
|
||||
stream.assert_tool_calls_balanced()
|
||||
stream.assert_no_run_error()
|
||||
|
||||
# Two tool-driven snapshots emitted (one per tool) plus the initial snapshot.
|
||||
snapshots = stream.get("STATE_SNAPSHOT")
|
||||
assert len(snapshots) >= 2, f"Expected at least 2 STATE_SNAPSHOTs; got {len(snapshots)}"
|
||||
|
||||
final = stream.snapshot()
|
||||
assert final["weather"] == {"city": "SF", "temp": 14}
|
||||
# Later tool must override earlier tool on the shared key.
|
||||
assert final["source"] == "refined"
|
||||
|
||||
|
||||
async def test_deterministic_state_coexists_with_predict_state_config() -> None:
|
||||
"""Predictive state and deterministic state must coexist without clobbering each other."""
|
||||
predict_config = {
|
||||
"draft": {
|
||||
"tool": "write_draft",
|
||||
"tool_argument": "body",
|
||||
}
|
||||
}
|
||||
updates = [
|
||||
# Predictive tool: its argument "body" populates state.draft optimistically.
|
||||
_tool_call("call-1", "write_draft", '{"body": "Hello world"}'),
|
||||
# Then a deterministic tool result landing a different key.
|
||||
_tool_result_with_state(
|
||||
"call-1",
|
||||
text="Draft saved",
|
||||
state={"weather": {"city": "SF", "temp": 14}},
|
||||
),
|
||||
]
|
||||
agent = _build_agent(
|
||||
updates,
|
||||
state_schema={**STATE_SCHEMA, "draft": {"type": "string"}},
|
||||
predict_state_config=predict_config,
|
||||
require_confirmation=False,
|
||||
)
|
||||
payload = dict(PAYLOAD)
|
||||
payload["state"] = {"weather": {}, "draft": ""}
|
||||
stream = await _run(agent, payload)
|
||||
|
||||
stream.assert_bookends()
|
||||
stream.assert_no_run_error()
|
||||
stream.assert_tool_calls_balanced()
|
||||
|
||||
# The final observed state must contain both the deterministic and predictive contributions.
|
||||
final = stream.snapshot()
|
||||
assert final["weather"] == {"city": "SF", "temp": 14}, f"Deterministic state missing from final snapshot: {final}"
|
||||
@@ -1405,3 +1405,95 @@ async def test_fabricated_rejection_without_pending_approval_is_blocked(streamin
|
||||
for content in msg.contents:
|
||||
if content.type == "function_result" and content.call_id == "fake_reject_001":
|
||||
assert False, "Fabricated rejection response leaked as function_result into LLM messages"
|
||||
|
||||
|
||||
async def test_state_update_end_to_end_via_real_tool_invocation(streaming_chat_client_stub):
|
||||
"""End-to-end coverage for issue #3167: a real ``@tool`` returning ``state_update`` must
|
||||
emit a deterministic STATE_SNAPSHOT through the full pipeline.
|
||||
|
||||
This test exercises the entire chain that a user would hit in production:
|
||||
``FunctionInvocationLayer`` executes the tool, ``FunctionTool.parse_result``
|
||||
preserves the returned ``Content`` with its ``additional_properties`` marker,
|
||||
``Content.from_function_result`` carries the marker through in ``items``,
|
||||
and the AG-UI emitter extracts it via ``_extract_tool_result_state`` and
|
||||
emits the snapshot. A regression anywhere in that chain will fail this test.
|
||||
"""
|
||||
from agent_framework import tool
|
||||
from agent_framework.ag_ui import AgentFrameworkAgent
|
||||
|
||||
from agent_framework_ag_ui import state_update
|
||||
|
||||
@tool(name="get_weather", description="Get current weather for a city.")
|
||||
async def get_weather(city: str) -> Content:
|
||||
return state_update(
|
||||
text=f"Weather in {city}: 14°C foggy",
|
||||
state={"weather": {"city": city, "temperature": 14, "conditions": "foggy"}},
|
||||
)
|
||||
|
||||
call_count = {"n": 0}
|
||||
|
||||
async def stream_fn(
|
||||
messages: MutableSequence[Message], options: ChatOptions, **kwargs: Any
|
||||
) -> AsyncIterator[ChatResponseUpdate]:
|
||||
"""First turn proposes a tool call; second turn (after tool execution) returns text."""
|
||||
call_count["n"] += 1
|
||||
if call_count["n"] == 1:
|
||||
yield ChatResponseUpdate(
|
||||
contents=[
|
||||
Content.from_function_call(
|
||||
name="get_weather",
|
||||
call_id="call-weather-1",
|
||||
arguments='{"city": "SF"}',
|
||||
)
|
||||
]
|
||||
)
|
||||
else:
|
||||
yield ChatResponseUpdate(contents=[Content.from_text(text="It's 14°C and foggy in SF.")])
|
||||
|
||||
agent = Agent(
|
||||
client=streaming_chat_client_stub(stream_fn),
|
||||
name="weather_agent",
|
||||
instructions="Answer weather questions.",
|
||||
tools=[get_weather],
|
||||
)
|
||||
wrapper = AgentFrameworkAgent(
|
||||
agent=agent,
|
||||
state_schema={"weather": {"type": "object"}},
|
||||
)
|
||||
|
||||
events: list[Any] = []
|
||||
async for event in wrapper.run(
|
||||
{
|
||||
"thread_id": "thread-weather",
|
||||
"run_id": "run-weather",
|
||||
"messages": [{"role": "user", "content": "What's the weather in SF?"}],
|
||||
"state": {"weather": {}},
|
||||
}
|
||||
):
|
||||
events.append(event)
|
||||
|
||||
types = [e.type for e in events]
|
||||
|
||||
# The tool call must be visible in the stream.
|
||||
assert "TOOL_CALL_START" in types, f"Missing TOOL_CALL_START in: {types}"
|
||||
assert "TOOL_CALL_RESULT" in types, f"Missing TOOL_CALL_RESULT in: {types}"
|
||||
|
||||
# A STATE_SNAPSHOT must be emitted after the tool result.
|
||||
tool_result_idx = types.index("TOOL_CALL_RESULT")
|
||||
snapshot_indices_after_result = [i for i, t in enumerate(types) if t == "STATE_SNAPSHOT" and i > tool_result_idx]
|
||||
assert snapshot_indices_after_result, (
|
||||
f"Expected a STATE_SNAPSHOT after TOOL_CALL_RESULT (index {tool_result_idx}); got types: {types}"
|
||||
)
|
||||
|
||||
# The tool's deterministic snapshot carries the actual fetched weather data.
|
||||
final_snapshot = events[snapshot_indices_after_result[-1]].snapshot
|
||||
assert final_snapshot["weather"] == {
|
||||
"city": "SF",
|
||||
"temperature": 14,
|
||||
"conditions": "foggy",
|
||||
}
|
||||
|
||||
# The LLM-visible tool result must carry the plain text, not the marker key.
|
||||
tool_result_event = next(e for e in events if e.type == "TOOL_CALL_RESULT")
|
||||
assert tool_result_event.content == "Weather in SF: 14°C foggy"
|
||||
assert "__ag_ui_tool_result_state__" not in tool_result_event.content
|
||||
|
||||
@@ -18,7 +18,24 @@ def test_core_ag_ui_lazy_exports_include_only_stable_api() -> None:
|
||||
assert hasattr(ag_ui, "AgentFrameworkAgent")
|
||||
assert hasattr(ag_ui, "AGUIChatClient")
|
||||
assert hasattr(ag_ui, "add_agent_framework_fastapi_endpoint")
|
||||
assert hasattr(ag_ui, "state_update")
|
||||
|
||||
assert not hasattr(ag_ui, "WorkflowFactory")
|
||||
assert not hasattr(ag_ui, "AGUIRequest")
|
||||
assert not hasattr(ag_ui, "RunMetadata")
|
||||
|
||||
|
||||
def test_agent_framework_ag_ui_exports_state_update() -> None:
|
||||
"""Runtime package should export the ``state_update`` helper."""
|
||||
from agent_framework_ag_ui import state_update
|
||||
|
||||
assert callable(state_update)
|
||||
|
||||
|
||||
def test_core_ag_ui_lazy_exports_include_event_converter_and_http_service() -> None:
|
||||
"""Core facade must expose AGUIEventConverter, AGUIHttpService, and __version__."""
|
||||
from agent_framework import ag_ui
|
||||
|
||||
assert hasattr(ag_ui, "AGUIEventConverter")
|
||||
assert hasattr(ag_ui, "AGUIHttpService")
|
||||
assert hasattr(ag_ui, "__version__")
|
||||
|
||||
@@ -2,14 +2,20 @@
|
||||
|
||||
"""Tests for _run_common.py edge cases."""
|
||||
|
||||
from ag_ui.core import EventType
|
||||
from agent_framework import Content
|
||||
|
||||
from agent_framework_ag_ui import state_update
|
||||
from agent_framework_ag_ui._orchestration._predictive_state import PredictiveStateHandler
|
||||
from agent_framework_ag_ui._run_common import (
|
||||
FlowState,
|
||||
_emit_mcp_tool_result,
|
||||
_emit_tool_result,
|
||||
_extract_resume_payload,
|
||||
_extract_tool_result_state,
|
||||
_normalize_resume_interrupts,
|
||||
)
|
||||
from agent_framework_ag_ui._state import TOOL_RESULT_STATE_KEY
|
||||
|
||||
|
||||
class TestNormalizeResumeInterrupts:
|
||||
@@ -120,3 +126,223 @@ class TestEmitToolResult:
|
||||
assert "TEXT_MESSAGE_END" in event_types
|
||||
assert flow.message_id is None
|
||||
assert flow.accumulated_text == ""
|
||||
|
||||
|
||||
class TestStateUpdateHelper:
|
||||
"""Tests for the public ``state_update`` helper."""
|
||||
|
||||
def test_builds_text_content_with_state_marker(self):
|
||||
"""state_update returns a text Content carrying state in additional_properties."""
|
||||
c = state_update(text="done", state={"weather": {"temp": 14}})
|
||||
assert c.type == "text"
|
||||
assert c.text == "done"
|
||||
assert c.additional_properties == {
|
||||
TOOL_RESULT_STATE_KEY: {"weather": {"temp": 14}},
|
||||
}
|
||||
|
||||
def test_empty_text_is_allowed(self):
|
||||
"""State-only tools can omit the text argument."""
|
||||
c = state_update(state={"steps": ["a", "b"]})
|
||||
assert c.text == ""
|
||||
assert c.additional_properties[TOOL_RESULT_STATE_KEY] == {"steps": ["a", "b"]}
|
||||
|
||||
def test_non_mapping_state_raises(self):
|
||||
"""Passing a non-mapping value for state raises TypeError."""
|
||||
import pytest
|
||||
|
||||
with pytest.raises(TypeError):
|
||||
state_update(text="t", state=["not", "a", "mapping"]) # type: ignore[arg-type]
|
||||
|
||||
def test_state_is_copied_defensively(self):
|
||||
"""Mutating the caller's dict after ``state_update`` must not mutate the content."""
|
||||
caller_state = {"weather": {"temp": 14}}
|
||||
c = state_update(text="ok", state=caller_state)
|
||||
caller_state["weather"]["temp"] = 99
|
||||
# The top-level dict was copied, so replacing the key in caller_state
|
||||
# would not affect the Content, but nested dicts share references — document
|
||||
# this by asserting only the top-level copy semantics.
|
||||
assert TOOL_RESULT_STATE_KEY in c.additional_properties
|
||||
inner = c.additional_properties[TOOL_RESULT_STATE_KEY]
|
||||
assert inner is not caller_state
|
||||
|
||||
|
||||
class TestExtractToolResultState:
|
||||
"""Tests for ``_extract_tool_result_state``."""
|
||||
|
||||
def test_returns_none_for_plain_string_result(self):
|
||||
content = Content.from_function_result(call_id="c1", result="plain")
|
||||
assert _extract_tool_result_state(content) is None
|
||||
|
||||
def test_extracts_state_from_inner_item(self):
|
||||
tool_return = state_update(text="hi", state={"k": 1})
|
||||
content = Content.from_function_result(call_id="c1", result=[tool_return])
|
||||
assert _extract_tool_result_state(content) == {"k": 1}
|
||||
|
||||
def test_extracts_state_from_outer_additional_properties(self):
|
||||
"""Outer function_result content can also carry state (legacy/advanced use)."""
|
||||
content = Content.from_function_result(
|
||||
call_id="c1",
|
||||
result="hi",
|
||||
additional_properties={TOOL_RESULT_STATE_KEY: {"k": 1}},
|
||||
)
|
||||
assert _extract_tool_result_state(content) == {"k": 1}
|
||||
|
||||
def test_merges_multiple_items(self):
|
||||
a = state_update(text="a", state={"k": 1, "shared": "from_a"})
|
||||
b = state_update(text="b", state={"shared": "from_b", "extra": True})
|
||||
content = Content.from_function_result(call_id="c1", result=[a, b])
|
||||
merged = _extract_tool_result_state(content)
|
||||
assert merged == {"k": 1, "shared": "from_b", "extra": True}
|
||||
|
||||
def test_ignores_non_dict_marker_value(self):
|
||||
"""A garbled marker value must not break extraction (defensive guard)."""
|
||||
bad = Content.from_text(
|
||||
"hi",
|
||||
additional_properties={TOOL_RESULT_STATE_KEY: "not-a-dict"},
|
||||
)
|
||||
content = Content.from_function_result(call_id="c1", result=[bad])
|
||||
assert _extract_tool_result_state(content) is None
|
||||
|
||||
|
||||
class TestEmitToolResultWithState:
|
||||
"""Tests for the deterministic state emission in ``_emit_tool_result``."""
|
||||
|
||||
def test_emits_state_snapshot_after_tool_call_result(self):
|
||||
"""Tool returning state_update produces a StateSnapshotEvent right after the result."""
|
||||
tool_return = state_update(
|
||||
text="Weather: 14°C",
|
||||
state={"weather": {"temp": 14, "conditions": "foggy"}},
|
||||
)
|
||||
content = Content.from_function_result(call_id="call_1", result=[tool_return])
|
||||
flow = FlowState()
|
||||
|
||||
events = _emit_tool_result(content, flow)
|
||||
event_types = [e.type for e in events]
|
||||
|
||||
# Expect TOOL_CALL_END, TOOL_CALL_RESULT, STATE_SNAPSHOT in that order.
|
||||
assert event_types[0] == EventType.TOOL_CALL_END
|
||||
assert event_types[1] == EventType.TOOL_CALL_RESULT
|
||||
state_idx = event_types.index(EventType.STATE_SNAPSHOT)
|
||||
assert state_idx == 2
|
||||
assert events[state_idx].snapshot == {"weather": {"temp": 14, "conditions": "foggy"}}
|
||||
|
||||
def test_updates_flow_current_state(self):
|
||||
tool_return = state_update(text="", state={"a": 1})
|
||||
content = Content.from_function_result(call_id="c1", result=[tool_return])
|
||||
flow = FlowState(current_state={"existing": "value"})
|
||||
|
||||
_emit_tool_result(content, flow)
|
||||
|
||||
# Existing keys must survive (merge semantics), new keys must be added.
|
||||
assert flow.current_state == {"existing": "value", "a": 1}
|
||||
|
||||
def test_merge_overrides_existing_key(self):
|
||||
tool_return = state_update(text="", state={"existing": "new"})
|
||||
content = Content.from_function_result(call_id="c1", result=[tool_return])
|
||||
flow = FlowState(current_state={"existing": "old", "other": 1})
|
||||
|
||||
_emit_tool_result(content, flow)
|
||||
|
||||
assert flow.current_state == {"existing": "new", "other": 1}
|
||||
|
||||
def test_no_state_snapshot_when_result_has_no_state(self):
|
||||
"""Plain tool results must not emit a StateSnapshotEvent."""
|
||||
content = Content.from_function_result(call_id="c1", result="plain")
|
||||
flow = FlowState()
|
||||
|
||||
events = _emit_tool_result(content, flow)
|
||||
assert all(e.type != EventType.STATE_SNAPSHOT for e in events)
|
||||
|
||||
def test_tool_result_content_text_unchanged(self):
|
||||
"""The text sent to the LLM must not leak the state marker."""
|
||||
tool_return = state_update(text="Weather: 14°C", state={"weather": {"temp": 14}})
|
||||
content = Content.from_function_result(call_id="c1", result=[tool_return])
|
||||
flow = FlowState()
|
||||
|
||||
events = _emit_tool_result(content, flow)
|
||||
result_events = [e for e in events if e.type == EventType.TOOL_CALL_RESULT]
|
||||
assert len(result_events) == 1
|
||||
assert result_events[0].content == "Weather: 14°C"
|
||||
assert TOOL_RESULT_STATE_KEY not in result_events[0].content
|
||||
|
||||
def test_coexists_with_active_predictive_state_handler(self):
|
||||
"""Both predictive and deterministic state produce a single coalesced snapshot.
|
||||
|
||||
Predictive state (``predict_state_config``) and deterministic state
|
||||
(``state_update``) are two independent mechanisms. When both are active,
|
||||
a single coalesced ``StateSnapshotEvent`` is emitted containing the
|
||||
merged result of both contributions.
|
||||
"""
|
||||
flow = FlowState(current_state={"preexisting": "value"})
|
||||
handler = PredictiveStateHandler(
|
||||
predict_state_config={"draft": {"tool": "write_draft", "tool_argument": "body"}},
|
||||
current_state=flow.current_state,
|
||||
)
|
||||
|
||||
tool_return = state_update(text="Draft written", state={"draft_final": True})
|
||||
content = Content.from_function_result(call_id="c1", result=[tool_return])
|
||||
|
||||
events = _emit_tool_result(content, flow, predictive_handler=handler)
|
||||
|
||||
# Exactly one coalesced snapshot must be emitted containing all merged keys.
|
||||
snapshots = [e for e in events if e.type == EventType.STATE_SNAPSHOT]
|
||||
assert len(snapshots) == 1
|
||||
assert snapshots[0].snapshot["draft_final"] is True
|
||||
assert snapshots[0].snapshot["preexisting"] == "value"
|
||||
assert flow.current_state["draft_final"] is True
|
||||
assert flow.current_state["preexisting"] == "value"
|
||||
|
||||
def test_predictive_and_deterministic_emit_single_snapshot(self):
|
||||
"""When both predictive_handler and state_update are active, only one snapshot is emitted."""
|
||||
flow = FlowState(current_state={"existing": "yes"})
|
||||
handler = PredictiveStateHandler(
|
||||
predict_state_config={"draft": {"tool": "write_draft", "tool_argument": "body"}},
|
||||
current_state=flow.current_state,
|
||||
)
|
||||
|
||||
tool_return = state_update(text="ok", state={"new_key": 42})
|
||||
content = Content.from_function_result(call_id="c1", result=[tool_return])
|
||||
|
||||
events = _emit_tool_result(content, flow, predictive_handler=handler)
|
||||
|
||||
snapshots = [e for e in events if e.type == EventType.STATE_SNAPSHOT]
|
||||
assert len(snapshots) == 1, f"Expected 1 coalesced snapshot, got {len(snapshots)}"
|
||||
assert snapshots[0].snapshot == {"existing": "yes", "new_key": 42}
|
||||
|
||||
|
||||
class TestEmitMcpToolResultWithState:
|
||||
"""MCP tool results should honour the same state_update marker.
|
||||
|
||||
MCP results come from an external MCP server rather than a locally
|
||||
executed ``@tool`` function, so they do not flow through ``parse_result``
|
||||
and ``content.items`` is typically empty. State is instead carried on the
|
||||
outer content's ``additional_properties`` (e.g. by middleware that
|
||||
inspects the MCP output and attaches a marker). ``_extract_tool_result_state``
|
||||
supports both locations so this path remains usable.
|
||||
"""
|
||||
|
||||
def test_mcp_tool_result_emits_state_snapshot_from_additional_properties(self):
|
||||
content = Content.from_mcp_server_tool_result(
|
||||
call_id="mcp_1",
|
||||
output="server result",
|
||||
additional_properties={TOOL_RESULT_STATE_KEY: {"mcp_ok": True}},
|
||||
)
|
||||
flow = FlowState()
|
||||
|
||||
events = _emit_mcp_tool_result(content, flow)
|
||||
event_types = [e.type for e in events]
|
||||
|
||||
assert EventType.TOOL_CALL_END in event_types
|
||||
assert EventType.TOOL_CALL_RESULT in event_types
|
||||
assert EventType.STATE_SNAPSHOT in event_types
|
||||
assert flow.current_state == {"mcp_ok": True}
|
||||
|
||||
def test_mcp_tool_result_without_state_emits_no_snapshot(self):
|
||||
content = Content.from_mcp_server_tool_result(
|
||||
call_id="mcp_1",
|
||||
output="server result",
|
||||
)
|
||||
flow = FlowState()
|
||||
|
||||
events = _emit_mcp_tool_result(content, flow)
|
||||
assert all(e.type != EventType.STATE_SNAPSHOT for e in events)
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Anthropic integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260402"
|
||||
version = "1.0.0b260409"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.0.0,<2",
|
||||
"agent-framework-core>=1.0.1,<2",
|
||||
"anthropic>=0.80.0,<0.80.1",
|
||||
]
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Azure AI Search integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260402"
|
||||
version = "1.0.0b260409"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.0.0,<2",
|
||||
"agent-framework-core>=1.0.1,<2",
|
||||
"azure-search-documents>=11.7.0b2,<11.7.0b3",
|
||||
]
|
||||
|
||||
|
||||
@@ -43,9 +43,34 @@ class CosmosCheckpointStorage:
|
||||
``FileCheckpointStorage``, allowing full Python object fidelity for
|
||||
complex workflow state while keeping the document structure human-readable.
|
||||
|
||||
SECURITY WARNING: Checkpoints use pickle for data serialization. Only load
|
||||
checkpoints from trusted sources. Loading a malicious checkpoint can execute
|
||||
arbitrary code.
|
||||
Security warning: checkpoints use pickle for non-JSON-native values. Loading
|
||||
checkpoints from untrusted sources is unsafe and can execute arbitrary code
|
||||
during deserialization. The built-in deserialization restrictions reduce risk,
|
||||
but they do not make untrusted checkpoints safe to load. Extending
|
||||
``allowed_checkpoint_types`` may further increase risk and should only be done
|
||||
for trusted application types.
|
||||
|
||||
By default, checkpoint deserialization is restricted to a built-in set of safe
|
||||
Python types (primitives, datetime, uuid, ...) and all ``agent_framework``
|
||||
internal types. To allow additional application-specific types, pass them via
|
||||
the ``allowed_checkpoint_types`` parameter using ``"module:qualname"`` format.
|
||||
|
||||
Example:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from azure.identity.aio import DefaultAzureCredential
|
||||
from agent_framework_azure_cosmos import CosmosCheckpointStorage
|
||||
|
||||
storage = CosmosCheckpointStorage(
|
||||
endpoint="https://my-account.documents.azure.com:443/",
|
||||
credential=DefaultAzureCredential(),
|
||||
database_name="agent-db",
|
||||
container_name="checkpoints",
|
||||
allowed_checkpoint_types=[
|
||||
"my_app.models:MyState",
|
||||
],
|
||||
)
|
||||
|
||||
The database and container are created automatically on first use
|
||||
if they do not already exist. The container uses partition key
|
||||
@@ -97,6 +122,7 @@ class CosmosCheckpointStorage:
|
||||
container_client: ContainerProxy | None = None,
|
||||
env_file_path: str | None = None,
|
||||
env_file_encoding: str | None = None,
|
||||
allowed_checkpoint_types: list[str] | None = None,
|
||||
) -> None:
|
||||
"""Initialize the Azure Cosmos DB checkpoint storage.
|
||||
|
||||
@@ -129,10 +155,15 @@ class CosmosCheckpointStorage:
|
||||
container_client: Pre-created Cosmos container client.
|
||||
env_file_path: Path to environment file for loading settings.
|
||||
env_file_encoding: Encoding of the environment file.
|
||||
allowed_checkpoint_types: Additional types (beyond the built-in safe set
|
||||
and framework types) that are permitted during checkpoint
|
||||
deserialization. Each entry should be a ``"module:qualname"``
|
||||
string (e.g., ``"my_app.models:MyState"``).
|
||||
"""
|
||||
self._cosmos_client: CosmosClient | None = cosmos_client
|
||||
self._container_proxy: ContainerProxy | None = container_client
|
||||
self._owns_client = False
|
||||
self._allowed_types: frozenset[str] = frozenset(allowed_checkpoint_types or [])
|
||||
|
||||
if self._container_proxy is not None:
|
||||
self.database_name: str = database_name or ""
|
||||
@@ -401,8 +432,7 @@ class CosmosCheckpointStorage:
|
||||
partition_key=PartitionKey(path="/workflow_name"),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _document_to_checkpoint(document: dict[str, Any]) -> WorkflowCheckpoint:
|
||||
def _document_to_checkpoint(self, document: dict[str, Any]) -> WorkflowCheckpoint:
|
||||
"""Convert a Cosmos DB document back to a WorkflowCheckpoint.
|
||||
|
||||
Strips Cosmos DB system properties (``_rid``, ``_self``, ``_etag``,
|
||||
@@ -413,7 +443,7 @@ class CosmosCheckpointStorage:
|
||||
cosmos_keys = {"id", "_rid", "_self", "_etag", "_attachments", "_ts"}
|
||||
cleaned = {k: v for k, v in document.items() if k not in cosmos_keys}
|
||||
|
||||
decoded = decode_checkpoint_value(cleaned)
|
||||
decoded = decode_checkpoint_value(cleaned, allowed_types=self._allowed_types)
|
||||
return WorkflowCheckpoint.from_dict(decoded)
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Azure Cosmos DB history provider integration for Microsoft Agent
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260402"
|
||||
version = "1.0.0b260409"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.0.0,<2",
|
||||
"agent-framework-core>=1.0.1,<2",
|
||||
"azure-cosmos>=4.3.0,<5",
|
||||
]
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import os
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
@@ -595,3 +596,142 @@ async def test_cosmos_checkpoint_storage_roundtrip_with_emulator() -> None:
|
||||
finally:
|
||||
with suppress(Exception):
|
||||
await cosmos_client.delete_database(database_name)
|
||||
|
||||
|
||||
# --- Tests for allowed_checkpoint_types ---
|
||||
|
||||
|
||||
@dataclass
|
||||
class _AppState:
|
||||
"""Application-defined state type used to test allowed_checkpoint_types."""
|
||||
|
||||
label: str
|
||||
count: int
|
||||
|
||||
|
||||
_APP_STATE_TYPE_KEY = f"{_AppState.__module__}:{_AppState.__qualname__}"
|
||||
|
||||
|
||||
def _make_checkpoint_with_state(state: dict[str, Any]) -> WorkflowCheckpoint:
|
||||
"""Create a checkpoint with custom state for serialization tests."""
|
||||
return WorkflowCheckpoint(
|
||||
workflow_name="test-workflow",
|
||||
graph_signature_hash="abc123",
|
||||
timestamp="2025-01-01T00:00:00+00:00",
|
||||
state=state,
|
||||
iteration_count=1,
|
||||
)
|
||||
|
||||
|
||||
async def test_init_accepts_allowed_checkpoint_types(mock_container: MagicMock) -> None:
|
||||
"""CosmosCheckpointStorage.__init__ accepts allowed_checkpoint_types."""
|
||||
storage = CosmosCheckpointStorage(
|
||||
container_client=mock_container,
|
||||
allowed_checkpoint_types=["some.module:SomeType"],
|
||||
)
|
||||
assert storage is not None
|
||||
|
||||
|
||||
async def test_load_allows_builtin_safe_types(mock_container: MagicMock) -> None:
|
||||
"""Built-in safe types load without opt-in via allowed_checkpoint_types."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
checkpoint = _make_checkpoint_with_state({
|
||||
"ts": datetime(2025, 1, 1, tzinfo=timezone.utc),
|
||||
"tags": {1, 2, 3},
|
||||
})
|
||||
doc = _checkpoint_to_cosmos_document(checkpoint)
|
||||
mock_container.query_items.return_value = _to_async_iter([doc])
|
||||
|
||||
storage = CosmosCheckpointStorage(container_client=mock_container)
|
||||
loaded = await storage.load(checkpoint.checkpoint_id)
|
||||
|
||||
assert loaded.state["ts"] == datetime(2025, 1, 1, tzinfo=timezone.utc)
|
||||
assert loaded.state["tags"] == {1, 2, 3}
|
||||
|
||||
|
||||
async def test_load_blocks_unlisted_app_type(mock_container: MagicMock) -> None:
|
||||
"""Application types are blocked when not listed in allowed_checkpoint_types."""
|
||||
checkpoint = _make_checkpoint_with_state({"data": _AppState(label="x", count=1)})
|
||||
doc = _checkpoint_to_cosmos_document(checkpoint)
|
||||
mock_container.query_items.return_value = _to_async_iter([doc])
|
||||
|
||||
storage = CosmosCheckpointStorage(container_client=mock_container)
|
||||
|
||||
with pytest.raises(WorkflowCheckpointException, match="deserialization blocked"):
|
||||
await storage.load(checkpoint.checkpoint_id)
|
||||
|
||||
|
||||
async def test_load_allows_listed_app_type(mock_container: MagicMock) -> None:
|
||||
"""Application types are allowed when listed in allowed_checkpoint_types."""
|
||||
checkpoint = _make_checkpoint_with_state({"data": _AppState(label="ok", count=7)})
|
||||
doc = _checkpoint_to_cosmos_document(checkpoint)
|
||||
mock_container.query_items.return_value = _to_async_iter([doc])
|
||||
|
||||
storage = CosmosCheckpointStorage(
|
||||
container_client=mock_container,
|
||||
allowed_checkpoint_types=[_APP_STATE_TYPE_KEY],
|
||||
)
|
||||
loaded = await storage.load(checkpoint.checkpoint_id)
|
||||
|
||||
assert isinstance(loaded.state["data"], _AppState)
|
||||
assert loaded.state["data"].label == "ok"
|
||||
assert loaded.state["data"].count == 7
|
||||
|
||||
|
||||
async def test_list_checkpoints_blocks_unlisted_app_type(mock_container: MagicMock) -> None:
|
||||
"""list_checkpoints skips documents with unlisted application types."""
|
||||
checkpoint = _make_checkpoint_with_state({"data": _AppState(label="x", count=1)})
|
||||
doc = _checkpoint_to_cosmos_document(checkpoint)
|
||||
mock_container.query_items.return_value = _to_async_iter([doc])
|
||||
|
||||
storage = CosmosCheckpointStorage(container_client=mock_container)
|
||||
results = await storage.list_checkpoints(workflow_name="test-workflow")
|
||||
|
||||
# The document is skipped (logged as warning) because the type is blocked
|
||||
assert len(results) == 0
|
||||
|
||||
|
||||
async def test_list_checkpoints_allows_listed_app_type(mock_container: MagicMock) -> None:
|
||||
"""list_checkpoints decodes documents with listed application types."""
|
||||
checkpoint = _make_checkpoint_with_state({"data": _AppState(label="ok", count=3)})
|
||||
doc = _checkpoint_to_cosmos_document(checkpoint)
|
||||
mock_container.query_items.return_value = _to_async_iter([doc])
|
||||
|
||||
storage = CosmosCheckpointStorage(
|
||||
container_client=mock_container,
|
||||
allowed_checkpoint_types=[_APP_STATE_TYPE_KEY],
|
||||
)
|
||||
results = await storage.list_checkpoints(workflow_name="test-workflow")
|
||||
|
||||
assert len(results) == 1
|
||||
assert isinstance(results[0].state["data"], _AppState)
|
||||
|
||||
|
||||
async def test_get_latest_blocks_unlisted_app_type(mock_container: MagicMock) -> None:
|
||||
"""get_latest raises when the checkpoint contains an unlisted application type."""
|
||||
checkpoint = _make_checkpoint_with_state({"data": _AppState(label="x", count=1)})
|
||||
doc = _checkpoint_to_cosmos_document(checkpoint)
|
||||
mock_container.query_items.return_value = _to_async_iter([doc])
|
||||
|
||||
storage = CosmosCheckpointStorage(container_client=mock_container)
|
||||
|
||||
with pytest.raises(WorkflowCheckpointException, match="deserialization blocked"):
|
||||
await storage.get_latest(workflow_name="test-workflow")
|
||||
|
||||
|
||||
async def test_get_latest_allows_listed_app_type(mock_container: MagicMock) -> None:
|
||||
"""get_latest decodes checkpoints with listed application types."""
|
||||
checkpoint = _make_checkpoint_with_state({"data": _AppState(label="latest", count=9)})
|
||||
doc = _checkpoint_to_cosmos_document(checkpoint)
|
||||
mock_container.query_items.return_value = _to_async_iter([doc])
|
||||
|
||||
storage = CosmosCheckpointStorage(
|
||||
container_client=mock_container,
|
||||
allowed_checkpoint_types=[_APP_STATE_TYPE_KEY],
|
||||
)
|
||||
result = await storage.get_latest(workflow_name="test-workflow")
|
||||
|
||||
assert result is not None
|
||||
assert isinstance(result.state["data"], _AppState)
|
||||
assert result.state["data"].label == "latest"
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Azure Functions integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260402"
|
||||
version = "1.0.0b260409"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -22,7 +22,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.0.0,<2",
|
||||
"agent-framework-core>=1.0.1,<2",
|
||||
"agent-framework-durabletask",
|
||||
"azure-functions>=1.24.0,<2",
|
||||
"azure-functions-durable>=1.3.1,<2",
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Amazon Bedrock integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260402"
|
||||
version = "1.0.0b260409"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.0.0,<2",
|
||||
"agent-framework-core>=1.0.1,<2",
|
||||
"boto3>=1.35.0,<2.0.0",
|
||||
"botocore>=1.35.0,<2.0.0",
|
||||
]
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "OpenAI ChatKit integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260402"
|
||||
version = "1.0.0b260409"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -22,7 +22,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.0.0,<2",
|
||||
"agent-framework-core>=1.0.1,<2",
|
||||
"openai-chatkit>=1.4.1,<2.0.0",
|
||||
]
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Claude Agent SDK integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260402"
|
||||
version = "1.0.0b260409"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.0.0,<2",
|
||||
"agent-framework-core>=1.0.1,<2",
|
||||
"claude-agent-sdk>=0.1.36,<0.1.49",
|
||||
]
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Copilot Studio integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260402"
|
||||
version = "1.0.0b260409"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.0.0,<2",
|
||||
"agent-framework-core>=1.0.1,<2",
|
||||
"microsoft-agents-copilotstudio-client>=0.3.1,<0.3.2",
|
||||
]
|
||||
|
||||
|
||||
@@ -486,8 +486,8 @@ YAML_KV_RE = re.compile(
|
||||
)
|
||||
|
||||
# Validates skill names: lowercase letters, numbers, hyphens only;
|
||||
# must not start or end with a hyphen.
|
||||
VALID_NAME_RE = re.compile(r"^[a-z0-9]([a-z0-9\-]*[a-z0-9])?$")
|
||||
# must not start or end with a hyphen, and must not contain consecutive hyphens.
|
||||
VALID_NAME_RE = re.compile(r"^[a-z0-9]([a-z0-9]*-[a-z0-9])*[a-z0-9]*$")
|
||||
|
||||
# Default system prompt template for advertising available skills to the model.
|
||||
# Use {skills} as the placeholder for the generated skills XML list.
|
||||
@@ -1156,7 +1156,8 @@ def _validate_skill_metadata(
|
||||
if len(name) > MAX_NAME_LENGTH or not VALID_NAME_RE.match(name):
|
||||
return (
|
||||
f"Skill from '{source}' has an invalid name '{name}': Must be {MAX_NAME_LENGTH} characters or fewer, "
|
||||
"using only lowercase letters, numbers, and hyphens, and must not start or end with a hyphen."
|
||||
"using only lowercase letters, numbers, and hyphens, and must not start or end with a hyphen "
|
||||
"or contain consecutive hyphens."
|
||||
)
|
||||
|
||||
if not description or not description.strip():
|
||||
@@ -1241,6 +1242,17 @@ def _read_and_parse_skill_file(
|
||||
return None
|
||||
|
||||
name, description = result
|
||||
|
||||
dir_name = Path(skill_dir_path).name
|
||||
if name != dir_name:
|
||||
logger.error(
|
||||
"SKILL.md at '%s' has frontmatter name '%s' that does not match the directory name '%s'; skipping.",
|
||||
skill_file,
|
||||
name,
|
||||
dir_name,
|
||||
)
|
||||
return None
|
||||
|
||||
return name, description, content
|
||||
|
||||
|
||||
|
||||
@@ -26,6 +26,28 @@ USER_AGENT_KEY: Final[str] = "User-Agent"
|
||||
HTTP_USER_AGENT: Final[str] = "agent-framework-python"
|
||||
AGENT_FRAMEWORK_USER_AGENT = f"{HTTP_USER_AGENT}/{version_info}" # type: ignore[has-type]
|
||||
|
||||
_user_agent_prefixes: list[str] = []
|
||||
|
||||
|
||||
def append_to_user_agent(prefix: str) -> None:
|
||||
"""Prepend a prefix to the agent framework user agent string.
|
||||
|
||||
This is useful for hosting layers that want to identify themselves in telemetry.
|
||||
Duplicate prefixes are ignored.
|
||||
|
||||
Args:
|
||||
prefix: The prefix to prepend (e.g. "foundry-hosting").
|
||||
"""
|
||||
if prefix and prefix not in _user_agent_prefixes:
|
||||
_user_agent_prefixes.append(prefix)
|
||||
|
||||
|
||||
def _get_user_agent() -> str:
|
||||
"""Return the full user agent string including any prepended prefixes."""
|
||||
if not _user_agent_prefixes:
|
||||
return AGENT_FRAMEWORK_USER_AGENT
|
||||
return f"{'/'.join(_user_agent_prefixes)}/{AGENT_FRAMEWORK_USER_AGENT}"
|
||||
|
||||
|
||||
def prepend_agent_framework_to_user_agent(headers: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""Prepend "agent-framework" to the User-Agent in the headers.
|
||||
@@ -57,12 +79,9 @@ def prepend_agent_framework_to_user_agent(headers: dict[str, Any] | None = None)
|
||||
"""
|
||||
if not IS_TELEMETRY_ENABLED:
|
||||
return headers or {}
|
||||
user_agent = _get_user_agent()
|
||||
if not headers:
|
||||
return {USER_AGENT_KEY: AGENT_FRAMEWORK_USER_AGENT}
|
||||
headers[USER_AGENT_KEY] = (
|
||||
f"{AGENT_FRAMEWORK_USER_AGENT} {headers[USER_AGENT_KEY]}"
|
||||
if USER_AGENT_KEY in headers
|
||||
else AGENT_FRAMEWORK_USER_AGENT
|
||||
)
|
||||
return {USER_AGENT_KEY: user_agent}
|
||||
headers[USER_AGENT_KEY] = f"{user_agent} {headers[USER_AGENT_KEY]}" if USER_AGENT_KEY in headers else user_agent
|
||||
|
||||
return headers
|
||||
|
||||
@@ -2816,6 +2816,7 @@ class ResponseStream(AsyncIterable[UpdateT], Generic[UpdateT, FinalT]):
|
||||
cleanup_hooks if cleanup_hooks is not None else []
|
||||
)
|
||||
self._cleanup_run: bool = False
|
||||
self._stream_error: Exception | None = None
|
||||
self._inner_stream: ResponseStream[Any, Any] | None = None
|
||||
self._inner_stream_source: ResponseStream[Any, Any] | Awaitable[ResponseStream[Any, Any]] | None = None
|
||||
self._wrap_inner: bool = False
|
||||
@@ -2948,8 +2949,12 @@ class ResponseStream(AsyncIterable[UpdateT], Generic[UpdateT, FinalT]):
|
||||
await self._run_cleanup_hooks()
|
||||
await self.get_final_response()
|
||||
raise
|
||||
except Exception:
|
||||
await self._run_cleanup_hooks()
|
||||
except Exception as exc:
|
||||
self._stream_error = exc
|
||||
try:
|
||||
await self._run_cleanup_hooks()
|
||||
finally:
|
||||
self._stream_error = None
|
||||
raise
|
||||
if self._map_update is not None:
|
||||
update = self._map_update(update) # type: ignore[assignment]
|
||||
|
||||
@@ -119,15 +119,11 @@ class WorkflowAgent(BaseAgent):
|
||||
if not any(is_type_compatible(list[Message], input_type) for input_type in start_executor.input_types):
|
||||
raise ValueError("Workflow's start executor cannot handle list[Message]")
|
||||
|
||||
resolved_context_providers = list(context_providers) if context_providers is not None else []
|
||||
if not resolved_context_providers:
|
||||
resolved_context_providers.append(InMemoryHistoryProvider())
|
||||
|
||||
super().__init__(
|
||||
id=id,
|
||||
name=name,
|
||||
description=description,
|
||||
context_providers=resolved_context_providers,
|
||||
context_providers=context_providers,
|
||||
**kwargs,
|
||||
)
|
||||
self._workflow: Workflow = workflow
|
||||
@@ -261,6 +257,15 @@ class WorkflowAgent(BaseAgent):
|
||||
An AgentResponse representing the workflow execution results.
|
||||
"""
|
||||
input_messages = normalize_messages_input(messages)
|
||||
|
||||
if (
|
||||
not any(
|
||||
provider.load_messages for provider in self.context_providers if isinstance(provider, HistoryProvider)
|
||||
)
|
||||
and session is not None
|
||||
):
|
||||
self.context_providers.append(InMemoryHistoryProvider())
|
||||
|
||||
provider_session = session
|
||||
if provider_session is None and self.context_providers:
|
||||
provider_session = AgentSession()
|
||||
@@ -332,6 +337,15 @@ class WorkflowAgent(BaseAgent):
|
||||
AgentResponseUpdate objects representing the workflow execution progress.
|
||||
"""
|
||||
input_messages = normalize_messages_input(messages)
|
||||
|
||||
if (
|
||||
not any(
|
||||
provider.load_messages for provider in self.context_providers if isinstance(provider, HistoryProvider)
|
||||
)
|
||||
and session is not None
|
||||
):
|
||||
self.context_providers.append(InMemoryHistoryProvider())
|
||||
|
||||
provider_session = session
|
||||
if provider_session is None and self.context_providers:
|
||||
provider_session = AgentSession()
|
||||
|
||||
@@ -244,14 +244,39 @@ class FileCheckpointStorage:
|
||||
is serialized using pickle and embedded as base64-encoded strings within the JSON. This allows
|
||||
for human-readable checkpoint files while preserving the ability to store complex Python objects.
|
||||
|
||||
SECURITY WARNING: Checkpoints use pickle for data serialization. Only load checkpoints
|
||||
from trusted sources. Loading a malicious checkpoint file can execute arbitrary code.
|
||||
By default, checkpoint deserialization is restricted to a built-in set of safe
|
||||
Python types (primitives, datetime, uuid, ...) and all ``agent_framework``
|
||||
internal types. To allow additional application-specific types, pass them via
|
||||
the ``allowed_checkpoint_types`` parameter using ``"module:qualname"`` format.
|
||||
|
||||
Example::
|
||||
|
||||
storage = FileCheckpointStorage(
|
||||
"/tmp/checkpoints",
|
||||
allowed_checkpoint_types=[
|
||||
"my_app.models:MyState",
|
||||
],
|
||||
)
|
||||
"""
|
||||
|
||||
def __init__(self, storage_path: str | Path):
|
||||
"""Initialize the file storage."""
|
||||
def __init__(
|
||||
self,
|
||||
storage_path: str | Path,
|
||||
*,
|
||||
allowed_checkpoint_types: list[str] | None = None,
|
||||
) -> None:
|
||||
"""Initialize the file storage.
|
||||
|
||||
Args:
|
||||
storage_path: Directory path where checkpoint files will be stored.
|
||||
allowed_checkpoint_types: Additional types (beyond the built-in safe set
|
||||
and framework types) that are permitted during checkpoint
|
||||
deserialization. Each entry should be a ``"module:qualname"``
|
||||
string (e.g., ``"my_app.models:MyState"``).
|
||||
"""
|
||||
self.storage_path = Path(storage_path)
|
||||
self.storage_path.mkdir(parents=True, exist_ok=True)
|
||||
self._allowed_types: frozenset[str] = frozenset(allowed_checkpoint_types or [])
|
||||
logger.info(f"Initialized file checkpoint storage at {self.storage_path}")
|
||||
|
||||
def _validate_file_path(self, checkpoint_id: CheckpointID) -> Path:
|
||||
@@ -327,7 +352,7 @@ class FileCheckpointStorage:
|
||||
from ._checkpoint_encoding import decode_checkpoint_value
|
||||
|
||||
try:
|
||||
decoded_checkpoint_dict = decode_checkpoint_value(encoded_checkpoint)
|
||||
decoded_checkpoint_dict = decode_checkpoint_value(encoded_checkpoint, allowed_types=self._allowed_types)
|
||||
except WorkflowCheckpointException:
|
||||
raise
|
||||
checkpoint = WorkflowCheckpoint.from_dict(decoded_checkpoint_dict)
|
||||
@@ -352,7 +377,9 @@ class FileCheckpointStorage:
|
||||
encoded_checkpoint = json.load(f)
|
||||
from ._checkpoint_encoding import decode_checkpoint_value
|
||||
|
||||
decoded_checkpoint_dict = decode_checkpoint_value(encoded_checkpoint)
|
||||
decoded_checkpoint_dict = decode_checkpoint_value(
|
||||
encoded_checkpoint, allowed_types=self._allowed_types
|
||||
)
|
||||
checkpoint = WorkflowCheckpoint.from_dict(decoded_checkpoint_dict)
|
||||
if checkpoint.workflow_name == workflow_name:
|
||||
checkpoints.append(checkpoint)
|
||||
|
||||
@@ -1,14 +1,5 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import logging
|
||||
import pickle # nosec # noqa: S403
|
||||
from typing import Any
|
||||
|
||||
from ..exceptions import WorkflowCheckpointException
|
||||
|
||||
"""Checkpoint encoding using JSON structure with pickle+base64 for arbitrary data.
|
||||
|
||||
This hybrid approach provides:
|
||||
@@ -16,10 +7,23 @@ This hybrid approach provides:
|
||||
- Full Python object fidelity via pickle for data values (non-JSON-native types)
|
||||
- Base64 encoding to embed binary pickle data in JSON strings
|
||||
|
||||
SECURITY WARNING: Checkpoints use pickle for data serialization. Only load checkpoints
|
||||
from trusted sources. Loading a malicious checkpoint file can execute arbitrary code.
|
||||
When ``allowed_types`` is supplied to :func:`decode_checkpoint_value`, a
|
||||
``RestrictedUnpickler`` is used that limits which classes may be instantiated
|
||||
during deserialization. The default built-in safe set covers common Python
|
||||
value types (primitives, datetime, uuid, ...) and all ``agent_framework``
|
||||
internal types. Callers can extend the set by passing additional
|
||||
``"module:qualname"`` strings.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import io
|
||||
import logging
|
||||
import pickle # nosec # noqa: S403
|
||||
from typing import Any
|
||||
|
||||
from ..exceptions import WorkflowCheckpointException
|
||||
|
||||
logger = logging.getLogger("agent_framework")
|
||||
|
||||
@@ -30,6 +34,82 @@ _TYPE_MARKER = "__type__"
|
||||
# Types that are natively JSON-serializable and don't need pickling
|
||||
_JSON_NATIVE_TYPES = (str, int, float, bool, type(None))
|
||||
|
||||
# Module prefix for framework-internal types that are always allowed
|
||||
_FRAMEWORK_MODULE_PREFIX = "agent_framework."
|
||||
|
||||
# Built-in types considered safe for checkpoint deserialization.
|
||||
# Each entry is a ``module:qualname`` string matching the format produced by
|
||||
# :func:`_type_to_key`. These are the classes for which pickle's
|
||||
# ``find_class`` will be called when unpickling common Python value types.
|
||||
_BUILTIN_ALLOWED_TYPE_KEYS: frozenset[str] = frozenset({
|
||||
# builtins
|
||||
"builtins:object",
|
||||
"builtins:complex",
|
||||
"builtins:range",
|
||||
"builtins:slice",
|
||||
"builtins:int",
|
||||
"builtins:float",
|
||||
"builtins:str",
|
||||
"builtins:bytes",
|
||||
"builtins:bytearray",
|
||||
"builtins:bool",
|
||||
"builtins:set",
|
||||
"builtins:frozenset",
|
||||
"builtins:list",
|
||||
"builtins:dict",
|
||||
"builtins:tuple",
|
||||
"builtins:type",
|
||||
# getattr is used by pickle to reconstruct enum members
|
||||
"builtins:getattr",
|
||||
# copyreg helpers used by pickle for object reconstruction
|
||||
"copyreg:_reconstructor",
|
||||
# datetime
|
||||
"datetime:datetime",
|
||||
"datetime:date",
|
||||
"datetime:time",
|
||||
"datetime:timedelta",
|
||||
"datetime:timezone",
|
||||
# uuid
|
||||
"uuid:UUID",
|
||||
# decimal
|
||||
"decimal:Decimal",
|
||||
# collections
|
||||
"collections:OrderedDict",
|
||||
"collections:defaultdict",
|
||||
"collections:deque",
|
||||
})
|
||||
|
||||
|
||||
class _RestrictedUnpickler(pickle.Unpickler): # noqa: S301
|
||||
"""Unpickler that restricts which classes may be instantiated.
|
||||
|
||||
Only classes whose ``module:qualname`` key appears in the combined allow
|
||||
set (built-in safe types + framework types + caller-specified extras) are
|
||||
permitted. All other classes raise :class:`pickle.UnpicklingError`.
|
||||
"""
|
||||
|
||||
def __init__(self, data: bytes, allowed_types: frozenset[str]) -> None:
|
||||
super().__init__(io.BytesIO(data))
|
||||
self._allowed_types = allowed_types
|
||||
|
||||
def find_class(self, module: str, name: str) -> type:
|
||||
type_key = f"{module}:{name}"
|
||||
|
||||
if (
|
||||
type_key in _BUILTIN_ALLOWED_TYPE_KEYS
|
||||
or type_key in self._allowed_types
|
||||
or module.startswith(_FRAMEWORK_MODULE_PREFIX)
|
||||
):
|
||||
return super().find_class(module, name) # type: ignore[no-any-return] # nosec
|
||||
|
||||
raise pickle.UnpicklingError(
|
||||
f"Checkpoint deserialization blocked for type '{type_key}'. "
|
||||
f"To allow this type, either include its 'module:qualname' key in the "
|
||||
f"'allowed_types' set passed to 'decode_checkpoint_value', or add it to "
|
||||
f"'allowed_checkpoint_types' on your checkpoint storage "
|
||||
f"(for example, 'FileCheckpointStorage.allowed_checkpoint_types')."
|
||||
)
|
||||
|
||||
|
||||
def encode_checkpoint_value(value: Any) -> Any:
|
||||
"""Encode a Python value for checkpoint storage.
|
||||
@@ -48,29 +128,51 @@ def encode_checkpoint_value(value: Any) -> Any:
|
||||
return _encode(value)
|
||||
|
||||
|
||||
def decode_checkpoint_value(value: Any) -> Any:
|
||||
def decode_checkpoint_value(value: Any, *, allowed_types: frozenset[str] | None = None) -> Any:
|
||||
"""Decode a value from checkpoint storage.
|
||||
|
||||
Reverses the encoding performed by encode_checkpoint_value.
|
||||
Pickled values (identified by _PICKLE_MARKER) are decoded and unpickled.
|
||||
|
||||
WARNING: Only call this with trusted data. Pickle can execute
|
||||
arbitrary code during deserialization. The post-unpickle type verification
|
||||
detects accidental corruption or type mismatches, but cannot prevent
|
||||
arbitrary code execution from malicious pickle payloads.
|
||||
|
||||
Args:
|
||||
value: A JSON-deserialized value from checkpoint storage.
|
||||
allowed_types: If not ``None``, restrict pickle deserialization to the
|
||||
built-in safe set, framework types, and the types listed here.
|
||||
Each entry should use ``"module:qualname"`` format — that is, the
|
||||
dotted module path followed by a colon and the class
|
||||
``__qualname__``. For example, given a user-defined class::
|
||||
|
||||
# my_app/models.py
|
||||
class MyState: ...
|
||||
|
||||
the corresponding entry would be ``"my_app.models:MyState"``::
|
||||
|
||||
decode_checkpoint_value(
|
||||
data,
|
||||
allowed_types=frozenset({"my_app.models:MyState"}),
|
||||
)
|
||||
|
||||
When using :class:`FileCheckpointStorage`, pass the same strings
|
||||
via ``allowed_checkpoint_types``::
|
||||
|
||||
storage = FileCheckpointStorage(
|
||||
"/tmp/checkpoints",
|
||||
allowed_checkpoint_types=["my_app.models:MyState"],
|
||||
)
|
||||
|
||||
If ``None``, no restriction is applied (backward-compatible
|
||||
behavior).
|
||||
|
||||
Returns:
|
||||
The original Python value.
|
||||
|
||||
Raises:
|
||||
WorkflowCheckpointException: If the unpickled object's type doesn't match
|
||||
the recorded type, indicating corruption, or if the base64/pickle
|
||||
data is malformed.
|
||||
the recorded type, indicating corruption, if the base64/pickle
|
||||
data is malformed, or if a disallowed type is encountered during
|
||||
restricted deserialization.
|
||||
"""
|
||||
return _decode(value)
|
||||
return _decode(value, allowed_types=allowed_types)
|
||||
|
||||
|
||||
def _encode(value: Any) -> Any:
|
||||
@@ -94,7 +196,7 @@ def _encode(value: Any) -> Any:
|
||||
}
|
||||
|
||||
|
||||
def _decode(value: Any) -> Any:
|
||||
def _decode(value: Any, *, allowed_types: frozenset[str] | None = None) -> Any:
|
||||
"""Recursively decode a value from JSON storage."""
|
||||
# JSON-native types pass through
|
||||
if isinstance(value, _JSON_NATIVE_TYPES):
|
||||
@@ -104,16 +206,16 @@ def _decode(value: Any) -> Any:
|
||||
if isinstance(value, dict):
|
||||
# Pickled value: decode, unpickle, and verify type
|
||||
if _PICKLE_MARKER in value and _TYPE_MARKER in value:
|
||||
obj = _base64_to_unpickle(value[_PICKLE_MARKER]) # type: ignore
|
||||
obj = _base64_to_unpickle(value[_PICKLE_MARKER], allowed_types=allowed_types) # type: ignore
|
||||
_verify_type(obj, value.get(_TYPE_MARKER)) # type: ignore
|
||||
return obj
|
||||
|
||||
# Regular dict: decode values recursively
|
||||
return {k: _decode(v) for k, v in value.items()} # type: ignore
|
||||
return {k: _decode(v, allowed_types=allowed_types) for k, v in value.items()} # type: ignore
|
||||
|
||||
# Handle encoded lists
|
||||
if isinstance(value, list):
|
||||
return [_decode(item) for item in value] # type: ignore
|
||||
return [_decode(item, allowed_types=allowed_types) for item in value] # type: ignore
|
||||
|
||||
return value
|
||||
|
||||
@@ -148,15 +250,23 @@ def _pickle_to_base64(value: Any) -> str:
|
||||
return base64.b64encode(pickled).decode("ascii")
|
||||
|
||||
|
||||
def _base64_to_unpickle(encoded: str) -> Any:
|
||||
def _base64_to_unpickle(encoded: str, *, allowed_types: frozenset[str] | None = None) -> Any:
|
||||
"""Decode base64 string and unpickle.
|
||||
|
||||
Args:
|
||||
encoded: Base64-encoded pickle data.
|
||||
allowed_types: If not ``None``, use restricted unpickling that only
|
||||
permits built-in safe types, framework types, and the specified
|
||||
extra types.
|
||||
|
||||
Raises:
|
||||
WorkflowCheckpointException: If the base64 data is corrupted or the pickle
|
||||
format is incompatible.
|
||||
WorkflowCheckpointException: If the base64 data is corrupted, the pickle
|
||||
format is incompatible, or a disallowed type is encountered.
|
||||
"""
|
||||
try:
|
||||
pickled = base64.b64decode(encoded.encode("ascii"))
|
||||
if allowed_types is not None:
|
||||
return _RestrictedUnpickler(pickled, allowed_types).load()
|
||||
return pickle.loads(pickled) # nosec # noqa: S301
|
||||
except Exception as exc:
|
||||
raise WorkflowCheckpointException(f"Failed to decode pickled checkpoint data: {exc}") from exc
|
||||
|
||||
@@ -7,10 +7,13 @@ This module lazily re-exports objects from:
|
||||
|
||||
Supported classes and functions:
|
||||
- AgentFrameworkAgent
|
||||
- AgentFrameworkWorkflow
|
||||
- AGUIChatClient
|
||||
- AGUIEventConverter
|
||||
- AGUIHttpService
|
||||
- add_agent_framework_fastapi_endpoint
|
||||
- state_update
|
||||
- __version__
|
||||
"""
|
||||
|
||||
import importlib
|
||||
@@ -23,6 +26,10 @@ _IMPORTS = [
|
||||
"AgentFrameworkWorkflow",
|
||||
"add_agent_framework_fastapi_endpoint",
|
||||
"AGUIChatClient",
|
||||
"AGUIEventConverter",
|
||||
"AGUIHttpService",
|
||||
"state_update",
|
||||
"__version__",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ from agent_framework_ag_ui import (
|
||||
AGUIHttpService,
|
||||
__version__,
|
||||
add_agent_framework_fastapi_endpoint,
|
||||
state_update,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
@@ -18,4 +19,5 @@ __all__ = [
|
||||
"AgentFrameworkWorkflow",
|
||||
"__version__",
|
||||
"add_agent_framework_fastapi_endpoint",
|
||||
"state_update",
|
||||
]
|
||||
|
||||
@@ -1323,6 +1323,12 @@ class ChatTelemetryLayer(Generic[OptionsCoT]):
|
||||
from ._types import ChatResponse
|
||||
|
||||
try:
|
||||
if result_stream._stream_error is not None: # pyright: ignore[reportPrivateUsage]
|
||||
# Stream errored; skip get_final_response() to avoid firing
|
||||
# result hooks such as after_run context providers on error
|
||||
# paths. Capture the error on the span before returning.
|
||||
capture_exception(span=span, exception=result_stream._stream_error, timestamp=time_ns()) # pyright: ignore[reportPrivateUsage]
|
||||
return
|
||||
response: ChatResponse[Any] = await result_stream.get_final_response()
|
||||
duration = duration_state.get("duration")
|
||||
response_attributes = _get_response_attributes(attributes, response)
|
||||
@@ -1579,6 +1585,12 @@ class AgentTelemetryLayer:
|
||||
from ._types import AgentResponse
|
||||
|
||||
try:
|
||||
if result_stream._stream_error is not None: # pyright: ignore[reportPrivateUsage]
|
||||
# Stream errored; skip get_final_response() to avoid firing
|
||||
# result hooks such as after_run context providers on error
|
||||
# paths. Capture the error on the span before returning.
|
||||
capture_exception(span=span, exception=result_stream._stream_error, timestamp=time_ns()) # pyright: ignore[reportPrivateUsage]
|
||||
return
|
||||
response: AgentResponse[Any] = await result_stream.get_final_response()
|
||||
duration = duration_state.get("duration")
|
||||
response_attributes = _get_response_attributes(
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Microsoft Agent Framework for building AI Agents with Python. Thi
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0"
|
||||
version = "1.0.1"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -34,14 +34,13 @@ all = [
|
||||
"mcp>=1.24.0,<2",
|
||||
"agent-framework-a2a",
|
||||
"agent-framework-ag-ui",
|
||||
"agent-framework-anthropic",
|
||||
"agent-framework-azure-ai-search",
|
||||
"agent-framework-azure-cosmos",
|
||||
"agent-framework-anthropic",
|
||||
"agent-framework-openai",
|
||||
"agent-framework-claude",
|
||||
"agent-framework-azurefunctions",
|
||||
"agent-framework-bedrock",
|
||||
"agent-framework-chatkit",
|
||||
"agent-framework-claude",
|
||||
"agent-framework-copilotstudio",
|
||||
"agent-framework-declarative",
|
||||
"agent-framework-devui",
|
||||
@@ -52,6 +51,7 @@ all = [
|
||||
"agent-framework-lab",
|
||||
"agent-framework-mem0",
|
||||
"agent-framework-ollama",
|
||||
"agent-framework-openai",
|
||||
"agent-framework-orchestrations",
|
||||
"agent-framework-purview",
|
||||
"agent-framework-redis",
|
||||
|
||||
@@ -296,6 +296,15 @@ class TestDiscoverAndLoadSkills:
|
||||
skills = _discover_file_skills([str(tmp_path)])
|
||||
assert len(skills) == 0
|
||||
|
||||
def test_skips_skill_with_name_directory_mismatch(self, tmp_path: Path) -> None:
|
||||
skill_dir = tmp_path / "wrong-dir-name"
|
||||
skill_dir.mkdir()
|
||||
(skill_dir / "SKILL.md").write_text(
|
||||
"---\nname: actual-skill-name\ndescription: A skill.\n---\nBody.", encoding="utf-8"
|
||||
)
|
||||
skills = _discover_file_skills([str(tmp_path)])
|
||||
assert len(skills) == 0
|
||||
|
||||
def test_deduplicates_skill_names(self, tmp_path: Path) -> None:
|
||||
dir1 = tmp_path / "dir1"
|
||||
dir2 = tmp_path / "dir2"
|
||||
@@ -904,6 +913,11 @@ class TestSkill:
|
||||
provider = SkillsProvider(skills=[invalid_skill])
|
||||
assert len(provider._skills) == 0
|
||||
|
||||
def test_name_with_consecutive_hyphens_skipped(self) -> None:
|
||||
invalid_skill = Skill(name="consecutive--hyphens", description="A skill.", content="Body")
|
||||
provider = SkillsProvider(skills=[invalid_skill])
|
||||
assert len(provider._skills) == 0
|
||||
|
||||
def test_name_too_long_skipped(self) -> None:
|
||||
invalid_skill = Skill(name="a" * 65, description="A skill.", content="Body")
|
||||
provider = SkillsProvider(skills=[invalid_skill])
|
||||
@@ -1421,6 +1435,11 @@ class TestValidateSkillMetadata:
|
||||
assert result is not None
|
||||
assert "invalid name" in result
|
||||
|
||||
def test_name_with_consecutive_hyphens(self) -> None:
|
||||
result = _validate_skill_metadata("consecutive--hyphens", "desc", "source")
|
||||
assert result is not None
|
||||
assert "invalid name" in result
|
||||
|
||||
def test_single_char_name(self) -> None:
|
||||
assert _validate_skill_metadata("a", "desc", "source") is None
|
||||
|
||||
@@ -1526,6 +1545,15 @@ class TestReadAndParseSkillFile:
|
||||
result = _read_and_parse_skill_file(str(skill_dir))
|
||||
assert result is None
|
||||
|
||||
def test_name_directory_mismatch_returns_none(self, tmp_path: Path) -> None:
|
||||
skill_dir = tmp_path / "wrong-dir-name"
|
||||
skill_dir.mkdir()
|
||||
(skill_dir / "SKILL.md").write_text(
|
||||
"---\nname: actual-skill-name\ndescription: A skill.\n---\nBody.", encoding="utf-8"
|
||||
)
|
||||
result = _read_and_parse_skill_file(str(skill_dir))
|
||||
assert result is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: _create_resource_element
|
||||
|
||||
@@ -1048,7 +1048,10 @@ async def test_file_checkpoint_storage_roundtrip_datetime():
|
||||
async def test_file_checkpoint_storage_roundtrip_dataclass():
|
||||
"""Test that dataclass objects roundtrip correctly via pickle encoding."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
storage = FileCheckpointStorage(temp_dir)
|
||||
storage = FileCheckpointStorage(
|
||||
temp_dir,
|
||||
allowed_checkpoint_types=["tests.workflow.test_checkpoint:_TestCustomData"],
|
||||
)
|
||||
|
||||
custom_obj = _TestCustomData(name="test", value=42, tags=["a", "b", "c"])
|
||||
|
||||
@@ -1238,7 +1241,10 @@ async def test_file_checkpoint_storage_roundtrip_messages_with_complex_data():
|
||||
async def test_file_checkpoint_storage_roundtrip_pending_request_info_events():
|
||||
"""Test that pending_request_info_events with WorkflowEvent objects roundtrip correctly."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
storage = FileCheckpointStorage(temp_dir)
|
||||
storage = FileCheckpointStorage(
|
||||
temp_dir,
|
||||
allowed_checkpoint_types=["tests.workflow.test_checkpoint:_TestToolApprovalRequest"],
|
||||
)
|
||||
|
||||
# Create request_info events using the proper WorkflowEvent factory
|
||||
event1 = WorkflowEvent.request_info(
|
||||
@@ -1300,7 +1306,13 @@ async def test_file_checkpoint_storage_roundtrip_pending_request_info_events():
|
||||
async def test_file_checkpoint_storage_roundtrip_full_checkpoint():
|
||||
"""Test complete WorkflowCheckpoint roundtrip with all fields populated using proper types."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
storage = FileCheckpointStorage(temp_dir)
|
||||
storage = FileCheckpointStorage(
|
||||
temp_dir,
|
||||
allowed_checkpoint_types=[
|
||||
"tests.workflow.test_checkpoint:_TestApprovalRequest",
|
||||
"tests.workflow.test_checkpoint:_TestExecutorState",
|
||||
],
|
||||
)
|
||||
|
||||
# Create proper WorkflowMessage objects
|
||||
msg1 = WorkflowMessage(data="msg1", source_id="s", target_id="t")
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Tests for restricted checkpoint deserialization.
|
||||
|
||||
These tests verify that persisted checkpoint loading uses a restricted
|
||||
unpickler by default:
|
||||
- Arbitrary callables are blocked during deserialization
|
||||
- __reduce__ payloads cannot execute code during deserialization
|
||||
- FileCheckpointStorage accepts allowed_checkpoint_types for extension
|
||||
- User-defined types are blocked unless explicitly allowed
|
||||
- Built-in safe types and framework types are always allowed
|
||||
"""
|
||||
|
||||
import base64
|
||||
import os
|
||||
import pickle
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_framework import WorkflowCheckpointException
|
||||
from agent_framework._workflows._checkpoint import FileCheckpointStorage
|
||||
from agent_framework._workflows._checkpoint_encoding import (
|
||||
_PICKLE_MARKER,
|
||||
_TYPE_MARKER,
|
||||
decode_checkpoint_value,
|
||||
encode_checkpoint_value,
|
||||
)
|
||||
|
||||
|
||||
class MaliciousPayload:
|
||||
"""A class whose __reduce__ executes code during unpickling."""
|
||||
|
||||
def __reduce__(self):
|
||||
return (os.getpid, ())
|
||||
|
||||
|
||||
def test_restricted_decode_blocks_arbitrary_callable():
|
||||
"""Restricted decoding blocks arbitrary module-level callables."""
|
||||
pickled = pickle.dumps(os.getpid, protocol=pickle.HIGHEST_PROTOCOL)
|
||||
encoded_b64 = base64.b64encode(pickled).decode("ascii")
|
||||
|
||||
checkpoint_value = {
|
||||
_PICKLE_MARKER: encoded_b64,
|
||||
_TYPE_MARKER: "builtins:builtin_function_or_method",
|
||||
}
|
||||
|
||||
with pytest.raises(WorkflowCheckpointException, match="deserialization blocked"):
|
||||
decode_checkpoint_value(checkpoint_value, allowed_types=frozenset())
|
||||
|
||||
|
||||
def test_restricted_decode_blocks_reduce_payload():
|
||||
"""__reduce__-based payloads are blocked before code can execute."""
|
||||
payload = MaliciousPayload()
|
||||
pickled = pickle.dumps(payload, protocol=pickle.HIGHEST_PROTOCOL)
|
||||
encoded_b64 = base64.b64encode(pickled).decode("ascii")
|
||||
|
||||
checkpoint_value = {
|
||||
_PICKLE_MARKER: encoded_b64,
|
||||
_TYPE_MARKER: f"{MaliciousPayload.__module__}:{MaliciousPayload.__qualname__}",
|
||||
}
|
||||
|
||||
with pytest.raises(WorkflowCheckpointException, match="deserialization blocked"):
|
||||
decode_checkpoint_value(checkpoint_value, allowed_types=frozenset())
|
||||
|
||||
|
||||
def test_restricted_decode_prevents_code_execution():
|
||||
"""Restricted deserialization prevents __reduce__ code from running."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
marker_file = os.path.join(tmpdir, "checkpoint_test_marker")
|
||||
|
||||
payload_bytes = pickle.dumps(
|
||||
type(
|
||||
"Exploit",
|
||||
(),
|
||||
{
|
||||
"__reduce__": lambda self: (
|
||||
eval,
|
||||
(f"open({marker_file!r}, 'w').write('pwned')",),
|
||||
)
|
||||
},
|
||||
)(),
|
||||
protocol=pickle.HIGHEST_PROTOCOL,
|
||||
)
|
||||
encoded_b64 = base64.b64encode(payload_bytes).decode("ascii")
|
||||
|
||||
checkpoint_value = {
|
||||
_PICKLE_MARKER: encoded_b64,
|
||||
_TYPE_MARKER: "builtins:int",
|
||||
}
|
||||
with pytest.raises(WorkflowCheckpointException, match="deserialization blocked"):
|
||||
decode_checkpoint_value(checkpoint_value, allowed_types=frozenset())
|
||||
|
||||
assert not os.path.exists(marker_file), (
|
||||
"Restricted unpickler should have prevented code execution, but the marker file was created."
|
||||
)
|
||||
|
||||
|
||||
def test_file_checkpoint_storage_accepts_allowed_types():
|
||||
"""FileCheckpointStorage.__init__ accepts allowed_checkpoint_types."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
storage = FileCheckpointStorage(
|
||||
tmpdir,
|
||||
allowed_checkpoint_types=["some.module:SomeType"],
|
||||
)
|
||||
assert storage is not None
|
||||
|
||||
|
||||
@dataclass
|
||||
class _AllowedTestState:
|
||||
"""Test dataclass that will be explicitly allowed."""
|
||||
|
||||
name: str
|
||||
value: int
|
||||
|
||||
|
||||
def test_restricted_decode_blocks_unlisted_user_type():
|
||||
"""User-defined types are blocked when not in allowed_checkpoint_types."""
|
||||
original = _AllowedTestState(name="test", value=42)
|
||||
encoded = encode_checkpoint_value(original)
|
||||
|
||||
with pytest.raises(WorkflowCheckpointException, match="deserialization blocked"):
|
||||
decode_checkpoint_value(encoded, allowed_types=frozenset())
|
||||
|
||||
|
||||
def test_restricted_decode_allows_listed_user_type():
|
||||
"""User-defined types are allowed when listed in allowed_types."""
|
||||
original = _AllowedTestState(name="test", value=42)
|
||||
encoded = encode_checkpoint_value(original)
|
||||
|
||||
type_key = f"{_AllowedTestState.__module__}:{_AllowedTestState.__qualname__}"
|
||||
decoded = decode_checkpoint_value(encoded, allowed_types=frozenset({type_key}))
|
||||
|
||||
assert isinstance(decoded, _AllowedTestState)
|
||||
assert decoded.name == "test"
|
||||
assert decoded.value == 42
|
||||
|
||||
|
||||
def test_restricted_decode_allows_builtin_safe_types():
|
||||
"""Built-in safe types (datetime, set, etc.) are always allowed."""
|
||||
test_values = [
|
||||
datetime(2025, 1, 1, tzinfo=timezone.utc),
|
||||
{1, 2, 3},
|
||||
frozenset({4, 5, 6}),
|
||||
(1, "two", 3.0),
|
||||
complex(1, 2),
|
||||
]
|
||||
for original in test_values:
|
||||
encoded = encode_checkpoint_value(original)
|
||||
decoded = decode_checkpoint_value(encoded, allowed_types=frozenset())
|
||||
assert decoded == original
|
||||
|
||||
|
||||
def test_unrestricted_decode_allows_arbitrary_types():
|
||||
"""Without allowed_types, decode_checkpoint_value remains unrestricted."""
|
||||
original = _AllowedTestState(name="test", value=42)
|
||||
encoded = encode_checkpoint_value(original)
|
||||
|
||||
decoded = decode_checkpoint_value(encoded)
|
||||
|
||||
assert isinstance(decoded, _AllowedTestState)
|
||||
assert decoded.name == "test"
|
||||
|
||||
|
||||
async def test_file_storage_blocks_unlisted_user_type():
|
||||
"""FileCheckpointStorage blocks user types not in allowed_checkpoint_types."""
|
||||
from agent_framework import WorkflowCheckpoint
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
# Save with a storage that allows the type
|
||||
type_key = f"{_AllowedTestState.__module__}:{_AllowedTestState.__qualname__}"
|
||||
save_storage = FileCheckpointStorage(tmpdir, allowed_checkpoint_types=[type_key])
|
||||
|
||||
checkpoint = WorkflowCheckpoint(
|
||||
workflow_name="test",
|
||||
graph_signature_hash="hash",
|
||||
state={"data": _AllowedTestState(name="test", value=1)},
|
||||
)
|
||||
await save_storage.save(checkpoint)
|
||||
|
||||
# Load with a storage that does NOT allow the type
|
||||
load_storage = FileCheckpointStorage(tmpdir)
|
||||
with pytest.raises(WorkflowCheckpointException, match="deserialization blocked"):
|
||||
await load_storage.load(checkpoint.checkpoint_id)
|
||||
|
||||
|
||||
async def test_file_storage_allows_listed_user_type():
|
||||
"""FileCheckpointStorage allows user types listed in allowed_checkpoint_types."""
|
||||
from agent_framework import WorkflowCheckpoint
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
type_key = f"{_AllowedTestState.__module__}:{_AllowedTestState.__qualname__}"
|
||||
storage = FileCheckpointStorage(tmpdir, allowed_checkpoint_types=[type_key])
|
||||
|
||||
checkpoint = WorkflowCheckpoint(
|
||||
workflow_name="test",
|
||||
graph_signature_hash="hash",
|
||||
state={"data": _AllowedTestState(name="allowed", value=99)},
|
||||
)
|
||||
await storage.save(checkpoint)
|
||||
loaded = await storage.load(checkpoint.checkpoint_id)
|
||||
|
||||
assert isinstance(loaded.state["data"], _AllowedTestState)
|
||||
assert loaded.state["data"].name == "allowed"
|
||||
assert loaded.state["data"].value == 99
|
||||
|
||||
|
||||
def test_restricted_unpickler_raises_pickle_error():
|
||||
"""_RestrictedUnpickler.find_class raises pickle.UnpicklingError, not a framework exception."""
|
||||
from agent_framework._workflows._checkpoint_encoding import _RestrictedUnpickler
|
||||
|
||||
pickled = pickle.dumps(os.getpid, protocol=pickle.HIGHEST_PROTOCOL)
|
||||
|
||||
unpickler = _RestrictedUnpickler(pickled, frozenset())
|
||||
with pytest.raises(pickle.UnpicklingError, match="deserialization blocked"):
|
||||
unpickler.load()
|
||||
@@ -130,7 +130,17 @@ async def test_checkpoint_with_pending_request_info_events():
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
# Use file-based storage to test full serialization
|
||||
storage = FileCheckpointStorage(temp_dir)
|
||||
storage = FileCheckpointStorage(
|
||||
temp_dir,
|
||||
allowed_checkpoint_types=[
|
||||
"tests.workflow.test_request_info_and_response:UserApprovalRequest",
|
||||
"tests.workflow.test_request_info_and_response:CalculationRequest",
|
||||
"tests.workflow.test_request_info_event_rehydrate:MockRequest",
|
||||
"tests.workflow.test_request_info_event_rehydrate:SimpleApproval",
|
||||
"tests.workflow.test_request_info_event_rehydrate:SlottedApproval",
|
||||
"tests.workflow.test_request_info_event_rehydrate:TimedApproval",
|
||||
],
|
||||
)
|
||||
|
||||
# Create workflow with checkpointing enabled
|
||||
executor = ApprovalRequiredExecutor(id="approval_executor")
|
||||
@@ -225,7 +235,17 @@ async def test_checkpoint_restore_with_responses_does_not_reemit_handled_request
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
# Use file-based storage to test full serialization
|
||||
storage = FileCheckpointStorage(temp_dir)
|
||||
storage = FileCheckpointStorage(
|
||||
temp_dir,
|
||||
allowed_checkpoint_types=[
|
||||
"tests.workflow.test_request_info_and_response:UserApprovalRequest",
|
||||
"tests.workflow.test_request_info_and_response:CalculationRequest",
|
||||
"tests.workflow.test_request_info_event_rehydrate:MockRequest",
|
||||
"tests.workflow.test_request_info_event_rehydrate:SimpleApproval",
|
||||
"tests.workflow.test_request_info_event_rehydrate:SlottedApproval",
|
||||
"tests.workflow.test_request_info_event_rehydrate:TimedApproval",
|
||||
],
|
||||
)
|
||||
|
||||
# Create workflow with checkpointing enabled
|
||||
executor = ApprovalRequiredExecutor(id="approval_executor")
|
||||
@@ -288,7 +308,17 @@ async def test_checkpoint_restore_with_partial_responses_reemits_unhandled_reque
|
||||
import tempfile
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
storage = FileCheckpointStorage(temp_dir)
|
||||
storage = FileCheckpointStorage(
|
||||
temp_dir,
|
||||
allowed_checkpoint_types=[
|
||||
"tests.workflow.test_request_info_and_response:UserApprovalRequest",
|
||||
"tests.workflow.test_request_info_and_response:CalculationRequest",
|
||||
"tests.workflow.test_request_info_event_rehydrate:MockRequest",
|
||||
"tests.workflow.test_request_info_event_rehydrate:SimpleApproval",
|
||||
"tests.workflow.test_request_info_event_rehydrate:SlottedApproval",
|
||||
"tests.workflow.test_request_info_event_rehydrate:TimedApproval",
|
||||
],
|
||||
)
|
||||
|
||||
# Create workflow with multiple requests
|
||||
executor = MultiRequestExecutor(id="multi_executor")
|
||||
|
||||
@@ -14,6 +14,7 @@ from agent_framework import (
|
||||
AgentSession,
|
||||
Content,
|
||||
Executor,
|
||||
HistoryProvider,
|
||||
InMemoryHistoryProvider,
|
||||
Message,
|
||||
ResponseStream,
|
||||
@@ -678,6 +679,110 @@ class TestWorkflowAgent:
|
||||
|
||||
assert agent.context_providers == [explicit_provider]
|
||||
|
||||
async def test_no_history_provider_injected_when_session_is_none(self) -> None:
|
||||
"""Test that InMemoryHistoryProvider is NOT injected when session is None."""
|
||||
capturing_executor = ConversationHistoryCapturingExecutor(id="no_session_test")
|
||||
workflow = WorkflowBuilder(start_executor=capturing_executor).build()
|
||||
agent = WorkflowAgent(workflow=workflow, name="No Session Agent")
|
||||
|
||||
await agent.run("hello")
|
||||
|
||||
assert not any(isinstance(p, InMemoryHistoryProvider) for p in agent.context_providers)
|
||||
|
||||
async def test_no_history_provider_injected_when_session_is_none_streaming(self) -> None:
|
||||
"""Test that InMemoryHistoryProvider is NOT injected when session is None (streaming)."""
|
||||
capturing_executor = ConversationHistoryCapturingExecutor(id="no_session_stream_test")
|
||||
workflow = WorkflowBuilder(start_executor=capturing_executor).build()
|
||||
agent = WorkflowAgent(workflow=workflow, name="No Session Stream Agent")
|
||||
|
||||
async for _ in agent.run("hello", stream=True):
|
||||
pass
|
||||
|
||||
assert not any(isinstance(p, InMemoryHistoryProvider) for p in agent.context_providers)
|
||||
|
||||
async def test_no_injection_when_history_provider_with_load_messages_exists(self) -> None:
|
||||
"""Test that no InMemoryHistoryProvider is injected when an existing HistoryProvider has load_messages=True."""
|
||||
capturing_executor = ConversationHistoryCapturingExecutor(id="existing_provider_test")
|
||||
workflow = WorkflowBuilder(start_executor=capturing_executor).build()
|
||||
existing_provider = InMemoryHistoryProvider("custom", load_messages=True)
|
||||
agent = WorkflowAgent(
|
||||
workflow=workflow,
|
||||
name="Existing Provider Agent",
|
||||
context_providers=[existing_provider],
|
||||
)
|
||||
session = AgentSession()
|
||||
|
||||
await agent.run("hello", session=session)
|
||||
|
||||
# Should still have only the original provider
|
||||
history_providers = [p for p in agent.context_providers if isinstance(p, HistoryProvider)]
|
||||
assert len(history_providers) == 1
|
||||
assert history_providers[0] is existing_provider
|
||||
|
||||
async def test_injection_when_history_provider_with_load_messages_false(self) -> None:
|
||||
"""Test that InMemoryHistoryProvider IS injected when existing HistoryProvider has load_messages=False."""
|
||||
capturing_executor = ConversationHistoryCapturingExecutor(id="no_load_provider_test")
|
||||
workflow = WorkflowBuilder(start_executor=capturing_executor).build()
|
||||
audit_provider = InMemoryHistoryProvider("audit", load_messages=False)
|
||||
agent = WorkflowAgent(
|
||||
workflow=workflow,
|
||||
name="Audit Provider Agent",
|
||||
context_providers=[audit_provider],
|
||||
)
|
||||
session = AgentSession()
|
||||
|
||||
await agent.run("hello", session=session)
|
||||
|
||||
# Should have injected an additional InMemoryHistoryProvider with load_messages=True
|
||||
history_providers = [p for p in agent.context_providers if isinstance(p, HistoryProvider)]
|
||||
assert len(history_providers) == 2
|
||||
loading_providers = [p for p in history_providers if p.load_messages]
|
||||
assert len(loading_providers) == 1
|
||||
assert isinstance(loading_providers[0], InMemoryHistoryProvider)
|
||||
|
||||
async def test_no_duplicate_injection_on_multiple_runs(self) -> None:
|
||||
"""Test that calling run() multiple times does not keep adding InMemoryHistoryProvider."""
|
||||
capturing_executor = ConversationHistoryCapturingExecutor(id="no_dup_test")
|
||||
workflow = WorkflowBuilder(start_executor=capturing_executor).build()
|
||||
agent = WorkflowAgent(workflow=workflow, name="No Dup Agent")
|
||||
session = AgentSession()
|
||||
|
||||
await agent.run("first", session=session)
|
||||
await agent.run("second", session=session)
|
||||
await agent.run("third", session=session)
|
||||
|
||||
history_providers = [p for p in agent.context_providers if isinstance(p, InMemoryHistoryProvider)]
|
||||
assert len(history_providers) == 1
|
||||
|
||||
async def test_no_duplicate_injection_on_multiple_runs_streaming(self) -> None:
|
||||
"""Test that calling run(stream=True) multiple times does not keep adding InMemoryHistoryProvider."""
|
||||
capturing_executor = ConversationHistoryCapturingExecutor(id="no_dup_stream_test")
|
||||
workflow = WorkflowBuilder(start_executor=capturing_executor).build()
|
||||
agent = WorkflowAgent(workflow=workflow, name="No Dup Stream Agent")
|
||||
session = AgentSession()
|
||||
|
||||
async for _ in agent.run("first", stream=True, session=session):
|
||||
pass
|
||||
async for _ in agent.run("second", stream=True, session=session):
|
||||
pass
|
||||
async for _ in agent.run("third", stream=True, session=session):
|
||||
pass
|
||||
|
||||
history_providers = [p for p in agent.context_providers if isinstance(p, InMemoryHistoryProvider)]
|
||||
assert len(history_providers) == 1
|
||||
|
||||
async def test_injection_with_session_in_streaming_mode(self) -> None:
|
||||
"""Test that InMemoryHistoryProvider is injected when session is provided in streaming mode."""
|
||||
capturing_executor = ConversationHistoryCapturingExecutor(id="stream_inject_test")
|
||||
workflow = WorkflowBuilder(start_executor=capturing_executor).build()
|
||||
agent = WorkflowAgent(workflow=workflow, name="Stream Inject Agent")
|
||||
session = AgentSession()
|
||||
|
||||
async for _ in agent.run("hello", stream=True, session=session):
|
||||
pass
|
||||
|
||||
assert any(isinstance(p, InMemoryHistoryProvider) for p in agent.context_providers)
|
||||
|
||||
async def test_checkpoint_storage_passed_to_workflow(self) -> None:
|
||||
"""Test that checkpoint_storage parameter is passed through to the workflow."""
|
||||
from agent_framework import InMemoryCheckpointStorage
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Declarative specification support for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260402"
|
||||
version = "1.0.0b260409"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -22,7 +22,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.0.0,<2",
|
||||
"agent-framework-core>=1.0.1,<2",
|
||||
"powerfx>=0.0.32,<0.0.35; python_version < '3.14'",
|
||||
"pyyaml>=6.0,<7.0",
|
||||
]
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -51,7 +51,7 @@ export default tseslint.config([
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Features: Entity selection, layout management, debug coordination
|
||||
*/
|
||||
|
||||
import { useEffect, useCallback, useState } from "react";
|
||||
import { useEffect, useCallback, useRef, useState } from "react";
|
||||
import { AppHeader, DebugPanel, SettingsModal, DeploymentModal } from "@/components/layout";
|
||||
import { GalleryView } from "@/components/features/gallery";
|
||||
import { AgentView } from "@/components/features/agent";
|
||||
@@ -15,17 +15,22 @@ import type {
|
||||
AgentInfo,
|
||||
WorkflowInfo,
|
||||
ExtendedResponseStreamEvent,
|
||||
ResponseTextDeltaEvent,
|
||||
} from "@/types";
|
||||
import { Button } from "./components/ui/button";
|
||||
import { Input } from "./components/ui/input";
|
||||
import { useDevUIStore } from "@/stores";
|
||||
|
||||
const DEBUG_TEXT_EVENT_FLUSH_INTERVAL_MS = 50;
|
||||
|
||||
export default function App() {
|
||||
// Local state for auth handling
|
||||
const [authRequired, setAuthRequired] = useState(false);
|
||||
const [authToken, setAuthToken] = useState("");
|
||||
const [isTestingToken, setIsTestingToken] = useState(false);
|
||||
const [authError, setAuthError] = useState("");
|
||||
const bufferedDebugTextRef = useRef<ResponseTextDeltaEvent | null>(null);
|
||||
const lastBufferedDebugFlushAtRef = useRef(0);
|
||||
|
||||
// Entity state from Zustand
|
||||
const agents = useDevUIStore((state) => state.agents);
|
||||
@@ -303,16 +308,63 @@ export default function App() {
|
||||
[selectEntity, updateAgent, updateWorkflow, addToast]
|
||||
);
|
||||
|
||||
const flushBufferedDebugText = useCallback(() => {
|
||||
const bufferedEvent = bufferedDebugTextRef.current;
|
||||
if (!bufferedEvent) {
|
||||
return;
|
||||
}
|
||||
|
||||
bufferedDebugTextRef.current = null;
|
||||
lastBufferedDebugFlushAtRef.current = performance.now();
|
||||
addDebugEvent(bufferedEvent);
|
||||
}, [addDebugEvent]);
|
||||
|
||||
// Handle debug events from active view
|
||||
const handleDebugEvent = useCallback(
|
||||
(event: ExtendedResponseStreamEvent | "clear") => {
|
||||
if (event === "clear") {
|
||||
bufferedDebugTextRef.current = null;
|
||||
clearDebugEvents();
|
||||
} else {
|
||||
addDebugEvent(event);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
event.type === "response.output_text.delta" &&
|
||||
"delta" in event &&
|
||||
typeof event.delta === "string" &&
|
||||
event.delta.length > 0
|
||||
) {
|
||||
const bufferedEvent = bufferedDebugTextRef.current;
|
||||
const isSameOutput =
|
||||
bufferedEvent !== null &&
|
||||
bufferedEvent.item_id === event.item_id &&
|
||||
bufferedEvent.output_index === event.output_index &&
|
||||
bufferedEvent.content_index === event.content_index;
|
||||
|
||||
if (isSameOutput && bufferedEvent) {
|
||||
bufferedDebugTextRef.current = {
|
||||
...bufferedEvent,
|
||||
delta: bufferedEvent.delta + event.delta,
|
||||
sequence_number: event.sequence_number ?? bufferedEvent.sequence_number,
|
||||
};
|
||||
} else {
|
||||
flushBufferedDebugText();
|
||||
bufferedDebugTextRef.current = { ...event } as ResponseTextDeltaEvent;
|
||||
}
|
||||
|
||||
if (
|
||||
performance.now() - lastBufferedDebugFlushAtRef.current >=
|
||||
DEBUG_TEXT_EVENT_FLUSH_INTERVAL_MS
|
||||
) {
|
||||
flushBufferedDebugText();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
flushBufferedDebugText();
|
||||
addDebugEvent(event);
|
||||
},
|
||||
[addDebugEvent, clearDebugEvents]
|
||||
[addDebugEvent, clearDebugEvents, flushBufferedDebugText]
|
||||
);
|
||||
|
||||
// Show loading state while initializing
|
||||
|
||||
@@ -44,6 +44,8 @@ import { loadStreamingState } from "@/services/streaming-state";
|
||||
|
||||
type DebugEventHandler = (event: ExtendedResponseStreamEvent | "clear") => void;
|
||||
|
||||
const ASSISTANT_TEXT_RENDER_INTERVAL_MS = 50;
|
||||
|
||||
interface AgentViewProps {
|
||||
selectedAgent: AgentInfo;
|
||||
onDebugEvent: DebugEventHandler;
|
||||
@@ -309,6 +311,71 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
} | null>(null);
|
||||
const userJustSentMessage = useRef<boolean>(false);
|
||||
const accumulatedTextRef = useRef<string>("");
|
||||
const lastAssistantTextRenderAt = useRef(0);
|
||||
|
||||
const renderAssistantStreamingText = useCallback(
|
||||
(
|
||||
assistantMessageId: string,
|
||||
status: "in_progress" | "completed" | "incomplete" = "in_progress",
|
||||
force: boolean = false
|
||||
) => {
|
||||
const now = performance.now();
|
||||
if (
|
||||
!force &&
|
||||
now - lastAssistantTextRenderAt.current < ASSISTANT_TEXT_RENDER_INTERVAL_MS
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentItems = useDevUIStore.getState().chatItems;
|
||||
let changed = false;
|
||||
const nextItems = currentItems.map((item) => {
|
||||
if (item.id !== assistantMessageId || item.type !== "message") {
|
||||
return item;
|
||||
}
|
||||
|
||||
const nextText = accumulatedTextRef.current;
|
||||
const existingTextContent = item.content.find(
|
||||
(content) => content.type === "text" || content.type === "output_text"
|
||||
);
|
||||
const currentText =
|
||||
existingTextContent && "text" in existingTextContent
|
||||
? existingTextContent.text
|
||||
: "";
|
||||
|
||||
if (currentText === nextText && item.status === status) {
|
||||
return item;
|
||||
}
|
||||
|
||||
changed = true;
|
||||
const existingNonTextContent = item.content.filter(
|
||||
(content) => content.type !== "text" && content.type !== "output_text"
|
||||
);
|
||||
|
||||
return {
|
||||
...item,
|
||||
content: nextText
|
||||
? [
|
||||
...existingNonTextContent,
|
||||
{
|
||||
type: "text",
|
||||
text: nextText,
|
||||
} as import("@/types/openai").MessageTextContent,
|
||||
]
|
||||
: existingNonTextContent,
|
||||
status,
|
||||
};
|
||||
});
|
||||
|
||||
if (changed) {
|
||||
lastAssistantTextRenderAt.current = now;
|
||||
setChatItems(nextItems);
|
||||
} else if (force) {
|
||||
lastAssistantTextRenderAt.current = now;
|
||||
}
|
||||
},
|
||||
[setChatItems]
|
||||
);
|
||||
|
||||
// Auto-scroll to bottom when new items arrive
|
||||
useEffect(() => {
|
||||
@@ -382,6 +449,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
undefined, // No abort signal for resume
|
||||
storedState.responseId // Pass response ID for resume
|
||||
);
|
||||
lastAssistantTextRenderAt.current = 0;
|
||||
|
||||
for await (const openAIEvent of streamGenerator) {
|
||||
// Pass all events to debug panel
|
||||
@@ -412,6 +480,12 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
: JSON.stringify(error)
|
||||
: "Request failed";
|
||||
|
||||
if (accumulatedTextRef.current) {
|
||||
renderAssistantStreamingText(assistantMessage.id, "incomplete", true);
|
||||
setIsStreaming(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentItems = useDevUIStore.getState().chatItems;
|
||||
setChatItems(currentItems.map((item) =>
|
||||
item.id === assistantMessage.id && item.type === "message"
|
||||
@@ -434,6 +508,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
// Handle function approval request events
|
||||
if (openAIEvent.type === "response.function_approval.requested") {
|
||||
const approvalEvent = openAIEvent as import("@/types/openai").ResponseFunctionApprovalRequestedEvent;
|
||||
renderAssistantStreamingText(assistantMessage.id, "in_progress", true);
|
||||
setPendingApprovals([
|
||||
...useDevUIStore.getState().pendingApprovals,
|
||||
{
|
||||
@@ -458,6 +533,12 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
const errorEvent = openAIEvent as ExtendedResponseStreamEvent & { message?: string };
|
||||
const errorMessage = errorEvent.message || "An error occurred";
|
||||
|
||||
if (accumulatedTextRef.current) {
|
||||
renderAssistantStreamingText(assistantMessage.id, "incomplete", true);
|
||||
setIsStreaming(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentItems = useDevUIStore.getState().chatItems;
|
||||
setChatItems(currentItems.map((item) =>
|
||||
item.id === assistantMessage.id && item.type === "message"
|
||||
@@ -484,27 +565,13 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
openAIEvent.delta
|
||||
) {
|
||||
accumulatedTextRef.current += openAIEvent.delta;
|
||||
|
||||
const currentItems = useDevUIStore.getState().chatItems;
|
||||
setChatItems(currentItems.map((item) =>
|
||||
item.id === assistantMessage.id && item.type === "message"
|
||||
? {
|
||||
...item,
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: accumulatedTextRef.current,
|
||||
} as import("@/types/openai").MessageTextContent,
|
||||
],
|
||||
status: "in_progress" as const,
|
||||
}
|
||||
: item
|
||||
));
|
||||
renderAssistantStreamingText(assistantMessage.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Stream ended - mark as complete
|
||||
const finalUsage = currentMessageUsage.current;
|
||||
renderAssistantStreamingText(assistantMessage.id, "in_progress", true);
|
||||
|
||||
const currentItems = useDevUIStore.getState().chatItems;
|
||||
setChatItems(currentItems.map((item) =>
|
||||
@@ -721,11 +788,12 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
setIsStreaming(false);
|
||||
setCurrentConversation(undefined);
|
||||
accumulatedTextRef.current = "";
|
||||
lastAssistantTextRenderAt.current = 0;
|
||||
|
||||
loadConversations();
|
||||
// currentConversation is intentionally excluded - this effect should only run when agent changes
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedAgent, onDebugEvent, setChatItems, setIsStreaming, setLoadingConversations, setAvailableConversations, setCurrentConversation, setPendingApprovals, updateConversationUsage]);
|
||||
}, [selectedAgent, onDebugEvent, renderAssistantStreamingText, setChatItems, setIsStreaming, setLoadingConversations, setAvailableConversations, setCurrentConversation, setPendingApprovals, updateConversationUsage]);
|
||||
|
||||
// Removed old input handling functions - now handled by ChatMessageInput component
|
||||
|
||||
@@ -1118,6 +1186,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
|
||||
// Clear text accumulator for new response
|
||||
accumulatedTextRef.current = "";
|
||||
lastAssistantTextRenderAt.current = 0;
|
||||
|
||||
// Create new AbortController for this request
|
||||
const signal = createAbortSignal();
|
||||
@@ -1167,6 +1236,12 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
}
|
||||
|
||||
// Update assistant message with error
|
||||
if (accumulatedTextRef.current) {
|
||||
renderAssistantStreamingText(assistantMessage.id, "incomplete", true);
|
||||
setIsStreaming(false);
|
||||
return; // Exit stream processing on failure
|
||||
}
|
||||
|
||||
const currentItems = useDevUIStore.getState().chatItems;
|
||||
setChatItems(currentItems.map((item) =>
|
||||
item.id === assistantMessage.id && item.type === "message"
|
||||
@@ -1189,6 +1264,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
// Handle function approval request events
|
||||
if (openAIEvent.type === "response.function_approval.requested") {
|
||||
const approvalEvent = openAIEvent as import("@/types/openai").ResponseFunctionApprovalRequestedEvent;
|
||||
renderAssistantStreamingText(assistantMessage.id, "in_progress", true);
|
||||
|
||||
// Add to pending approvals (for popup)
|
||||
setPendingApprovals([
|
||||
@@ -1267,6 +1343,12 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
const errorMessage = errorEvent.message || "An error occurred";
|
||||
|
||||
// Update assistant message with error and stop streaming
|
||||
if (accumulatedTextRef.current) {
|
||||
renderAssistantStreamingText(assistantMessage.id, "incomplete", true);
|
||||
setIsStreaming(false);
|
||||
return; // Exit stream processing early on error
|
||||
}
|
||||
|
||||
const currentItems = useDevUIStore.getState().chatItems;
|
||||
setChatItems(currentItems.map((item) =>
|
||||
item.id === assistantMessage.id && item.type === "message"
|
||||
@@ -1290,6 +1372,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
if (openAIEvent.type === "response.output_item.added") {
|
||||
const outputItemEvent = openAIEvent as import("@/types/openai").ResponseOutputItemAddedEvent;
|
||||
const item = outputItemEvent.item;
|
||||
renderAssistantStreamingText(assistantMessage.id, "in_progress", true);
|
||||
|
||||
// Handle function calls as separate conversation items
|
||||
if (item.type === "function_call") {
|
||||
@@ -1363,28 +1446,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
openAIEvent.delta
|
||||
) {
|
||||
accumulatedTextRef.current += openAIEvent.delta;
|
||||
|
||||
// Update assistant message with accumulated content
|
||||
// Preserve any existing non-text content (images, files, data)
|
||||
const currentItems = useDevUIStore.getState().chatItems;
|
||||
setChatItems(currentItems.map((item) => {
|
||||
if (item.id === assistantMessage.id && item.type === "message") {
|
||||
// Keep existing non-text content, update text content
|
||||
const existingNonTextContent = item.content.filter(c => c.type !== "text");
|
||||
return {
|
||||
...item,
|
||||
content: [
|
||||
...existingNonTextContent,
|
||||
{
|
||||
type: "text",
|
||||
text: accumulatedTextRef.current,
|
||||
} as import("@/types/openai").MessageTextContent,
|
||||
],
|
||||
status: "in_progress" as const,
|
||||
};
|
||||
}
|
||||
return item;
|
||||
}));
|
||||
renderAssistantStreamingText(assistantMessage.id);
|
||||
}
|
||||
|
||||
// Handle completion/error by detecting when streaming stops
|
||||
@@ -1394,6 +1456,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
// Stream ended - mark as complete
|
||||
// Usage is provided via response.completed event (OpenAI standard)
|
||||
const finalUsage = currentMessageUsage.current;
|
||||
renderAssistantStreamingText(assistantMessage.id, "in_progress", true);
|
||||
|
||||
const currentItems = useDevUIStore.getState().chatItems;
|
||||
setChatItems(currentItems.map((item) =>
|
||||
@@ -1419,45 +1482,42 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
if (isAbortError(error)) {
|
||||
// User cancelled - mark as cancelled for UI feedback
|
||||
setWasCancelled(true);
|
||||
// Mark the message as completed with what we have
|
||||
const currentItems = useDevUIStore.getState().chatItems;
|
||||
setChatItems(currentItems.map((item) =>
|
||||
item.id === assistantMessage.id && item.type === "message"
|
||||
? {
|
||||
...item,
|
||||
status: accumulatedTextRef.current ? "completed" as const : "incomplete" as const,
|
||||
// Keep whatever text we have accumulated
|
||||
content: item.content,
|
||||
}
|
||||
: item
|
||||
));
|
||||
renderAssistantStreamingText(
|
||||
assistantMessage.id,
|
||||
accumulatedTextRef.current ? "completed" : "incomplete",
|
||||
true
|
||||
);
|
||||
} else {
|
||||
// Other errors - show error message
|
||||
const currentItems = useDevUIStore.getState().chatItems;
|
||||
setChatItems(currentItems.map((item) =>
|
||||
item.id === assistantMessage.id && item.type === "message"
|
||||
? {
|
||||
...item,
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Error: ${
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to get response"
|
||||
}`,
|
||||
} as import("@/types/openai").MessageTextContent,
|
||||
],
|
||||
status: "incomplete" as const,
|
||||
}
|
||||
: item
|
||||
));
|
||||
if (accumulatedTextRef.current) {
|
||||
renderAssistantStreamingText(assistantMessage.id, "incomplete", true);
|
||||
} else {
|
||||
const currentItems = useDevUIStore.getState().chatItems;
|
||||
setChatItems(currentItems.map((item) =>
|
||||
item.id === assistantMessage.id && item.type === "message"
|
||||
? {
|
||||
...item,
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Error: ${
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to get response"
|
||||
}`,
|
||||
} as import("@/types/openai").MessageTextContent,
|
||||
],
|
||||
status: "incomplete" as const,
|
||||
}
|
||||
: item
|
||||
));
|
||||
}
|
||||
}
|
||||
setIsStreaming(false);
|
||||
resetCancelling();
|
||||
}
|
||||
},
|
||||
[selectedAgent, currentConversation, onDebugEvent, setChatItems, setIsStreaming, setCurrentConversation, setAvailableConversations, setPendingApprovals, updateConversationUsage, createAbortSignal, resetCancelling]
|
||||
[selectedAgent, currentConversation, onDebugEvent, renderAssistantStreamingText, setChatItems, setIsStreaming, setCurrentConversation, setAvailableConversations, setPendingApprovals, updateConversationUsage, createAbortSignal, resetCancelling]
|
||||
);
|
||||
|
||||
// Handle non-streaming message sending
|
||||
|
||||
@@ -16,9 +16,10 @@ import type {
|
||||
import type { AgentFrameworkRequest } from "@/types/agent-framework";
|
||||
import type { ExtendedResponseStreamEvent } from "@/types/openai";
|
||||
import {
|
||||
applyStreamingEventToState,
|
||||
createStreamingState,
|
||||
loadStreamingState,
|
||||
updateStreamingState,
|
||||
markStreamingCompleted,
|
||||
saveStreamingState,
|
||||
clearStreamingState,
|
||||
} from "./streaming-state";
|
||||
import { isAbortError } from "@/hooks";
|
||||
@@ -72,6 +73,7 @@ const DEFAULT_API_BASE_URL =
|
||||
// Retry configuration for streaming
|
||||
const RETRY_INTERVAL_MS = 1000; // Base retry interval (will use exponential backoff)
|
||||
const MAX_RETRY_ATTEMPTS = 10; // Max 10 retries (~30 seconds with exponential backoff)
|
||||
const STREAMING_STATE_SAVE_INTERVAL_MS = 250;
|
||||
|
||||
// Get backend URL from localStorage or default
|
||||
function getBackendUrl(): string {
|
||||
@@ -223,7 +225,7 @@ class ApiClient {
|
||||
chat_client_type: entity.chat_client_type,
|
||||
context_provider: entity.context_provider,
|
||||
middleware: entity.middleware,
|
||||
};
|
||||
} as AgentInfo;
|
||||
} else {
|
||||
// Workflow - prefer executors field, fall back to tools for backward compatibility
|
||||
const executorList = entity.executors || entity.tools || [];
|
||||
@@ -263,7 +265,7 @@ class ApiClient {
|
||||
input_type_name: entity.input_type_name || "Input",
|
||||
start_executor_id: startExecutorId,
|
||||
tools: [],
|
||||
};
|
||||
} as WorkflowInfo;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -484,31 +486,65 @@ class ApiClient {
|
||||
let hasYieldedAnyEvent = false;
|
||||
let currentResponseId: string | undefined = resumeResponseId;
|
||||
let lastMessageId: string | undefined = undefined;
|
||||
let lastStreamingStateSaveAt = 0;
|
||||
let storedState = conversationId ? loadStreamingState(conversationId) : null;
|
||||
let streamingState = storedState ? { ...storedState } : null;
|
||||
|
||||
const persistStreamingState = (force: boolean = false): void => {
|
||||
if (!conversationId || !streamingState) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
if (!force && now - lastStreamingStateSaveAt < STREAMING_STATE_SAVE_INTERVAL_MS) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastStreamingStateSaveAt = now;
|
||||
saveStreamingState({
|
||||
...streamingState,
|
||||
timestamp: now,
|
||||
});
|
||||
};
|
||||
|
||||
const recordStreamingEvent = (event: ExtendedResponseStreamEvent): void => {
|
||||
if (!conversationId || !currentResponseId) {
|
||||
return;
|
||||
}
|
||||
|
||||
streamingState = applyStreamingEventToState(
|
||||
streamingState ?? createStreamingState({
|
||||
conversationId,
|
||||
responseId: currentResponseId,
|
||||
lastMessageId,
|
||||
lastSequenceNumber,
|
||||
accumulatedText: storedState?.accumulatedText,
|
||||
}),
|
||||
event,
|
||||
currentResponseId,
|
||||
lastMessageId
|
||||
);
|
||||
|
||||
const isTextDelta =
|
||||
event.type === "response.output_text.delta" &&
|
||||
"delta" in event &&
|
||||
typeof event.delta === "string" &&
|
||||
event.delta.length > 0;
|
||||
persistStreamingState(!isTextDelta);
|
||||
};
|
||||
|
||||
// Try to resume from stored state if conversation ID is provided
|
||||
if (conversationId) {
|
||||
const storedState = loadStreamingState(conversationId);
|
||||
if (storedState) {
|
||||
// Use stored response ID if no explicit one provided
|
||||
if (!resumeResponseId) {
|
||||
currentResponseId = storedState.responseId;
|
||||
}
|
||||
|
||||
lastSequenceNumber = storedState.lastSequenceNumber;
|
||||
lastMessageId = storedState.lastMessageId;
|
||||
|
||||
// Replay stored events only if we're not explicitly resuming
|
||||
// (explicit resume means the caller already has the events)
|
||||
if (!resumeResponseId) {
|
||||
for (const event of storedState.events) {
|
||||
hasYieldedAnyEvent = true;
|
||||
yield event;
|
||||
}
|
||||
} else {
|
||||
// Mark that we've already seen events up to this sequence number
|
||||
hasYieldedAnyEvent = storedState.events.length > 0;
|
||||
}
|
||||
if (storedState) {
|
||||
// Use stored response ID if no explicit one provided
|
||||
if (!resumeResponseId) {
|
||||
currentResponseId = storedState.responseId;
|
||||
}
|
||||
|
||||
lastSequenceNumber = storedState.lastSequenceNumber;
|
||||
lastMessageId = storedState.lastMessageId;
|
||||
hasYieldedAnyEvent =
|
||||
storedState.lastSequenceNumber >= 0 ||
|
||||
Boolean(storedState.accumulatedText);
|
||||
}
|
||||
|
||||
while (retryCount <= MAX_RETRY_ATTEMPTS) {
|
||||
@@ -621,7 +657,8 @@ class ApiClient {
|
||||
if (done) {
|
||||
// Stream completed successfully
|
||||
if (conversationId) {
|
||||
markStreamingCompleted(conversationId);
|
||||
clearStreamingState(conversationId);
|
||||
streamingState = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -640,7 +677,8 @@ class ApiClient {
|
||||
// Handle [DONE] signal
|
||||
if (dataStr === "[DONE]") {
|
||||
if (conversationId) {
|
||||
markStreamingCompleted(conversationId);
|
||||
clearStreamingState(conversationId);
|
||||
streamingState = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -676,6 +714,9 @@ class ApiClient {
|
||||
if (conversationId) {
|
||||
clearStreamingState(conversationId);
|
||||
}
|
||||
storedState = null;
|
||||
streamingState = null;
|
||||
lastStreamingStateSaveAt = 0;
|
||||
yield {
|
||||
type: "error",
|
||||
message: "Connection lost - previous response failed. Starting new response.",
|
||||
@@ -684,9 +725,7 @@ class ApiClient {
|
||||
hasYieldedAnyEvent = true;
|
||||
|
||||
// Save new event to storage
|
||||
if (conversationId && currentResponseId) {
|
||||
updateStreamingState(conversationId, openAIEvent, currentResponseId, lastMessageId);
|
||||
}
|
||||
recordStreamingEvent(openAIEvent);
|
||||
|
||||
yield openAIEvent;
|
||||
}
|
||||
@@ -698,9 +737,7 @@ class ApiClient {
|
||||
hasYieldedAnyEvent = true;
|
||||
|
||||
// Save event to storage before yielding
|
||||
if (conversationId && currentResponseId) {
|
||||
updateStreamingState(conversationId, openAIEvent, currentResponseId, lastMessageId);
|
||||
}
|
||||
recordStreamingEvent(openAIEvent);
|
||||
|
||||
yield openAIEvent;
|
||||
}
|
||||
@@ -709,9 +746,7 @@ class ApiClient {
|
||||
hasYieldedAnyEvent = true;
|
||||
|
||||
// Still save to storage if we have conversation context
|
||||
if (conversationId && currentResponseId) {
|
||||
updateStreamingState(conversationId, openAIEvent, currentResponseId, lastMessageId);
|
||||
}
|
||||
recordStreamingEvent(openAIEvent);
|
||||
|
||||
yield openAIEvent;
|
||||
}
|
||||
@@ -730,7 +765,8 @@ class ApiClient {
|
||||
// Don't retry on abort
|
||||
if (isAbortError(error)) {
|
||||
if (conversationId) {
|
||||
markStreamingCompleted(conversationId); // Clean up state
|
||||
clearStreamingState(conversationId);
|
||||
streamingState = null;
|
||||
}
|
||||
throw error; // Re-throw abort error without retrying
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
*
|
||||
* Manages browser storage of streaming response state to enable:
|
||||
* - Resume interrupted streams after page refresh
|
||||
* - Replay cached events before fetching new ones
|
||||
* - Graceful recovery from network disconnections
|
||||
*/
|
||||
|
||||
@@ -14,7 +13,6 @@ export interface StreamingState {
|
||||
responseId: string;
|
||||
lastMessageId?: string;
|
||||
lastSequenceNumber: number;
|
||||
events: ExtendedResponseStreamEvent[];
|
||||
timestamp: number; // When this state was last updated
|
||||
completed: boolean; // Whether the stream completed successfully
|
||||
accumulatedText?: string; // Accumulated text content for quick restoration
|
||||
@@ -23,6 +21,14 @@ export interface StreamingState {
|
||||
const STORAGE_KEY_PREFIX = "devui_streaming_state_";
|
||||
const STATE_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
|
||||
interface CreateStreamingStateOptions {
|
||||
conversationId: string;
|
||||
responseId: string;
|
||||
lastMessageId?: string;
|
||||
lastSequenceNumber?: number;
|
||||
accumulatedText?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Storage key for a specific conversation
|
||||
*/
|
||||
@@ -31,16 +37,81 @@ function getStorageKey(conversationId: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract accumulated text from events (for quick restoration)
|
||||
* Read raw streaming state from storage, including completed entries.
|
||||
*/
|
||||
function extractAccumulatedText(events: ExtendedResponseStreamEvent[]): string {
|
||||
let text = "";
|
||||
for (const event of events) {
|
||||
if (event.type === "response.output_text.delta" && "delta" in event) {
|
||||
text += event.delta;
|
||||
}
|
||||
function readStreamingState(conversationId: string): StreamingState | null {
|
||||
const key = getStorageKey(conversationId);
|
||||
const data = localStorage.getItem(key);
|
||||
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
return text;
|
||||
|
||||
const state: StreamingState = JSON.parse(data);
|
||||
|
||||
// Check if state has expired
|
||||
const age = Date.now() - state.timestamp;
|
||||
if (age > STATE_EXPIRY_MS) {
|
||||
clearStreamingState(conversationId);
|
||||
return null;
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an initial streaming state snapshot.
|
||||
*/
|
||||
export function createStreamingState({
|
||||
conversationId,
|
||||
responseId,
|
||||
lastMessageId,
|
||||
lastSequenceNumber = -1,
|
||||
accumulatedText,
|
||||
}: CreateStreamingStateOptions): StreamingState {
|
||||
return {
|
||||
conversationId,
|
||||
responseId,
|
||||
lastMessageId,
|
||||
lastSequenceNumber,
|
||||
timestamp: Date.now(),
|
||||
completed: false,
|
||||
accumulatedText,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an incoming stream event to an in-memory streaming state snapshot.
|
||||
*/
|
||||
export function applyStreamingEventToState(
|
||||
state: StreamingState,
|
||||
event: ExtendedResponseStreamEvent,
|
||||
responseId: string,
|
||||
lastMessageId?: string
|
||||
): StreamingState {
|
||||
const sequenceNumber = "sequence_number" in event ? event.sequence_number : undefined;
|
||||
const nextState: StreamingState = {
|
||||
...state,
|
||||
responseId,
|
||||
lastMessageId,
|
||||
timestamp: Date.now(),
|
||||
completed: event.type === "response.completed" || event.type === "response.failed",
|
||||
};
|
||||
|
||||
if (sequenceNumber !== undefined) {
|
||||
nextState.lastSequenceNumber = sequenceNumber;
|
||||
}
|
||||
|
||||
if (
|
||||
event.type === "response.output_text.delta" &&
|
||||
"delta" in event &&
|
||||
typeof event.delta === "string" &&
|
||||
event.delta.length > 0
|
||||
) {
|
||||
nextState.accumulatedText = `${state.accumulatedText ?? ""}${event.delta}`;
|
||||
}
|
||||
|
||||
return nextState;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -71,19 +142,8 @@ export function saveStreamingState(state: StreamingState): void {
|
||||
*/
|
||||
export function loadStreamingState(conversationId: string): StreamingState | null {
|
||||
try {
|
||||
const key = getStorageKey(conversationId);
|
||||
const data = localStorage.getItem(key);
|
||||
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const state: StreamingState = JSON.parse(data);
|
||||
|
||||
// Check if state has expired
|
||||
const age = Date.now() - state.timestamp;
|
||||
if (age > STATE_EXPIRY_MS) {
|
||||
clearStreamingState(conversationId);
|
||||
const state = readStreamingState(conversationId);
|
||||
if (!state) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -99,54 +159,6 @@ export function loadStreamingState(conversationId: string): StreamingState | nul
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update streaming state with a new event
|
||||
*/
|
||||
export function updateStreamingState(
|
||||
conversationId: string,
|
||||
event: ExtendedResponseStreamEvent,
|
||||
responseId: string,
|
||||
lastMessageId?: string
|
||||
): void {
|
||||
try {
|
||||
const existing = loadStreamingState(conversationId);
|
||||
const sequenceNumber = "sequence_number" in event ? event.sequence_number : undefined;
|
||||
|
||||
const newEvents = existing ? [...existing.events, event] : [event];
|
||||
|
||||
const state: StreamingState = {
|
||||
conversationId,
|
||||
responseId,
|
||||
lastMessageId,
|
||||
lastSequenceNumber: sequenceNumber ?? (existing?.lastSequenceNumber ?? -1),
|
||||
events: newEvents,
|
||||
timestamp: Date.now(),
|
||||
completed: event.type === "response.completed" || event.type === "response.failed",
|
||||
accumulatedText: extractAccumulatedText(newEvents),
|
||||
};
|
||||
|
||||
saveStreamingState(state);
|
||||
} catch (error) {
|
||||
console.error("Failed to update streaming state:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark streaming state as completed
|
||||
*/
|
||||
export function markStreamingCompleted(conversationId: string): void {
|
||||
try {
|
||||
const existing = loadStreamingState(conversationId);
|
||||
if (existing) {
|
||||
existing.completed = true;
|
||||
existing.timestamp = Date.now();
|
||||
saveStreamingState(existing);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to mark streaming as completed:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear streaming state for a conversation
|
||||
*/
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Debug UI for Microsoft Agent Framework with OpenAI-compatible API
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260402"
|
||||
version = "1.0.0b260409"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://github.com/microsoft/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.0.0,<2",
|
||||
"agent-framework-core>=1.0.1,<2",
|
||||
"openai>=1.99.0,<3",
|
||||
"opentelemetry-sdk>=1.39.0,<2",
|
||||
"fastapi>=0.115.0,<0.133.1",
|
||||
|
||||
@@ -0,0 +1,750 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Browser-based regression test for DevUI streaming memory growth."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import AsyncIterable, Awaitable, Generator
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
import uvicorn
|
||||
from agent_framework import (
|
||||
AgentResponse,
|
||||
AgentResponseUpdate,
|
||||
AgentSession,
|
||||
BaseAgent,
|
||||
Content,
|
||||
Message,
|
||||
ResponseStream,
|
||||
)
|
||||
from websockets.asyncio.client import connect as websocket_connect
|
||||
|
||||
from agent_framework_devui import DevServer
|
||||
|
||||
_BROWSER_COMMANDS = (
|
||||
"chrome",
|
||||
"chrome.exe",
|
||||
"google-chrome",
|
||||
"google-chrome-stable",
|
||||
"chromium",
|
||||
"chromium-browser",
|
||||
"microsoft-edge",
|
||||
"msedge",
|
||||
"msedge.exe",
|
||||
)
|
||||
_BROWSER_ENV_VARS = ("DEVUI_TEST_BROWSER", "CHROME_BIN", "BROWSER_BIN")
|
||||
_WINDOWS_PROCESS_QUERY = """
|
||||
$rows = @(
|
||||
Get-CimInstance Win32_Process | ForEach-Object {
|
||||
if (-not $_.CommandLine) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
$process = Get-Process -Id $_.ProcessId -ErrorAction Stop
|
||||
[PSCustomObject]@{
|
||||
pid = [int]$_.ProcessId
|
||||
parent_pid = [int]$_.ParentProcessId
|
||||
rss_kb = [int][Math]::Round($process.WorkingSet64 / 1KB)
|
||||
command = [string]$_.CommandLine
|
||||
}
|
||||
}
|
||||
catch {
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
$rows | ConvertTo-Json -Compress
|
||||
""".strip()
|
||||
|
||||
_STREAM_CHUNK_COUNT = 12_000
|
||||
_STREAM_CHUNK_SIZE = 128
|
||||
_POST_SEND_DELAY_S = 1.0
|
||||
_SAMPLE_INTERVAL_S = 0.5
|
||||
_SAMPLE_WINDOW_S = 12.0
|
||||
_MAX_RENDERER_GROWTH_MB = 500.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _BrowserProcessRow:
|
||||
pid: int
|
||||
parent_pid: int
|
||||
rss_kb: int
|
||||
command: str
|
||||
|
||||
|
||||
class MemoryStressAgent(BaseAgent):
|
||||
"""Agent that emits many small streaming chunks."""
|
||||
|
||||
def __init__(self, *, chunk_count: int, chunk_size: int, delay_ms: float, **kwargs: Any) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self._chunk_count = chunk_count
|
||||
self._chunk_size = max(chunk_size, 24)
|
||||
self._delay_s = max(delay_ms, 0.0) / 1000.0
|
||||
|
||||
def run(
|
||||
self,
|
||||
messages: str | Message | list[str] | list[Message] | None = None,
|
||||
*,
|
||||
stream: bool = False,
|
||||
session: AgentSession | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Awaitable[AgentResponse] | ResponseStream[AgentResponseUpdate, AgentResponse]:
|
||||
del messages, session, kwargs
|
||||
if stream:
|
||||
return self._run_stream()
|
||||
return self._run()
|
||||
|
||||
async def _run(self) -> AgentResponse:
|
||||
text = "".join(self._make_chunk(index) for index in range(self._chunk_count))
|
||||
return AgentResponse(messages=[Message("assistant", [Content.from_text(text=text)])])
|
||||
|
||||
def _run_stream(self) -> ResponseStream[AgentResponseUpdate, AgentResponse]:
|
||||
async def _iter() -> AsyncIterable[AgentResponseUpdate]:
|
||||
for index in range(self._chunk_count):
|
||||
yield AgentResponseUpdate(
|
||||
contents=[Content.from_text(text=self._make_chunk(index))],
|
||||
role="assistant",
|
||||
)
|
||||
if self._delay_s:
|
||||
await asyncio.sleep(self._delay_s)
|
||||
|
||||
return ResponseStream(_iter(), finalizer=AgentResponse.from_updates)
|
||||
|
||||
def _make_chunk(self, index: int) -> str:
|
||||
prefix = f"[{index:06d}] "
|
||||
payload_size = max(self._chunk_size - len(prefix), 1)
|
||||
payload = ("x" * (payload_size - 1)) + ("\n" if index % 8 == 7 else " ")
|
||||
return prefix + payload
|
||||
|
||||
|
||||
class _CDPClient:
|
||||
"""Minimal Chrome DevTools Protocol client for a single attached page."""
|
||||
|
||||
def __init__(self, websocket: Any) -> None:
|
||||
self._websocket = websocket
|
||||
self._next_id = 0
|
||||
|
||||
async def send(
|
||||
self,
|
||||
method: str,
|
||||
params: dict[str, Any] | None = None,
|
||||
*,
|
||||
session_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
self._next_id += 1
|
||||
command_id = self._next_id
|
||||
payload: dict[str, Any] = {"id": command_id, "method": method}
|
||||
if params is not None:
|
||||
payload["params"] = params
|
||||
if session_id is not None:
|
||||
payload["sessionId"] = session_id
|
||||
|
||||
await self._websocket.send(json.dumps(payload))
|
||||
|
||||
while True:
|
||||
raw_message = await self._websocket.recv()
|
||||
if isinstance(raw_message, bytes):
|
||||
raw_message = raw_message.decode("utf-8")
|
||||
|
||||
message = json.loads(raw_message)
|
||||
if message.get("id") != command_id:
|
||||
continue
|
||||
|
||||
error = message.get("error")
|
||||
if isinstance(error, dict):
|
||||
raise RuntimeError(f"CDP command {method} failed: {error}")
|
||||
|
||||
result = message.get("result")
|
||||
return result if isinstance(result, dict) else {}
|
||||
|
||||
async def evaluate(self, expression: str, *, session_id: str) -> Any:
|
||||
result = await self.send(
|
||||
"Runtime.evaluate",
|
||||
{
|
||||
"expression": expression,
|
||||
"awaitPromise": True,
|
||||
"returnByValue": True,
|
||||
},
|
||||
session_id=session_id,
|
||||
)
|
||||
remote_result = result.get("result")
|
||||
if isinstance(remote_result, dict):
|
||||
return remote_result.get("value")
|
||||
return None
|
||||
|
||||
|
||||
def _get_browser_candidates() -> tuple[Path, ...]:
|
||||
if sys.platform == "darwin":
|
||||
return (
|
||||
Path("/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"),
|
||||
Path("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"),
|
||||
Path("/Applications/Chromium.app/Contents/MacOS/Chromium"),
|
||||
)
|
||||
|
||||
if sys.platform == "win32":
|
||||
windows_bases: list[Path] = []
|
||||
for env_var in ("PROGRAMFILES", "PROGRAMFILES(X86)", "LOCALAPPDATA"):
|
||||
raw_value = os.environ.get(env_var)
|
||||
if raw_value:
|
||||
windows_bases.append(Path(raw_value))
|
||||
|
||||
return tuple(
|
||||
dict.fromkeys(
|
||||
[base / "Microsoft/Edge/Application/msedge.exe" for base in windows_bases]
|
||||
+ [base / "Google/Chrome/Application/chrome.exe" for base in windows_bases]
|
||||
+ [base / "Chromium/Application/chrome.exe" for base in windows_bases]
|
||||
)
|
||||
)
|
||||
|
||||
return (
|
||||
Path("/usr/bin/google-chrome"),
|
||||
Path("/usr/bin/google-chrome-stable"),
|
||||
Path("/usr/bin/chromium"),
|
||||
Path("/usr/bin/chromium-browser"),
|
||||
Path("/usr/bin/microsoft-edge"),
|
||||
Path("/opt/google/chrome/chrome"),
|
||||
Path("/opt/microsoft/msedge/msedge"),
|
||||
Path("/snap/bin/chromium"),
|
||||
)
|
||||
|
||||
|
||||
def _find_browser_executable() -> Path | None:
|
||||
for env_var in _BROWSER_ENV_VARS:
|
||||
configured_path = os.environ.get(env_var)
|
||||
if not configured_path:
|
||||
continue
|
||||
|
||||
candidate = Path(configured_path).expanduser()
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
|
||||
for candidate in _get_browser_candidates():
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
|
||||
for command in _BROWSER_COMMANDS:
|
||||
resolved = shutil.which(command)
|
||||
if resolved is not None:
|
||||
return Path(resolved)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _find_available_port() -> int:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
sock.listen(1)
|
||||
return int(sock.getsockname()[1])
|
||||
|
||||
|
||||
def _get_json_response(*, host: str, port: int, path: str) -> dict[str, Any]:
|
||||
connection = http.client.HTTPConnection(host, port, timeout=5)
|
||||
try:
|
||||
connection.request("GET", path)
|
||||
response = connection.getresponse()
|
||||
if response.status != 200:
|
||||
raise RuntimeError(f"Request to {path} failed with status {response.status}")
|
||||
payload = response.read().decode("utf-8")
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
data = json.loads(payload)
|
||||
if isinstance(data, dict):
|
||||
return data
|
||||
raise RuntimeError(f"Expected JSON object from {path}, got: {type(data).__name__}")
|
||||
|
||||
|
||||
async def _get_devtools_websocket_url(port: int) -> str:
|
||||
deadline = time.monotonic() + 10.0
|
||||
while time.monotonic() < deadline:
|
||||
with contextlib.suppress(Exception):
|
||||
version_data = _get_json_response(host="127.0.0.1", port=port, path="/json/version")
|
||||
websocket_url = version_data.get("webSocketDebuggerUrl")
|
||||
if isinstance(websocket_url, str) and websocket_url:
|
||||
return websocket_url
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
raise RuntimeError(f"Timed out waiting for DevTools on port {port}")
|
||||
|
||||
|
||||
def _wait_for_server_details(server_instance: uvicorn.Server) -> tuple[int, str]:
|
||||
deadline = time.monotonic() + 10.0
|
||||
actual_port: int | None = None
|
||||
|
||||
while time.monotonic() < deadline:
|
||||
if hasattr(server_instance, "servers") and server_instance.servers:
|
||||
for uvicorn_server in server_instance.servers:
|
||||
sockets = getattr(uvicorn_server, "sockets", None)
|
||||
if not sockets:
|
||||
continue
|
||||
actual_port = int(sockets[0].getsockname()[1])
|
||||
break
|
||||
|
||||
if actual_port is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
health = _get_json_response(host="127.0.0.1", port=actual_port, path="/health")
|
||||
if health.get("status") == "healthy":
|
||||
entities = _get_json_response(host="127.0.0.1", port=actual_port, path="/v1/entities")
|
||||
entity_list = entities.get("entities")
|
||||
if isinstance(entity_list, list) and entity_list:
|
||||
entity = entity_list[0]
|
||||
if isinstance(entity, dict) and isinstance(entity.get("id"), str):
|
||||
return actual_port, entity["id"]
|
||||
time.sleep(0.1)
|
||||
|
||||
raise RuntimeError("Timed out waiting for DevUI server startup")
|
||||
|
||||
|
||||
def _parse_posix_process_rows(output: str) -> list[_BrowserProcessRow]:
|
||||
rows: list[_BrowserProcessRow] = []
|
||||
for line in output.splitlines():
|
||||
parts = line.strip().split(None, 3)
|
||||
if len(parts) != 4:
|
||||
continue
|
||||
|
||||
pid_text, parent_pid_text, rss_text, command = parts
|
||||
with contextlib.suppress(ValueError):
|
||||
rows.append(
|
||||
_BrowserProcessRow(
|
||||
pid=int(pid_text),
|
||||
parent_pid=int(parent_pid_text),
|
||||
rss_kb=int(rss_text),
|
||||
command=command,
|
||||
)
|
||||
)
|
||||
|
||||
return rows
|
||||
|
||||
|
||||
def _parse_windows_process_rows(output: str) -> list[_BrowserProcessRow]:
|
||||
text = output.strip()
|
||||
if not text:
|
||||
return []
|
||||
|
||||
payload = json.loads(text)
|
||||
items = payload if isinstance(payload, list) else [payload]
|
||||
|
||||
rows: list[_BrowserProcessRow] = []
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
|
||||
pid = item.get("pid")
|
||||
parent_pid = item.get("parent_pid")
|
||||
rss_kb = item.get("rss_kb")
|
||||
command = item.get("command")
|
||||
if not all(isinstance(value, int) for value in (pid, parent_pid, rss_kb)):
|
||||
continue
|
||||
if not isinstance(command, str):
|
||||
continue
|
||||
|
||||
rows.append(
|
||||
_BrowserProcessRow(
|
||||
pid=pid,
|
||||
parent_pid=parent_pid,
|
||||
rss_kb=rss_kb,
|
||||
command=command,
|
||||
)
|
||||
)
|
||||
|
||||
return rows
|
||||
|
||||
|
||||
def _read_process_rows() -> list[_BrowserProcessRow]:
|
||||
if sys.platform == "win32":
|
||||
result = subprocess.run(
|
||||
["powershell", "-NoProfile", "-Command", _WINDOWS_PROCESS_QUERY],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
encoding="utf-8",
|
||||
)
|
||||
return _parse_windows_process_rows(result.stdout)
|
||||
|
||||
result = subprocess.run(
|
||||
["ps", "-axo", "pid=,ppid=,rss=,command="],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
return _parse_posix_process_rows(result.stdout)
|
||||
|
||||
|
||||
def _collect_process_tree(root_pids: set[int], process_rows: list[_BrowserProcessRow]) -> list[_BrowserProcessRow]:
|
||||
process_by_pid = {row.pid: row for row in process_rows}
|
||||
child_pids_by_parent: dict[int, list[int]] = {}
|
||||
for row in process_rows:
|
||||
child_pids_by_parent.setdefault(row.parent_pid, []).append(row.pid)
|
||||
|
||||
collected_rows: list[_BrowserProcessRow] = []
|
||||
seen_pids: set[int] = set()
|
||||
pending_pids = list(root_pids)
|
||||
|
||||
while pending_pids:
|
||||
pid = pending_pids.pop()
|
||||
if pid in seen_pids:
|
||||
continue
|
||||
|
||||
seen_pids.add(pid)
|
||||
process_row = process_by_pid.get(pid)
|
||||
if process_row is None:
|
||||
continue
|
||||
|
||||
collected_rows.append(process_row)
|
||||
pending_pids.extend(child_pids_by_parent.get(pid, []))
|
||||
|
||||
return collected_rows
|
||||
|
||||
|
||||
def _collect_browser_process_rows(root_pid: int, profile_dir: str) -> list[_BrowserProcessRow]:
|
||||
process_rows = _read_process_rows()
|
||||
normalized_profile_dir = profile_dir.casefold()
|
||||
matched_root_pids = {row.pid for row in process_rows if normalized_profile_dir in row.command.casefold()}
|
||||
matched_root_pids.add(root_pid)
|
||||
return _collect_process_tree(matched_root_pids, process_rows)
|
||||
|
||||
|
||||
def _sample_peak_renderer_rss_mb(root_pid: int, profile_dir: str) -> float:
|
||||
renderer_rss_kb = [
|
||||
row.rss_kb
|
||||
for row in _collect_browser_process_rows(root_pid, profile_dir)
|
||||
if "--type=renderer" in row.command.casefold()
|
||||
]
|
||||
return round((max(renderer_rss_kb, default=0)) / 1024, 2)
|
||||
|
||||
|
||||
def _terminate_browser_processes(root_pid: int, profile_dir: str) -> None:
|
||||
browser_rows = _collect_browser_process_rows(root_pid, profile_dir)
|
||||
browser_pids = sorted({row.pid for row in browser_rows} | {root_pid}, reverse=True)
|
||||
|
||||
if sys.platform == "win32":
|
||||
for pid in browser_pids:
|
||||
with contextlib.suppress(subprocess.CalledProcessError):
|
||||
subprocess.run(
|
||||
["taskkill", "/PID", str(pid), "/T", "/F"],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=True,
|
||||
)
|
||||
return
|
||||
|
||||
for pid in browser_pids:
|
||||
with contextlib.suppress(ProcessLookupError):
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
|
||||
|
||||
def _launch_browser_process(*, browser_path: Path, debug_port: int, profile_dir: str) -> subprocess.Popen[str]:
|
||||
return subprocess.Popen(
|
||||
[
|
||||
str(browser_path),
|
||||
"--headless=new",
|
||||
f"--remote-debugging-port={debug_port}",
|
||||
"--remote-debugging-address=127.0.0.1",
|
||||
f"--user-data-dir={profile_dir}",
|
||||
"--no-first-run",
|
||||
"--no-default-browser-check",
|
||||
"--disable-background-networking",
|
||||
"--disable-sync",
|
||||
"--disable-renderer-backgrounding",
|
||||
"--hide-scrollbars",
|
||||
"--mute-audio",
|
||||
"--enable-precise-memory-info",
|
||||
"--no-sandbox",
|
||||
"about:blank",
|
||||
],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
text=True,
|
||||
)
|
||||
|
||||
|
||||
def _shutdown_browser_process(browser_process: subprocess.Popen[str], *, profile_dir: str) -> None:
|
||||
with contextlib.suppress(Exception):
|
||||
browser_process.terminate()
|
||||
browser_process.wait(timeout=5)
|
||||
_terminate_browser_processes(browser_process.pid, profile_dir)
|
||||
|
||||
|
||||
def test_parse_posix_process_rows() -> None:
|
||||
output = """
|
||||
101 1 2048 /usr/bin/google-chrome --user-data-dir=/tmp/devui-memory
|
||||
202 101 4096 /usr/bin/google-chrome --type=renderer --lang=en-US
|
||||
""".strip()
|
||||
|
||||
assert _parse_posix_process_rows(output) == [
|
||||
_BrowserProcessRow(
|
||||
pid=101,
|
||||
parent_pid=1,
|
||||
rss_kb=2048,
|
||||
command="/usr/bin/google-chrome --user-data-dir=/tmp/devui-memory",
|
||||
),
|
||||
_BrowserProcessRow(
|
||||
pid=202,
|
||||
parent_pid=101,
|
||||
rss_kb=4096,
|
||||
command="/usr/bin/google-chrome --type=renderer --lang=en-US",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def test_parse_windows_process_rows() -> None:
|
||||
output = json.dumps([
|
||||
{
|
||||
"pid": 301,
|
||||
"parent_pid": 1,
|
||||
"rss_kb": 2048,
|
||||
"command": r"C:\Program Files\Google\Chrome\Application\chrome.exe",
|
||||
},
|
||||
{
|
||||
"pid": 302,
|
||||
"parent_pid": 301,
|
||||
"rss_kb": 6144,
|
||||
"command": r"C:\Program Files\Google\Chrome\Application\chrome.exe --type=renderer",
|
||||
},
|
||||
])
|
||||
|
||||
assert _parse_windows_process_rows(output) == [
|
||||
_BrowserProcessRow(
|
||||
pid=301,
|
||||
parent_pid=1,
|
||||
rss_kb=2048,
|
||||
command=r"C:\Program Files\Google\Chrome\Application\chrome.exe",
|
||||
),
|
||||
_BrowserProcessRow(
|
||||
pid=302,
|
||||
parent_pid=301,
|
||||
rss_kb=6144,
|
||||
command=r"C:\Program Files\Google\Chrome\Application\chrome.exe --type=renderer",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def test_sample_peak_renderer_rss_mb_uses_browser_process_tree(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
profile_dir = "/tmp/devui-memory-browser"
|
||||
process_rows = [
|
||||
_BrowserProcessRow(
|
||||
pid=101,
|
||||
parent_pid=1,
|
||||
rss_kb=1024,
|
||||
command="/usr/bin/google-chrome",
|
||||
),
|
||||
_BrowserProcessRow(
|
||||
pid=102,
|
||||
parent_pid=101,
|
||||
rss_kb=4096,
|
||||
command="/usr/bin/google-chrome --type=renderer",
|
||||
),
|
||||
_BrowserProcessRow(
|
||||
pid=201,
|
||||
parent_pid=1,
|
||||
rss_kb=2048,
|
||||
command=f"/usr/bin/google-chrome --user-data-dir={profile_dir}",
|
||||
),
|
||||
_BrowserProcessRow(
|
||||
pid=202,
|
||||
parent_pid=201,
|
||||
rss_kb=8192,
|
||||
command="/usr/bin/google-chrome --type=renderer",
|
||||
),
|
||||
_BrowserProcessRow(
|
||||
pid=999,
|
||||
parent_pid=1,
|
||||
rss_kb=32768,
|
||||
command="/usr/bin/google-chrome --type=renderer",
|
||||
),
|
||||
]
|
||||
|
||||
monkeypatch.setattr(sys.modules[__name__], "_read_process_rows", lambda: process_rows)
|
||||
|
||||
assert _sample_peak_renderer_rss_mb(101, profile_dir) == 8.0
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def memory_regression_server() -> Generator[tuple[str, str]]:
|
||||
"""Start DevUI with a synthetic streaming agent and yield the base URL plus entity ID."""
|
||||
|
||||
server = DevServer(host="127.0.0.1", port=0)
|
||||
server.register_entities([
|
||||
MemoryStressAgent(
|
||||
id="memory-stream-agent",
|
||||
name="MemoryStreamAgent",
|
||||
description="Streams many small chunks for UI memory profiling.",
|
||||
chunk_count=_STREAM_CHUNK_COUNT,
|
||||
chunk_size=_STREAM_CHUNK_SIZE,
|
||||
delay_ms=1.0,
|
||||
)
|
||||
])
|
||||
|
||||
app = server.get_app()
|
||||
server_config = uvicorn.Config(
|
||||
app=app,
|
||||
host="127.0.0.1",
|
||||
port=0,
|
||||
log_level="error",
|
||||
ws="none",
|
||||
)
|
||||
server_instance = uvicorn.Server(server_config)
|
||||
|
||||
def run_server() -> None:
|
||||
asyncio.run(server_instance.serve())
|
||||
|
||||
server_thread = threading.Thread(target=run_server, daemon=True)
|
||||
server_thread.start()
|
||||
|
||||
actual_port, entity_id = _wait_for_server_details(server_instance)
|
||||
yield f"http://127.0.0.1:{actual_port}", entity_id
|
||||
|
||||
with contextlib.suppress(Exception):
|
||||
server_instance.should_exit = True
|
||||
server_thread.join(timeout=5)
|
||||
|
||||
|
||||
async def _wait_for_expression(
|
||||
client: _CDPClient,
|
||||
*,
|
||||
session_id: str,
|
||||
expression: str,
|
||||
timeout_s: float,
|
||||
) -> Any:
|
||||
deadline = time.monotonic() + timeout_s
|
||||
while time.monotonic() < deadline:
|
||||
value = await client.evaluate(expression, session_id=session_id)
|
||||
if value:
|
||||
return value
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
raise AssertionError(f"Timed out waiting for expression: {expression}")
|
||||
|
||||
|
||||
async def test_devui_streaming_renderer_memory_is_bounded(
|
||||
memory_regression_server: tuple[str, str],
|
||||
) -> None:
|
||||
"""Fail when frontend renderer memory grows unbounded during streaming."""
|
||||
|
||||
browser_path = _find_browser_executable()
|
||||
if browser_path is None:
|
||||
pytest.skip("No Chromium-based browser found for DevUI memory regression test")
|
||||
|
||||
base_url, entity_id = memory_regression_server
|
||||
debug_port = _find_available_port()
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="devui-memory-browser-") as profile_dir:
|
||||
browser_process = _launch_browser_process(
|
||||
browser_path=browser_path,
|
||||
debug_port=debug_port,
|
||||
profile_dir=profile_dir,
|
||||
)
|
||||
|
||||
try:
|
||||
websocket_url = await _get_devtools_websocket_url(debug_port)
|
||||
|
||||
async with websocket_connect(websocket_url, max_size=None) as websocket:
|
||||
client = _CDPClient(websocket)
|
||||
|
||||
target = await client.send("Target.createTarget", {"url": "about:blank"})
|
||||
target_id = target["targetId"]
|
||||
attached = await client.send(
|
||||
"Target.attachToTarget",
|
||||
{"targetId": target_id, "flatten": True},
|
||||
)
|
||||
session_id = attached["sessionId"]
|
||||
|
||||
await client.send("Page.enable", session_id=session_id)
|
||||
await client.send("Runtime.enable", session_id=session_id)
|
||||
await client.send(
|
||||
"Page.navigate",
|
||||
{"url": f"{base_url}/?entity_id={entity_id}"},
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
await _wait_for_expression(
|
||||
client,
|
||||
session_id=session_id,
|
||||
expression=(
|
||||
"Boolean("
|
||||
"document.querySelector('textarea') && "
|
||||
"document.querySelector('button[aria-label=\"Send message\"]')"
|
||||
")"
|
||||
),
|
||||
timeout_s=30.0,
|
||||
)
|
||||
|
||||
start_renderer_rss_mb = _sample_peak_renderer_rss_mb(
|
||||
browser_process.pid,
|
||||
profile_dir,
|
||||
)
|
||||
|
||||
await client.evaluate(
|
||||
"""
|
||||
(() => {
|
||||
const textarea = document.querySelector("textarea");
|
||||
const valueSetter = Object.getOwnPropertyDescriptor(
|
||||
HTMLTextAreaElement.prototype,
|
||||
"value"
|
||||
).set;
|
||||
valueSetter.call(textarea, "Stream a very long answer.");
|
||||
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
document.querySelector('button[aria-label="Send message"]').click();
|
||||
return true;
|
||||
})()
|
||||
""",
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
await _wait_for_expression(
|
||||
client,
|
||||
session_id=session_id,
|
||||
expression="Boolean(document.querySelector('button[aria-label=\"Stop generating response\"]'))",
|
||||
timeout_s=10.0,
|
||||
)
|
||||
|
||||
await asyncio.sleep(_POST_SEND_DELAY_S)
|
||||
|
||||
peak_renderer_rss_mb = start_renderer_rss_mb
|
||||
samples: list[tuple[float, float]] = [(0.0, start_renderer_rss_mb)]
|
||||
start_time = time.monotonic()
|
||||
|
||||
while time.monotonic() - start_time < _SAMPLE_WINDOW_S:
|
||||
current_sample = _sample_peak_renderer_rss_mb(
|
||||
browser_process.pid,
|
||||
profile_dir,
|
||||
)
|
||||
elapsed_s = round(time.monotonic() - start_time, 2)
|
||||
samples.append((elapsed_s, current_sample))
|
||||
peak_renderer_rss_mb = max(peak_renderer_rss_mb, current_sample)
|
||||
|
||||
if peak_renderer_rss_mb - start_renderer_rss_mb > _MAX_RENDERER_GROWTH_MB:
|
||||
break
|
||||
|
||||
await asyncio.sleep(_SAMPLE_INTERVAL_S)
|
||||
|
||||
renderer_growth_mb = round(peak_renderer_rss_mb - start_renderer_rss_mb, 2)
|
||||
assert renderer_growth_mb <= _MAX_RENDERER_GROWTH_MB, (
|
||||
"DevUI renderer memory grew too much during a ~1.5 MB streaming response. "
|
||||
f"start={start_renderer_rss_mb:.2f}MB "
|
||||
f"peak={peak_renderer_rss_mb:.2f}MB "
|
||||
f"growth={renderer_growth_mb:.2f}MB "
|
||||
f"budget={_MAX_RENDERER_GROWTH_MB:.2f}MB "
|
||||
f"samples={samples}"
|
||||
)
|
||||
finally:
|
||||
_shutdown_browser_process(browser_process, profile_dir=profile_dir)
|
||||
@@ -4,7 +4,7 @@ description = "Durable Task integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260402"
|
||||
version = "1.0.0b260409"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -22,7 +22,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.0.0,<2",
|
||||
"agent-framework-core>=1.0.1,<2",
|
||||
"durabletask>=1.3.0,<2",
|
||||
"durabletask-azuremanaged>=1.3.0,<2",
|
||||
"python-dateutil>=2.8.0,<3",
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Microsoft Foundry integrations for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0"
|
||||
version = "1.0.1"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,8 +23,8 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.0.0,<2",
|
||||
"agent-framework-openai>=1.0.0,<2",
|
||||
"agent-framework-core>=1.0.1,<2",
|
||||
"agent-framework-openai>=1.0.1,<2",
|
||||
"azure-ai-inference>=1.0.0b9,<1.0.0b10",
|
||||
"azure-ai-projects>=2.0.0,<3.0",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) Microsoft Corporation.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE
|
||||
@@ -0,0 +1,11 @@
|
||||
# Foundry Hosting
|
||||
|
||||
This package provides the integration of Agent Framework agents and workflows with the Foundry Agent Server, which can be hosted on Foundry infrastructure.
|
||||
|
||||
## Responses
|
||||
|
||||
TODO
|
||||
|
||||
## Invocations
|
||||
|
||||
TODO
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user