Merge branch 'main' into copilot/add-error-checking-workflow-samples
This commit is contained in:
@@ -48,7 +48,8 @@ jobs:
|
||||
.
|
||||
.github
|
||||
dotnet
|
||||
workflow-samples
|
||||
python
|
||||
declarative-agents
|
||||
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@v5.2.0
|
||||
@@ -63,6 +64,20 @@ jobs:
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
|
||||
- name: Generate filtered solution
|
||||
shell: pwsh
|
||||
run: |
|
||||
./dotnet/eng/scripts/New-FilteredSolution.ps1 `
|
||||
-Solution dotnet/agent-framework-dotnet.slnx `
|
||||
-TargetFramework net10.0 `
|
||||
-Configuration Debug `
|
||||
-OutputPath dotnet/filtered.slnx `
|
||||
-Verbose
|
||||
|
||||
- name: Build solution
|
||||
shell: bash
|
||||
run: dotnet build dotnet/filtered.slnx -f net10.0 --warnaserror
|
||||
|
||||
- name: Run verify-samples
|
||||
id: verify
|
||||
working-directory: dotnet
|
||||
|
||||
@@ -115,12 +115,13 @@ jobs:
|
||||
-m "not integration"
|
||||
--timeout=120 --session-timeout=900 --timeout_method thread
|
||||
--retries 2 --retry-delay 5
|
||||
--junitxml=pytest.xml
|
||||
working-directory: ./python
|
||||
- name: Surface failing tests
|
||||
if: always()
|
||||
uses: pmeier/pytest-results-action@v0.7.2
|
||||
with:
|
||||
path: ./python/**.xml
|
||||
path: ./python/pytest.xml
|
||||
summary: true
|
||||
display-options: fEX
|
||||
fail-on-empty: false
|
||||
@@ -163,6 +164,7 @@ jobs:
|
||||
-n logical --dist worksteal
|
||||
--timeout=120 --session-timeout=900 --timeout_method thread
|
||||
--retries 2 --retry-delay 5
|
||||
--junitxml=pytest.xml
|
||||
working-directory: ./python
|
||||
- name: Test OpenAI samples
|
||||
timeout-minutes: 10
|
||||
@@ -173,7 +175,7 @@ jobs:
|
||||
if: always()
|
||||
uses: pmeier/pytest-results-action@v0.7.2
|
||||
with:
|
||||
path: ./python/**.xml
|
||||
path: ./python/pytest.xml
|
||||
summary: true
|
||||
display-options: fEX
|
||||
fail-on-empty: false
|
||||
@@ -225,6 +227,7 @@ jobs:
|
||||
-n logical --dist worksteal
|
||||
--timeout=120 --session-timeout=900 --timeout_method thread
|
||||
--retries 2 --retry-delay 5
|
||||
--junitxml=pytest.xml
|
||||
working-directory: ./python
|
||||
- name: Test Azure samples
|
||||
timeout-minutes: 10
|
||||
@@ -235,7 +238,7 @@ jobs:
|
||||
if: always()
|
||||
uses: pmeier/pytest-results-action@v0.7.2
|
||||
with:
|
||||
path: ./python/**.xml
|
||||
path: ./python/pytest.xml
|
||||
summary: true
|
||||
display-options: fEX
|
||||
fail-on-empty: false
|
||||
@@ -285,6 +288,7 @@ jobs:
|
||||
-n logical --dist worksteal
|
||||
--timeout=120 --session-timeout=900 --timeout_method thread
|
||||
--retries 2 --retry-delay 5
|
||||
--junitxml=pytest.xml
|
||||
working-directory: ./python
|
||||
- name: Stop local MCP server
|
||||
if: always()
|
||||
@@ -310,7 +314,7 @@ jobs:
|
||||
if: always()
|
||||
uses: pmeier/pytest-results-action@v0.7.2
|
||||
with:
|
||||
path: ./python/**.xml
|
||||
path: ./python/pytest.xml
|
||||
summary: true
|
||||
display-options: fEX
|
||||
fail-on-empty: false
|
||||
@@ -375,12 +379,13 @@ jobs:
|
||||
-x
|
||||
--timeout=360 --session-timeout=900 --timeout_method thread
|
||||
--retries 2 --retry-delay 5
|
||||
--junitxml=pytest.xml
|
||||
working-directory: ./python
|
||||
- name: Surface failing tests
|
||||
if: always()
|
||||
uses: pmeier/pytest-results-action@v0.7.2
|
||||
with:
|
||||
path: ./python/**.xml
|
||||
path: ./python/pytest.xml
|
||||
summary: true
|
||||
display-options: fEX
|
||||
fail-on-empty: false
|
||||
@@ -430,12 +435,13 @@ jobs:
|
||||
-n logical --dist worksteal
|
||||
--timeout=120 --session-timeout=900 --timeout_method thread
|
||||
--retries 2 --retry-delay 5
|
||||
--junitxml=pytest.xml
|
||||
working-directory: ./python
|
||||
- name: Surface failing tests
|
||||
if: always()
|
||||
uses: pmeier/pytest-results-action@v0.7.2
|
||||
with:
|
||||
path: ./python/**.xml
|
||||
path: ./python/pytest.xml
|
||||
summary: true
|
||||
display-options: fEX
|
||||
fail-on-empty: false
|
||||
@@ -489,13 +495,13 @@ jobs:
|
||||
echo "Cosmos DB emulator did not become ready in time." >&2
|
||||
exit 1
|
||||
- name: Test with pytest (Cosmos integration)
|
||||
run: uv run --directory packages/azure-cosmos poe integration-tests -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5
|
||||
run: uv run --directory packages/azure-cosmos poe integration-tests -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5 --junitxml=pytest.xml
|
||||
working-directory: ./python
|
||||
- name: Surface failing tests
|
||||
if: always()
|
||||
uses: pmeier/pytest-results-action@v0.7.2
|
||||
with:
|
||||
path: ./python/**.xml
|
||||
path: ./python/pytest.xml
|
||||
summary: true
|
||||
display-options: fEX
|
||||
fail-on-empty: false
|
||||
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
UV_CACHE_DIR: /tmp/.uv-cache
|
||||
# Unit tests
|
||||
- name: Run all tests
|
||||
run: uv run poe test -A
|
||||
run: uv run poe test -A --junitxml=pytest.xml
|
||||
working-directory: ./python
|
||||
|
||||
# Surface failing tests
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
if: always()
|
||||
uses: pmeier/pytest-results-action@v0.7.2
|
||||
with:
|
||||
path: ./python/**.xml
|
||||
path: ./python/pytest.xml
|
||||
summary: true
|
||||
display-options: fEX
|
||||
fail-on-empty: false
|
||||
|
||||
@@ -47,6 +47,8 @@ htmlcov/
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
pytest.xml
|
||||
python-coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
|
||||
@@ -9,9 +9,16 @@ The `verify-samples` project (`dotnet/eng/verify-samples/`) is an automated tool
|
||||
|
||||
## Running verify-samples
|
||||
|
||||
**Important:** By default, samples must be pre-built before running verify-samples. Build the solution first, or pass `--build` to build samples during the run:
|
||||
|
||||
```bash
|
||||
cd dotnet
|
||||
dotnet build agent-framework-dotnet.slnx -f net10.0
|
||||
```
|
||||
|
||||
Then run verify-samples:
|
||||
|
||||
```bash
|
||||
# Run all samples across all categories
|
||||
dotnet run --project eng/verify-samples -- --log results.log --csv results.csv
|
||||
|
||||
@@ -24,6 +31,10 @@ dotnet run --project eng/verify-samples -- Agent_Step02_StructuredOutput Agent_S
|
||||
# Control parallelism (default 8)
|
||||
dotnet run --project eng/verify-samples -- --parallel 8 --log results.log
|
||||
|
||||
# Build samples during run (skips the need for a prior build step)
|
||||
# This may cause build conflicts as multiple samples are built in parallel, so use with caution
|
||||
dotnet run --project eng/verify-samples -- --build --log results.log
|
||||
|
||||
# Combine options
|
||||
dotnet run --project eng/verify-samples -- --category 03-workflows --parallel 4 --log results.log --csv results.csv --md results.md
|
||||
```
|
||||
|
||||
@@ -246,7 +246,7 @@ internal static class AgentsSamples
|
||||
ExpectedOutputDescription =
|
||||
[
|
||||
"The output should contain information about both the current time and the weather in Seattle.",
|
||||
"The weather information should reference the plugin result: cloudy with a high of 15°C.",
|
||||
"The weather information should be similar to: cloudy with a high of 15°C. Exact phrasing may vary.",
|
||||
"The output should not contain error messages or stack traces.",
|
||||
],
|
||||
},
|
||||
@@ -521,7 +521,7 @@ internal static class AgentsSamples
|
||||
OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME"],
|
||||
ExpectedOutputDescription =
|
||||
[
|
||||
"The output should demonstrate server-side conversation sessions with non-streaming and streaming turns.",
|
||||
"The output should contain multiple joke responses showing a multi-turn conversation.",
|
||||
"The output should not contain error messages or stack traces.",
|
||||
],
|
||||
},
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
// dotnet run -- --log results.log # Write sequential log to file
|
||||
// dotnet run -- --csv results.csv # Write CSV summary to file
|
||||
// dotnet run -- --md results.md # Write Markdown summary to file
|
||||
// dotnet run -- --build # Build samples during run (default: --no-build)
|
||||
// Note: By default, this tool expects sample build outputs to already exist.
|
||||
// Pre-build the solution before running, or pass --build to avoid missing build output failures.
|
||||
//
|
||||
// Required environment variables (for AI-powered samples):
|
||||
// AZURE_OPENAI_ENDPOINT
|
||||
@@ -63,7 +66,7 @@ try
|
||||
// Run all samples
|
||||
var reporter = new ConsoleReporter();
|
||||
var verifier = new SampleVerifier(chatClient);
|
||||
var orchestrator = new VerificationOrchestrator(verifier, reporter, dotnetRoot, TimeSpan.FromMinutes(3), logWriter);
|
||||
var orchestrator = new VerificationOrchestrator(verifier, reporter, dotnetRoot, TimeSpan.FromMinutes(3), logWriter, buildSamples: options.BuildSamples);
|
||||
|
||||
var run = await orchestrator.RunAllAsync(options.Samples, options.MaxParallelism);
|
||||
|
||||
|
||||
@@ -20,23 +20,32 @@ internal static class SampleRunner
|
||||
{
|
||||
/// <summary>
|
||||
/// Runs <c>dotnet run --framework net10.0</c> in the given project directory.
|
||||
/// When <paramref name="build"/> is false (the default), <c>--no-build</c> is passed
|
||||
/// to skip building, assuming the project was pre-built.
|
||||
/// </summary>
|
||||
public static Task<SampleRunResult> RunAsync(
|
||||
string projectPath,
|
||||
TimeSpan timeout,
|
||||
bool build = false,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> RunAsync(projectPath, "run --framework net10.0", timeout, inputs: null, inputDelayMs: 0, cancellationToken: cancellationToken);
|
||||
=> RunAsync(projectPath, DotnetRunArgs(build), timeout, inputs: null, inputDelayMs: 0, cancellationToken: cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Runs <c>dotnet run --framework net10.0</c> with stdin inputs.
|
||||
/// When <paramref name="build"/> is false (the default), <c>--no-build</c> is passed
|
||||
/// to skip building, assuming the project was pre-built.
|
||||
/// </summary>
|
||||
public static Task<SampleRunResult> RunAsync(
|
||||
string projectPath,
|
||||
TimeSpan timeout,
|
||||
string?[]? inputs,
|
||||
int inputDelayMs = 2000,
|
||||
bool build = false,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> RunAsync(projectPath, "run --framework net10.0", timeout, inputs, inputDelayMs, cancellationToken);
|
||||
=> RunAsync(projectPath, DotnetRunArgs(build), timeout, inputs, inputDelayMs, cancellationToken);
|
||||
|
||||
private static string DotnetRunArgs(bool build) =>
|
||||
$"run {(build ? "" : "--no-build")} --framework net10.0";
|
||||
|
||||
/// <summary>
|
||||
/// Runs an arbitrary <c>dotnet</c> command in the given working directory.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.ComponentModel;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
@@ -27,11 +28,19 @@ internal sealed class SampleVerifier
|
||||
instructions: """
|
||||
You are a test output verifier. You will be given:
|
||||
1. The actual stdout output of a program
|
||||
2. A list of expectations about what the output should contain or demonstrate
|
||||
2. The stderr output (if any)
|
||||
3. A list of expectations about what the output should contain or demonstrate
|
||||
|
||||
Your job is to determine whether the actual output satisfies each expectation.
|
||||
Be reasonable — the output comes from an LLM so exact wording won't match, but the
|
||||
semantic intent should be clearly satisfied.
|
||||
|
||||
In your response, you MUST:
|
||||
- Always provide ai_reasoning with a brief overall assessment.
|
||||
- Always provide exactly one entry in expectation_results for each expectation,
|
||||
in the same order as the input list.
|
||||
- For each expectation_results entry, echo the expectation text in the expectation
|
||||
field and explain your assessment in the detail field, citing evidence from the output.
|
||||
""",
|
||||
name: "OutputVerifier");
|
||||
}
|
||||
@@ -78,7 +87,7 @@ internal sealed class SampleVerifier
|
||||
}
|
||||
else
|
||||
{
|
||||
var aiResult = await this.VerifyWithAIAsync(run.Stdout, sample.ExpectedOutputDescription);
|
||||
var aiResult = await this.VerifyWithAIAsync(run.Stdout, run.Stderr, sample.ExpectedOutputDescription);
|
||||
aiReasoning = aiResult.Reasoning;
|
||||
|
||||
foreach (var unmet in aiResult.UnmetExpectations)
|
||||
@@ -100,16 +109,28 @@ internal sealed class SampleVerifier
|
||||
}
|
||||
|
||||
private async Task<(string Reasoning, List<string> UnmetExpectations)> VerifyWithAIAsync(
|
||||
string actualOutput,
|
||||
string stdout,
|
||||
string stderr,
|
||||
string[] expectations)
|
||||
{
|
||||
var expectationList = string.Join("\n", expectations.Select((e, i) => $" {i + 1}. {e}"));
|
||||
|
||||
var stderrSection = string.IsNullOrWhiteSpace(stderr)
|
||||
? ""
|
||||
: $"""
|
||||
|
||||
Stderr output:
|
||||
---
|
||||
{Truncate(stderr, 2000)}
|
||||
---
|
||||
""";
|
||||
|
||||
var prompt = $"""
|
||||
Actual program output:
|
||||
---
|
||||
{Truncate(actualOutput, 4000)}
|
||||
{Truncate(stdout, 4000)}
|
||||
---
|
||||
|
||||
{stderrSection}
|
||||
Expectations to verify:
|
||||
{expectationList}
|
||||
|
||||
@@ -126,7 +147,9 @@ internal sealed class SampleVerifier
|
||||
return ($"AI verification returned null result. Raw: {response.Text}", ["AI verification returned null result."]);
|
||||
}
|
||||
|
||||
var reasoning = result.Reasoning ?? "(no reasoning provided)";
|
||||
var reasoning = string.IsNullOrWhiteSpace(result.AIReasoning)
|
||||
? "(no reasoning provided)"
|
||||
: result.AIReasoning;
|
||||
|
||||
// Collect unmet expectations as individual failures
|
||||
var unmet = new List<string>();
|
||||
@@ -174,12 +197,14 @@ internal sealed class AIVerificationResponse
|
||||
public bool Pass { get; set; }
|
||||
|
||||
/// <summary>Brief explanation of the overall assessment.</summary>
|
||||
[JsonPropertyName("reasoning")]
|
||||
public string? Reasoning { get; set; }
|
||||
[JsonPropertyName("ai_reasoning")]
|
||||
[Description("Always required. Brief explanation of the overall assessment, covering all expectations.")]
|
||||
public string AIReasoning { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Per-expectation results.</summary>
|
||||
[JsonPropertyName("expectation_results")]
|
||||
public List<ExpectationResult>? ExpectationResults { get; set; }
|
||||
[Description("Always required. One entry per expectation, in the same order as the input list.")]
|
||||
public List<ExpectationResult> ExpectationResults { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -190,7 +215,8 @@ internal sealed class ExpectationResult
|
||||
{
|
||||
/// <summary>The expectation text that was evaluated.</summary>
|
||||
[JsonPropertyName("expectation")]
|
||||
public string? Expectation { get; set; }
|
||||
[Description("Echo back the expectation text being evaluated.")]
|
||||
public string Expectation { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Whether this expectation was met.</summary>
|
||||
[JsonPropertyName("met")]
|
||||
@@ -198,5 +224,6 @@ internal sealed class ExpectationResult
|
||||
|
||||
/// <summary>Detail about how the expectation was or was not met.</summary>
|
||||
[JsonPropertyName("detail")]
|
||||
public string? Detail { get; set; }
|
||||
[Description("Explain how the expectation was or was not met, citing specific evidence from the output.")]
|
||||
public string Detail { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
@@ -14,19 +14,22 @@ internal sealed class VerificationOrchestrator
|
||||
private readonly LogFileWriter? _logWriter;
|
||||
private readonly string _dotnetRoot;
|
||||
private readonly TimeSpan _timeout;
|
||||
private readonly bool _buildSamples;
|
||||
|
||||
public VerificationOrchestrator(
|
||||
SampleVerifier verifier,
|
||||
ConsoleReporter reporter,
|
||||
string dotnetRoot,
|
||||
TimeSpan timeout,
|
||||
LogFileWriter? logWriter = null)
|
||||
LogFileWriter? logWriter = null,
|
||||
bool buildSamples = false)
|
||||
{
|
||||
this._verifier = verifier;
|
||||
this._reporter = reporter;
|
||||
this._logWriter = logWriter;
|
||||
this._dotnetRoot = dotnetRoot;
|
||||
this._timeout = timeout;
|
||||
this._buildSamples = buildSamples;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -136,8 +139,8 @@ internal sealed class VerificationOrchestrator
|
||||
|
||||
var projectPath = Path.Combine(this._dotnetRoot, sample.ProjectPath);
|
||||
var run = sample.Inputs.Length > 0
|
||||
? await SampleRunner.RunAsync(projectPath, this._timeout, sample.Inputs, sample.InputDelayMs)
|
||||
: await SampleRunner.RunAsync(projectPath, this._timeout);
|
||||
? await SampleRunner.RunAsync(projectPath, this._timeout, sample.Inputs, sample.InputDelayMs, build: this._buildSamples)
|
||||
: await SampleRunner.RunAsync(projectPath, this._timeout, build: this._buildSamples);
|
||||
|
||||
log.Add($"[{sample.Name}] Completed ({run.Elapsed.TotalSeconds:F1}s, exit={run.ExitCode})");
|
||||
this._reporter.WriteLineWithPrefix(
|
||||
|
||||
@@ -27,6 +27,12 @@ internal sealed class VerifyOptions
|
||||
/// </summary>
|
||||
public string? LogFilePath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When true, samples are built as part of <c>dotnet run</c>.
|
||||
/// When false (the default), <c>--no-build</c> is passed, assuming a prior build step.
|
||||
/// </summary>
|
||||
public bool BuildSamples { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The filtered list of samples to process.
|
||||
/// </summary>
|
||||
@@ -55,6 +61,7 @@ internal sealed class VerifyOptions
|
||||
var logFilePath = ExtractArg(argList, "--log");
|
||||
var csvFilePath = ExtractArg(argList, "--csv");
|
||||
var markdownFilePath = ExtractArg(argList, "--md");
|
||||
var buildSamples = ExtractFlag(argList, "--build");
|
||||
|
||||
int maxParallelism = 8;
|
||||
var parallelArg = ExtractArg(argList, "--parallel");
|
||||
@@ -105,6 +112,7 @@ internal sealed class VerifyOptions
|
||||
LogFilePath = logFilePath,
|
||||
CsvFilePath = csvFilePath,
|
||||
MarkdownFilePath = markdownFilePath,
|
||||
BuildSamples = buildSamples,
|
||||
Samples = samples,
|
||||
};
|
||||
}
|
||||
@@ -128,4 +136,16 @@ internal sealed class VerifyOptions
|
||||
list.RemoveRange(idx, 2);
|
||||
return value;
|
||||
}
|
||||
|
||||
private static bool ExtractFlag(List<string> list, string flag)
|
||||
{
|
||||
var idx = list.IndexOf(flag);
|
||||
if (idx < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
list.RemoveAt(idx);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
+1
-1
@@ -168,7 +168,7 @@ public sealed class FileSystemJsonCheckpointStore : JsonCheckpointStore, IDispos
|
||||
string filePath = Path.Combine(this.Directory.FullName, fileName);
|
||||
|
||||
if (!this.CheckpointIndex.Contains(key) ||
|
||||
!File.Exists(fileName))
|
||||
!File.Exists(filePath))
|
||||
{
|
||||
throw new KeyNotFoundException($"Checkpoint '{key.CheckpointId}' not found in store at '{this.Directory.FullName}'.");
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
+19
@@ -178,4 +178,23 @@ public sealed class FileSystemJsonCheckpointStoreTests
|
||||
Func<Task> createCheckpointAction = async () => await store.CreateCheckpointAsync(runId, TestData);
|
||||
await createCheckpointAction.Should().NotThrowAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RetrieveCheckpointAsync_ShouldReturnPersistedDataAsync()
|
||||
{
|
||||
// Arrange
|
||||
using TempDirectory tempDirectory = new();
|
||||
using FileSystemJsonCheckpointStore store = new(tempDirectory);
|
||||
|
||||
string sessionId = Guid.NewGuid().ToString("N");
|
||||
JsonElement originalData = JsonSerializer.SerializeToElement(new { name = "test", value = 42 });
|
||||
|
||||
// Act
|
||||
CheckpointInfo checkpoint = await store.CreateCheckpointAsync(sessionId, originalData);
|
||||
JsonElement retrieved = await store.RetrieveCheckpointAsync(sessionId, checkpoint);
|
||||
|
||||
// Assert
|
||||
retrieved.GetProperty("name").GetString().Should().Be("test");
|
||||
retrieved.GetProperty("value").GetInt32().Should().Be(42);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
+55
-1
@@ -7,6 +7,58 @@ 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
|
||||
|
||||
- **repo**: Add `PACKAGE_STATUS.md` to track lifecycle status of all Python packages ([#5062](https://github.com/microsoft/agent-framework/pull/5062))
|
||||
|
||||
### Changed
|
||||
|
||||
- **agent-framework**, **agent-framework-core**, **agent-framework-openai**, **agent-framework-foundry**: [BREAKING] Promote from `1.0.0rc6` to `1.0.0` (Production/Stable) ([#5062](https://github.com/microsoft/agent-framework/pull/5062))
|
||||
- **agent-framework-core**, **agent-framework-openai**, **agent-framework-foundry**: [BREAKING] Dependency floors now require released `>=1.0.0,<2` packages, breaking compatibility with older RC installs ([#5062](https://github.com/microsoft/agent-framework/pull/5062))
|
||||
- **agent-framework-a2a**, **agent-framework-ag-ui**, **agent-framework-anthropic**, **agent-framework-azure-ai-search**, **agent-framework-azure-cosmos**, **agent-framework-azurefunctions**, **agent-framework-bedrock**, **agent-framework-chatkit**, **agent-framework-claude**, **agent-framework-copilotstudio**, **agent-framework-declarative**, **agent-framework-devui**, **agent-framework-durabletask**, **agent-framework-foundry-local**, **agent-framework-github-copilot**, **agent-framework-lab**, **agent-framework-mem0**, **agent-framework-ollama**, **agent-framework-orchestrations**, **agent-framework-purview**, **agent-framework-redis**: Bump beta versions from `1.0.0b260330` to `1.0.0b260402` ([#5062](https://github.com/microsoft/agent-framework/pull/5062))
|
||||
- **docs**: Update install instructions to drop `--pre` flag for released packages ([#5062](https://github.com/microsoft/agent-framework/pull/5062))
|
||||
|
||||
### Removed
|
||||
|
||||
- **agent-framework-core**: [BREAKING] Remove deprecated `BaseContextProvider` and `BaseHistoryProvider` aliases ([#5062](https://github.com/microsoft/agent-framework/pull/5062))
|
||||
- **agent-framework-core**: [BREAKING] Remove deprecated `text` parameter from `Message` constructor ([#5062](https://github.com/microsoft/agent-framework/pull/5062))
|
||||
|
||||
### Fixed
|
||||
|
||||
- **agent-framework-core**, **agent-framework-openai**, **agent-framework-foundry**, **agent-framework-azurefunctions**, **agent-framework-devui**, **agent-framework-orchestrations**, **agent-framework-azure-ai-search**: Migrate message construction from `Message(text=...)` to `Message(contents=[...])` throughout codebase ([#5062](https://github.com/microsoft/agent-framework/pull/5062))
|
||||
- **agent-framework-devui**: Accept legacy payload formats (`text`, `message`, `content`, `input`, `data`) and convert to framework-native `Message(contents=...)` ([#5062](https://github.com/microsoft/agent-framework/pull/5062))
|
||||
- **samples**: Fix Foundry samples to use env vars consistently and update install guidance ([#5062](https://github.com/microsoft/agent-framework/pull/5062))
|
||||
|
||||
## [1.0.0rc6] - 2026-03-30
|
||||
|
||||
### Added
|
||||
@@ -846,7 +898,9 @@ 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.0rc6...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
|
||||
[1.0.0rc4]: https://github.com/microsoft/agent-framework/compare/python-1.0.0rc3...python-1.0.0rc4
|
||||
|
||||
@@ -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__",
|
||||
]
|
||||
|
||||
@@ -46,6 +46,7 @@ from ._orchestration._tooling import collect_server_tools, merge_tools, register
|
||||
from ._run_common import (
|
||||
FlowState,
|
||||
_build_run_finished_event, # type: ignore
|
||||
_close_reasoning_block, # type: ignore
|
||||
_emit_content, # type: ignore
|
||||
_extract_resume_payload, # type: ignore
|
||||
_has_only_tool_calls, # type: ignore
|
||||
@@ -1058,6 +1059,10 @@ async def run_agent_stream(
|
||||
}
|
||||
)
|
||||
|
||||
# Close any open reasoning block
|
||||
for event in _close_reasoning_block(flow):
|
||||
yield event
|
||||
|
||||
# Close any open message
|
||||
if flow.message_id:
|
||||
logger.debug(f"End of run: closing text message message_id={flow.message_id}")
|
||||
|
||||
@@ -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__)
|
||||
@@ -128,6 +130,7 @@ class FlowState:
|
||||
interrupts: list[dict[str, Any]] = field(default_factory=list) # pyright: ignore[reportUnknownVariableType]
|
||||
reasoning_messages: list[dict[str, Any]] = field(default_factory=list) # pyright: ignore[reportUnknownVariableType]
|
||||
accumulated_reasoning: dict[str, str] = field(default_factory=dict) # pyright: ignore[reportUnknownVariableType]
|
||||
reasoning_message_id: str | None = None
|
||||
|
||||
def get_tool_name(self, call_id: str | None) -> str | None:
|
||||
"""Get tool name by call ID."""
|
||||
@@ -232,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] = []
|
||||
|
||||
@@ -270,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
|
||||
@@ -294,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(
|
||||
@@ -459,7 +529,30 @@ 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]:
|
||||
"""Close an open reasoning block, emitting end events.
|
||||
|
||||
Should be called when the reasoning block is complete -- e.g. when
|
||||
non-reasoning content arrives or at end of a run.
|
||||
"""
|
||||
if not flow.reasoning_message_id:
|
||||
return []
|
||||
message_id = flow.reasoning_message_id
|
||||
flow.reasoning_message_id = None
|
||||
return [
|
||||
ReasoningMessageEndEvent(message_id=message_id),
|
||||
ReasoningEndEvent(message_id=message_id),
|
||||
]
|
||||
|
||||
|
||||
def _emit_text_reasoning(content: Content, flow: FlowState | None = None) -> list[BaseEvent]:
|
||||
@@ -468,6 +561,17 @@ def _emit_text_reasoning(content: Content, flow: FlowState | None = None) -> lis
|
||||
Uses the protocol-defined reasoning event types so that AG-UI consumers
|
||||
such as CopilotKit can render reasoning natively.
|
||||
|
||||
When *flow* is provided the function follows the streaming pattern: it
|
||||
emits ``ReasoningStartEvent`` / ``ReasoningMessageStartEvent`` only on
|
||||
the first delta for a given ``message_id`` and just
|
||||
``ReasoningMessageContentEvent`` for subsequent deltas. The matching
|
||||
``ReasoningMessageEndEvent`` / ``ReasoningEndEvent`` are deferred until
|
||||
``_close_reasoning_block`` is called (e.g. when non-reasoning content
|
||||
arrives or at end-of-run).
|
||||
|
||||
Without *flow* (backward-compat) the full Start→Content→End sequence is
|
||||
emitted for every call.
|
||||
|
||||
Only ``content.text`` is used for the visible reasoning message. If
|
||||
``content.protected_data`` is present it is emitted as a
|
||||
``ReasoningEncryptedValueEvent`` so that consumers can persist encrypted
|
||||
@@ -483,26 +587,49 @@ def _emit_text_reasoning(content: Content, flow: FlowState | None = None) -> lis
|
||||
|
||||
message_id = content.id or generate_event_id()
|
||||
|
||||
events: list[BaseEvent] = [
|
||||
ReasoningStartEvent(message_id=message_id),
|
||||
ReasoningMessageStartEvent(message_id=message_id, role="assistant"),
|
||||
]
|
||||
events: list[BaseEvent] = []
|
||||
|
||||
if text:
|
||||
events.append(ReasoningMessageContentEvent(message_id=message_id, delta=text))
|
||||
if flow is not None:
|
||||
# Streaming mode: track open reasoning block in flow state.
|
||||
if flow.reasoning_message_id != message_id:
|
||||
# Close any previously open reasoning block (different message_id).
|
||||
events.extend(_close_reasoning_block(flow))
|
||||
# Open new reasoning block.
|
||||
events.append(ReasoningStartEvent(message_id=message_id))
|
||||
events.append(ReasoningMessageStartEvent(message_id=message_id, role="assistant"))
|
||||
flow.reasoning_message_id = message_id
|
||||
|
||||
events.append(ReasoningMessageEndEvent(message_id=message_id))
|
||||
if text:
|
||||
events.append(ReasoningMessageContentEvent(message_id=message_id, delta=text))
|
||||
|
||||
if content.protected_data is not None:
|
||||
events.append(
|
||||
ReasoningEncryptedValueEvent(
|
||||
subtype="message",
|
||||
entity_id=message_id,
|
||||
encrypted_value=content.protected_data,
|
||||
if content.protected_data is not None:
|
||||
events.append(
|
||||
ReasoningEncryptedValueEvent(
|
||||
subtype="message",
|
||||
entity_id=message_id,
|
||||
encrypted_value=content.protected_data,
|
||||
)
|
||||
)
|
||||
)
|
||||
else:
|
||||
# No flow -- backward-compatible full sequence per call.
|
||||
events.append(ReasoningStartEvent(message_id=message_id))
|
||||
events.append(ReasoningMessageStartEvent(message_id=message_id, role="assistant"))
|
||||
|
||||
events.append(ReasoningEndEvent(message_id=message_id))
|
||||
if text:
|
||||
events.append(ReasoningMessageContentEvent(message_id=message_id, delta=text))
|
||||
|
||||
events.append(ReasoningMessageEndEvent(message_id=message_id))
|
||||
|
||||
if content.protected_data is not None:
|
||||
events.append(
|
||||
ReasoningEncryptedValueEvent(
|
||||
subtype="message",
|
||||
entity_id=message_id,
|
||||
encrypted_value=content.protected_data,
|
||||
)
|
||||
)
|
||||
|
||||
events.append(ReasoningEndEvent(message_id=message_id))
|
||||
|
||||
# Persist reasoning into flow state for MESSAGES_SNAPSHOT.
|
||||
# Accumulate reasoning text per message_id, similar to flow.accumulated_text,
|
||||
@@ -546,23 +673,30 @@ def _emit_content(
|
||||
) -> list[BaseEvent]:
|
||||
"""Emit appropriate events for any content type."""
|
||||
content_type = getattr(content, "type", None)
|
||||
|
||||
# Close open reasoning block when switching to non-reasoning content.
|
||||
if content_type != "text_reasoning":
|
||||
events = _close_reasoning_block(flow)
|
||||
else:
|
||||
events = []
|
||||
|
||||
if content_type == "text":
|
||||
return _emit_text(content, flow, skip_text)
|
||||
return events + _emit_text(content, flow, skip_text)
|
||||
if content_type == "function_call":
|
||||
return _emit_tool_call(content, flow, predictive_handler)
|
||||
return events + _emit_tool_call(content, flow, predictive_handler)
|
||||
if content_type == "function_result":
|
||||
return _emit_tool_result(content, flow, predictive_handler)
|
||||
return events + _emit_tool_result(content, flow, predictive_handler)
|
||||
if content_type == "function_approval_request":
|
||||
return _emit_approval_request(content, flow, predictive_handler, require_confirmation)
|
||||
return events + _emit_approval_request(content, flow, predictive_handler, require_confirmation)
|
||||
if content_type == "usage":
|
||||
return _emit_usage(content)
|
||||
return events + _emit_usage(content)
|
||||
if content_type == "oauth_consent_request":
|
||||
return _emit_oauth_consent(content)
|
||||
return events + _emit_oauth_consent(content)
|
||||
if content_type == "mcp_server_tool_call":
|
||||
return _emit_mcp_tool_call(content, flow)
|
||||
return events + _emit_mcp_tool_call(content, flow)
|
||||
if content_type == "mcp_server_tool_result":
|
||||
return _emit_mcp_tool_result(content, flow, predictive_handler)
|
||||
return events + _emit_mcp_tool_result(content, flow, predictive_handler)
|
||||
if content_type == "text_reasoning":
|
||||
return _emit_text_reasoning(content, flow)
|
||||
logger.debug("Skipping unsupported content type in AG-UI emitter: %s", content_type)
|
||||
return []
|
||||
return events
|
||||
|
||||
@@ -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)},
|
||||
)
|
||||
@@ -29,6 +29,7 @@ from ._message_adapters import normalize_agui_input_messages
|
||||
from ._run_common import (
|
||||
FlowState,
|
||||
_build_run_finished_event,
|
||||
_close_reasoning_block,
|
||||
_emit_content,
|
||||
_extract_resume_payload,
|
||||
_normalize_resume_interrupts,
|
||||
@@ -729,6 +730,9 @@ async def run_workflow_stream(
|
||||
run_error_emitted = True
|
||||
terminal_emitted = True
|
||||
|
||||
for reasoning_evt in _close_reasoning_block(flow):
|
||||
yield reasoning_evt
|
||||
|
||||
for end_event in _drain_open_message():
|
||||
yield end_event
|
||||
|
||||
|
||||
@@ -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__")
|
||||
|
||||
@@ -11,6 +11,7 @@ from ag_ui.core import (
|
||||
ReasoningMessageEndEvent,
|
||||
ReasoningMessageStartEvent,
|
||||
ReasoningStartEvent,
|
||||
TextMessageContentEvent,
|
||||
TextMessageEndEvent,
|
||||
TextMessageStartEvent,
|
||||
ToolCallArgsEvent,
|
||||
@@ -29,6 +30,7 @@ from agent_framework_ag_ui._agent_run import (
|
||||
from agent_framework_ag_ui._run_common import (
|
||||
FlowState,
|
||||
_build_run_finished_event,
|
||||
_close_reasoning_block,
|
||||
_emit_approval_request,
|
||||
_emit_content,
|
||||
_emit_mcp_tool_call,
|
||||
@@ -1344,8 +1346,11 @@ class TestEmitContentMcpRouting:
|
||||
|
||||
events = _emit_content(content, flow)
|
||||
|
||||
assert len(events) == 5
|
||||
# Streaming pattern: Start + MessageStart + Content (no End events yet)
|
||||
assert len(events) == 3
|
||||
assert isinstance(events[0], ReasoningStartEvent)
|
||||
assert isinstance(events[1], ReasoningMessageStartEvent)
|
||||
assert isinstance(events[2], ReasoningMessageContentEvent)
|
||||
|
||||
|
||||
class TestReasoningInSnapshot:
|
||||
@@ -1501,3 +1506,137 @@ class TestReasoningInSnapshot:
|
||||
assert len(flow.reasoning_messages) == 1
|
||||
assert flow.reasoning_messages[0]["content"] == "part1 part2"
|
||||
assert flow.reasoning_messages[0]["encryptedValue"] == "encrypted-payload"
|
||||
|
||||
def test_reasoning_done_after_deltas_does_not_duplicate(self):
|
||||
"""A done-style content arriving after deltas does not duplicate accumulated text.
|
||||
|
||||
The upstream client should skip done events when deltas preceded them,
|
||||
but if one leaks through, the accumulator must not double-append.
|
||||
This test verifies that only the delta-produced text is stored.
|
||||
"""
|
||||
flow = FlowState()
|
||||
msg_id = "reason_dedup"
|
||||
|
||||
delta1 = Content.from_text_reasoning(id=msg_id, text="Hello ")
|
||||
delta2 = Content.from_text_reasoning(id=msg_id, text="world")
|
||||
|
||||
_emit_text_reasoning(delta1, flow)
|
||||
_emit_text_reasoning(delta2, flow)
|
||||
|
||||
# Accumulated text should equal the concatenation of deltas only
|
||||
assert len(flow.reasoning_messages) == 1
|
||||
assert flow.reasoning_messages[0]["content"] == "Hello world"
|
||||
assert flow.reasoning_messages[0]["id"] == msg_id
|
||||
|
||||
def test_reasoning_deltas_emit_one_content_event_each(self):
|
||||
"""Each reasoning delta emits exactly one ReasoningMessageContentEvent
|
||||
within a single Start/End sequence (streaming pattern)."""
|
||||
flow = FlowState()
|
||||
msg_id = "reason_evt"
|
||||
|
||||
delta1 = Content.from_text_reasoning(id=msg_id, text="Think ")
|
||||
delta2 = Content.from_text_reasoning(id=msg_id, text="hard")
|
||||
|
||||
events1 = _emit_text_reasoning(delta1, flow)
|
||||
events2 = _emit_text_reasoning(delta2, flow)
|
||||
close_events = _close_reasoning_block(flow)
|
||||
|
||||
all_events = events1 + events2 + close_events
|
||||
content_events = [e for e in all_events if isinstance(e, ReasoningMessageContentEvent)]
|
||||
|
||||
assert len(content_events) == 2
|
||||
assert content_events[0].delta == "Think "
|
||||
assert content_events[1].delta == "hard"
|
||||
|
||||
# Streaming pattern: one Start/End sequence wrapping both content events
|
||||
start_events = [e for e in all_events if isinstance(e, ReasoningStartEvent)]
|
||||
end_events = [e for e in all_events if isinstance(e, ReasoningEndEvent)]
|
||||
msg_start_events = [e for e in all_events if isinstance(e, ReasoningMessageStartEvent)]
|
||||
msg_end_events = [e for e in all_events if isinstance(e, ReasoningMessageEndEvent)]
|
||||
assert len(start_events) == 1
|
||||
assert len(end_events) == 1
|
||||
assert len(msg_start_events) == 1
|
||||
assert len(msg_end_events) == 1
|
||||
|
||||
def test_reasoning_streaming_event_order(self):
|
||||
"""Streaming reasoning emits Start once, then Content per delta, then End on close."""
|
||||
flow = FlowState()
|
||||
msg_id = "reason_order"
|
||||
|
||||
d1 = Content.from_text_reasoning(id=msg_id, text="A ")
|
||||
d2 = Content.from_text_reasoning(id=msg_id, text="B ")
|
||||
d3 = Content.from_text_reasoning(id=msg_id, text="C")
|
||||
|
||||
events = []
|
||||
events.extend(_emit_text_reasoning(d1, flow))
|
||||
events.extend(_emit_text_reasoning(d2, flow))
|
||||
events.extend(_emit_text_reasoning(d3, flow))
|
||||
events.extend(_close_reasoning_block(flow))
|
||||
|
||||
assert isinstance(events[0], ReasoningStartEvent)
|
||||
assert isinstance(events[1], ReasoningMessageStartEvent)
|
||||
assert isinstance(events[2], ReasoningMessageContentEvent)
|
||||
assert events[2].delta == "A "
|
||||
assert isinstance(events[3], ReasoningMessageContentEvent)
|
||||
assert events[3].delta == "B "
|
||||
assert isinstance(events[4], ReasoningMessageContentEvent)
|
||||
assert events[4].delta == "C"
|
||||
assert isinstance(events[5], ReasoningMessageEndEvent)
|
||||
assert isinstance(events[6], ReasoningEndEvent)
|
||||
assert len(events) == 7
|
||||
|
||||
def test_close_reasoning_block_noop_when_not_open(self):
|
||||
"""_close_reasoning_block returns empty list when no reasoning block is open."""
|
||||
flow = FlowState()
|
||||
assert _close_reasoning_block(flow) == []
|
||||
|
||||
def test_close_reasoning_block_resets_state(self):
|
||||
"""_close_reasoning_block clears reasoning_message_id."""
|
||||
flow = FlowState()
|
||||
_emit_text_reasoning(Content.from_text_reasoning(id="r1", text="x"), flow)
|
||||
assert flow.reasoning_message_id == "r1"
|
||||
|
||||
_close_reasoning_block(flow)
|
||||
assert flow.reasoning_message_id is None
|
||||
|
||||
def test_emit_content_closes_reasoning_on_text(self):
|
||||
"""Switching from reasoning to text content auto-closes reasoning block."""
|
||||
flow = FlowState()
|
||||
reasoning = Content.from_text_reasoning(id="r1", text="thinking")
|
||||
text = Content.from_text("answer")
|
||||
|
||||
r_events = _emit_content(reasoning, flow)
|
||||
t_events = _emit_content(text, flow)
|
||||
|
||||
# reasoning events: Start + MsgStart + Content
|
||||
assert isinstance(r_events[0], ReasoningStartEvent)
|
||||
# text events should start with reasoning End events
|
||||
assert isinstance(t_events[0], ReasoningMessageEndEvent)
|
||||
assert isinstance(t_events[1], ReasoningEndEvent)
|
||||
# then text start
|
||||
|
||||
assert isinstance(t_events[2], TextMessageStartEvent)
|
||||
assert isinstance(t_events[3], TextMessageContentEvent)
|
||||
|
||||
def test_reasoning_distinct_ids_close_previous_block(self):
|
||||
"""Emitting reasoning with a new message_id auto-closes the previous block."""
|
||||
flow = FlowState()
|
||||
c1 = Content.from_text_reasoning(id="block1", text="first")
|
||||
c2 = Content.from_text_reasoning(id="block2", text="second")
|
||||
|
||||
events1 = _emit_text_reasoning(c1, flow)
|
||||
events2 = _emit_text_reasoning(c2, flow)
|
||||
close = _close_reasoning_block(flow)
|
||||
|
||||
# events1: Start(block1) + MsgStart(block1) + Content(block1)
|
||||
assert events1[0].message_id == "block1"
|
||||
# events2: MsgEnd(block1) + End(block1) + Start(block2) + MsgStart(block2) + Content(block2)
|
||||
assert isinstance(events2[0], ReasoningMessageEndEvent)
|
||||
assert events2[0].message_id == "block1"
|
||||
assert isinstance(events2[1], ReasoningEndEvent)
|
||||
assert events2[1].message_id == "block1"
|
||||
assert isinstance(events2[2], ReasoningStartEvent)
|
||||
assert events2[2].message_id == "block2"
|
||||
# close: MsgEnd(block2) + End(block2)
|
||||
assert isinstance(close[0], ReasoningMessageEndEvent)
|
||||
assert close[0].message_id == "block2"
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ The Azure Cosmos DB integration provides `CosmosHistoryProvider` for persistent
|
||||
|
||||
```python
|
||||
from azure.identity.aio import DefaultAzureCredential
|
||||
from agent_framework.azure import CosmosHistoryProvider
|
||||
from agent_framework_azure_cosmos import CosmosHistoryProvider
|
||||
|
||||
provider = CosmosHistoryProvider(
|
||||
endpoint="https://<account>.documents.azure.com:443/",
|
||||
@@ -35,13 +35,92 @@ Container naming behavior:
|
||||
- Container name is configured on the provider (`container_name` or `AZURE_COSMOS_CONTAINER_NAME`)
|
||||
- `session_id` is used as the Cosmos partition key for reads/writes
|
||||
|
||||
See the [conversation samples](../../samples/02-agents/conversations/) for runnable examples, including
|
||||
[`cosmos_history_provider.py`](../../samples/02-agents/conversations/cosmos_history_provider.py).
|
||||
See `samples/02-agents/conversations/cosmos_history_provider.py` for a runnable example.
|
||||
|
||||
## Import Paths
|
||||
## Cosmos DB Workflow Checkpoint Storage
|
||||
|
||||
`CosmosCheckpointStorage` implements the `CheckpointStorage` protocol, enabling
|
||||
durable workflow checkpointing backed by Azure Cosmos DB NoSQL. Workflows can be
|
||||
paused and resumed across process restarts by persisting checkpoint state in Cosmos DB.
|
||||
|
||||
### Basic Usage
|
||||
|
||||
#### Managed Identity / RBAC (recommended for production)
|
||||
|
||||
```python
|
||||
from agent_framework.azure import CosmosHistoryProvider
|
||||
# or directly:
|
||||
from agent_framework_azure_cosmos import CosmosHistoryProvider
|
||||
from azure.identity.aio import DefaultAzureCredential
|
||||
from agent_framework import WorkflowBuilder
|
||||
from agent_framework_azure_cosmos import CosmosCheckpointStorage
|
||||
|
||||
checkpoint_storage = CosmosCheckpointStorage(
|
||||
endpoint="https://<account>.documents.azure.com:443/",
|
||||
credential=DefaultAzureCredential(),
|
||||
database_name="agent-framework",
|
||||
container_name="workflow-checkpoints",
|
||||
)
|
||||
```
|
||||
|
||||
#### Account Key
|
||||
|
||||
```python
|
||||
from agent_framework_azure_cosmos import CosmosCheckpointStorage
|
||||
|
||||
checkpoint_storage = CosmosCheckpointStorage(
|
||||
endpoint="https://<account>.documents.azure.com:443/",
|
||||
credential="<your-account-key>",
|
||||
database_name="agent-framework",
|
||||
container_name="workflow-checkpoints",
|
||||
)
|
||||
```
|
||||
|
||||
#### Then use with a workflow
|
||||
|
||||
```python
|
||||
from agent_framework import WorkflowBuilder
|
||||
|
||||
# Build a workflow with checkpointing enabled
|
||||
workflow = WorkflowBuilder(
|
||||
start_executor=start,
|
||||
checkpoint_storage=checkpoint_storage,
|
||||
).build()
|
||||
|
||||
# Run the workflow — checkpoints are automatically saved after each superstep
|
||||
result = await workflow.run(message="input data")
|
||||
|
||||
# Resume from a checkpoint
|
||||
latest = await checkpoint_storage.get_latest(workflow_name=workflow.name)
|
||||
if latest:
|
||||
resumed = await workflow.run(checkpoint_id=latest.checkpoint_id)
|
||||
```
|
||||
|
||||
### Authentication Options
|
||||
|
||||
`CosmosCheckpointStorage` supports the same authentication modes as `CosmosHistoryProvider`:
|
||||
|
||||
- **Managed identity / RBAC** (recommended): Pass `DefaultAzureCredential()`,
|
||||
`ManagedIdentityCredential()`, or any Azure `TokenCredential`
|
||||
- **Account key**: Pass a key string via `credential` parameter
|
||||
- **Environment variables**: Set `AZURE_COSMOS_ENDPOINT`, `AZURE_COSMOS_DATABASE_NAME`,
|
||||
`AZURE_COSMOS_CONTAINER_NAME`, and `AZURE_COSMOS_KEY` (key not required when using
|
||||
Azure credentials)
|
||||
- **Pre-created client**: Pass an existing `CosmosClient` or `ContainerProxy`
|
||||
|
||||
### Database and Container Setup
|
||||
|
||||
The database and container are created automatically on first use (via
|
||||
`create_database_if_not_exists` and `create_container_if_not_exists`). The container
|
||||
uses `/workflow_name` as the partition key. You can also pre-create them in the Azure
|
||||
portal with this partition key configuration.
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `AZURE_COSMOS_ENDPOINT` | Cosmos DB account endpoint |
|
||||
| `AZURE_COSMOS_DATABASE_NAME` | Database name |
|
||||
| `AZURE_COSMOS_CONTAINER_NAME` | Container name |
|
||||
| `AZURE_COSMOS_KEY` | Account key (optional if using Azure credentials) |
|
||||
|
||||
See `samples/03-workflows/checkpoint/cosmos_workflow_checkpointing.py` for a standalone example,
|
||||
or `samples/03-workflows/checkpoint/cosmos_workflow_checkpointing_foundry.py` for an end-to-end
|
||||
example with Azure AI Foundry agents.
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import importlib.metadata
|
||||
|
||||
from ._checkpoint_storage import CosmosCheckpointStorage
|
||||
from ._history_provider import CosmosHistoryProvider
|
||||
|
||||
try:
|
||||
@@ -10,6 +11,7 @@ except importlib.metadata.PackageNotFoundError:
|
||||
__version__ = "0.0.0" # Fallback for development mode
|
||||
|
||||
__all__ = [
|
||||
"CosmosCheckpointStorage",
|
||||
"CosmosHistoryProvider",
|
||||
"__version__",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,456 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Azure Cosmos DB checkpoint storage for workflow checkpointing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, TypedDict
|
||||
|
||||
from agent_framework import AGENT_FRAMEWORK_USER_AGENT
|
||||
from agent_framework._settings import SecretString, load_settings
|
||||
from agent_framework._workflows._checkpoint import CheckpointID, WorkflowCheckpoint
|
||||
from agent_framework._workflows._checkpoint_encoding import decode_checkpoint_value, encode_checkpoint_value
|
||||
from agent_framework.exceptions import WorkflowCheckpointException
|
||||
from azure.core.credentials import TokenCredential
|
||||
from azure.core.credentials_async import AsyncTokenCredential
|
||||
from azure.cosmos import PartitionKey
|
||||
from azure.cosmos.aio import ContainerProxy, CosmosClient
|
||||
from azure.cosmos.exceptions import CosmosResourceNotFoundError
|
||||
|
||||
AzureCredentialTypes = TokenCredential | AsyncTokenCredential
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AzureCosmosCheckpointSettings(TypedDict, total=False):
|
||||
"""Settings for CosmosCheckpointStorage resolved from args and environment."""
|
||||
|
||||
endpoint: str | None
|
||||
database_name: str | None
|
||||
container_name: str | None
|
||||
key: SecretString | None
|
||||
|
||||
|
||||
class CosmosCheckpointStorage:
|
||||
"""Azure Cosmos DB-backed checkpoint storage for workflow checkpointing.
|
||||
|
||||
Implements the ``CheckpointStorage`` protocol using Azure Cosmos DB NoSQL
|
||||
as the persistent backend. Checkpoints are stored as JSON documents with
|
||||
``workflow_name`` as the partition key, enabling efficient per-workflow queries.
|
||||
|
||||
This storage uses the same hybrid JSON + pickle encoding as
|
||||
``FileCheckpointStorage``, allowing full Python object fidelity for
|
||||
complex workflow state while keeping the document structure human-readable.
|
||||
|
||||
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
|
||||
``/workflow_name``.
|
||||
|
||||
Example using managed identity / RBAC:
|
||||
|
||||
.. 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",
|
||||
)
|
||||
|
||||
Example using account key:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
storage = CosmosCheckpointStorage(
|
||||
endpoint="https://my-account.documents.azure.com:443/",
|
||||
credential="my-account-key",
|
||||
database_name="agent-db",
|
||||
container_name="checkpoints",
|
||||
)
|
||||
|
||||
Then use with a workflow builder:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
workflow = WorkflowBuilder(
|
||||
start_executor=start,
|
||||
checkpoint_storage=storage,
|
||||
).build()
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
endpoint: str | None = None,
|
||||
database_name: str | None = None,
|
||||
container_name: str | None = None,
|
||||
credential: str | AzureCredentialTypes | None = None,
|
||||
cosmos_client: CosmosClient | None = None,
|
||||
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.
|
||||
|
||||
Supports multiple authentication modes:
|
||||
|
||||
- **Container client** (``container_client``): Use a pre-created
|
||||
Cosmos async container proxy. No client lifecycle is managed.
|
||||
- **Cosmos client** (``cosmos_client``): Use a pre-created Cosmos
|
||||
async client. The caller is responsible for closing it.
|
||||
- **Endpoint + credential**: Create a new Cosmos client. The storage
|
||||
owns the client and closes it on ``close()``.
|
||||
- **Environment variables**: Falls back to ``AZURE_COSMOS_ENDPOINT``,
|
||||
``AZURE_COSMOS_DATABASE_NAME``, ``AZURE_COSMOS_CONTAINER_NAME``,
|
||||
and ``AZURE_COSMOS_KEY``.
|
||||
|
||||
Args:
|
||||
endpoint: Cosmos DB account endpoint.
|
||||
Can be set via ``AZURE_COSMOS_ENDPOINT``.
|
||||
database_name: Cosmos DB database name.
|
||||
Can be set via ``AZURE_COSMOS_DATABASE_NAME``.
|
||||
container_name: Cosmos DB container name.
|
||||
Can be set via ``AZURE_COSMOS_CONTAINER_NAME``.
|
||||
credential: Credential to authenticate with Cosmos DB.
|
||||
For **managed identity / RBAC**, pass an Azure credential object
|
||||
such as ``DefaultAzureCredential()`` or
|
||||
``ManagedIdentityCredential()``.
|
||||
For **key-based auth**, pass the account key as a string,
|
||||
or set ``AZURE_COSMOS_KEY`` in the environment.
|
||||
cosmos_client: Pre-created Cosmos async client.
|
||||
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 ""
|
||||
self.container_name: str = container_name or ""
|
||||
return
|
||||
|
||||
required_fields: list[str] = ["database_name", "container_name"]
|
||||
if cosmos_client is None:
|
||||
required_fields.append("endpoint")
|
||||
if credential is None:
|
||||
required_fields.append("key")
|
||||
|
||||
settings = load_settings(
|
||||
AzureCosmosCheckpointSettings,
|
||||
env_prefix="AZURE_COSMOS_",
|
||||
required_fields=required_fields,
|
||||
endpoint=endpoint,
|
||||
database_name=database_name,
|
||||
container_name=container_name,
|
||||
key=credential if isinstance(credential, str) else None,
|
||||
env_file_path=env_file_path,
|
||||
env_file_encoding=env_file_encoding,
|
||||
)
|
||||
self.database_name = settings["database_name"] # type: ignore[assignment]
|
||||
self.container_name = settings["container_name"] # type: ignore[assignment]
|
||||
|
||||
if self._cosmos_client is None:
|
||||
self._cosmos_client = CosmosClient(
|
||||
url=settings["endpoint"], # type: ignore[arg-type]
|
||||
credential=credential or settings["key"].get_secret_value(), # type: ignore[arg-type,union-attr]
|
||||
user_agent_suffix=AGENT_FRAMEWORK_USER_AGENT,
|
||||
)
|
||||
self._owns_client = True
|
||||
|
||||
async def save(self, checkpoint: WorkflowCheckpoint) -> CheckpointID:
|
||||
"""Save a checkpoint to Cosmos DB and return its ID.
|
||||
|
||||
The checkpoint is encoded to a JSON-compatible form (using pickle for
|
||||
non-JSON-native values) and stored as a Cosmos DB document with the
|
||||
``workflow_name`` as the partition key.
|
||||
|
||||
The document ``id`` is a composite of ``workflow_name`` and
|
||||
``checkpoint_id`` to ensure global uniqueness across partitions.
|
||||
|
||||
Args:
|
||||
checkpoint: The WorkflowCheckpoint object to save.
|
||||
|
||||
Returns:
|
||||
The unique ID of the saved checkpoint.
|
||||
"""
|
||||
await self._ensure_container_proxy()
|
||||
|
||||
checkpoint_dict = checkpoint.to_dict()
|
||||
encoded = encode_checkpoint_value(checkpoint_dict)
|
||||
|
||||
document: dict[str, Any] = {
|
||||
"id": self._make_document_id(checkpoint.workflow_name, checkpoint.checkpoint_id),
|
||||
"workflow_name": checkpoint.workflow_name,
|
||||
**encoded,
|
||||
}
|
||||
|
||||
await self._container_proxy.upsert_item(body=document) # type: ignore[union-attr]
|
||||
logger.info("Saved checkpoint %s to Cosmos DB", checkpoint.checkpoint_id)
|
||||
return checkpoint.checkpoint_id
|
||||
|
||||
async def load(self, checkpoint_id: CheckpointID) -> WorkflowCheckpoint:
|
||||
"""Load a checkpoint from Cosmos DB by ID.
|
||||
|
||||
Args:
|
||||
checkpoint_id: The unique ID of the checkpoint to load.
|
||||
|
||||
Returns:
|
||||
The WorkflowCheckpoint object corresponding to the given ID.
|
||||
|
||||
Raises:
|
||||
WorkflowCheckpointException: If no checkpoint with the given ID exists,
|
||||
or if multiple checkpoints share the same ID across workflows.
|
||||
"""
|
||||
await self._ensure_container_proxy()
|
||||
|
||||
query = "SELECT * FROM c WHERE c.checkpoint_id = @checkpoint_id"
|
||||
parameters: list[dict[str, object]] = [
|
||||
{"name": "@checkpoint_id", "value": checkpoint_id},
|
||||
]
|
||||
|
||||
items = self._container_proxy.query_items( # type: ignore[union-attr]
|
||||
query=query,
|
||||
parameters=parameters,
|
||||
)
|
||||
|
||||
results: list[dict[str, Any]] = []
|
||||
async for item in items:
|
||||
results.append(item)
|
||||
|
||||
if not results:
|
||||
raise WorkflowCheckpointException(f"No checkpoint found with ID {checkpoint_id}")
|
||||
|
||||
if len(results) > 1:
|
||||
workflow_names = [r.get("workflow_name", "unknown") for r in results]
|
||||
raise WorkflowCheckpointException(
|
||||
f"Multiple checkpoints found with ID {checkpoint_id} across workflows: "
|
||||
f"{workflow_names}. Use list_checkpoints(workflow_name=...) to query "
|
||||
f"by workflow instead."
|
||||
)
|
||||
|
||||
return self._document_to_checkpoint(results[0])
|
||||
|
||||
async def list_checkpoints(self, *, workflow_name: str) -> list[WorkflowCheckpoint]:
|
||||
"""List checkpoint objects for a given workflow name.
|
||||
|
||||
Args:
|
||||
workflow_name: The name of the workflow to list checkpoints for.
|
||||
|
||||
Returns:
|
||||
A list of WorkflowCheckpoint objects for the specified workflow name.
|
||||
"""
|
||||
await self._ensure_container_proxy()
|
||||
|
||||
query = "SELECT * FROM c WHERE c.workflow_name = @workflow_name ORDER BY c.timestamp ASC"
|
||||
parameters: list[dict[str, object]] = [
|
||||
{"name": "@workflow_name", "value": workflow_name},
|
||||
]
|
||||
|
||||
items = self._container_proxy.query_items( # type: ignore[union-attr]
|
||||
query=query,
|
||||
parameters=parameters,
|
||||
partition_key=workflow_name,
|
||||
)
|
||||
|
||||
checkpoints: list[WorkflowCheckpoint] = []
|
||||
async for item in items:
|
||||
try:
|
||||
checkpoints.append(self._document_to_checkpoint(item))
|
||||
except Exception as e:
|
||||
logger.warning("Failed to decode checkpoint document: %s", e)
|
||||
return checkpoints
|
||||
|
||||
async def delete(self, checkpoint_id: CheckpointID) -> bool:
|
||||
"""Delete a checkpoint from Cosmos DB by ID.
|
||||
|
||||
Args:
|
||||
checkpoint_id: The unique ID of the checkpoint to delete.
|
||||
|
||||
Returns:
|
||||
True if the checkpoint was successfully deleted, False if not found.
|
||||
"""
|
||||
await self._ensure_container_proxy()
|
||||
|
||||
query = "SELECT c.id, c.workflow_name FROM c WHERE c.checkpoint_id = @checkpoint_id"
|
||||
parameters: list[dict[str, object]] = [
|
||||
{"name": "@checkpoint_id", "value": checkpoint_id},
|
||||
]
|
||||
|
||||
items = self._container_proxy.query_items( # type: ignore[union-attr]
|
||||
query=query,
|
||||
parameters=parameters,
|
||||
)
|
||||
|
||||
async for item in items:
|
||||
try:
|
||||
await self._container_proxy.delete_item( # type: ignore[union-attr]
|
||||
item=item["id"],
|
||||
partition_key=item["workflow_name"],
|
||||
)
|
||||
logger.info("Deleted checkpoint %s from Cosmos DB", checkpoint_id)
|
||||
return True
|
||||
except CosmosResourceNotFoundError:
|
||||
return False
|
||||
|
||||
return False
|
||||
|
||||
async def get_latest(self, *, workflow_name: str) -> WorkflowCheckpoint | None:
|
||||
"""Get the latest checkpoint for a given workflow name.
|
||||
|
||||
Args:
|
||||
workflow_name: The name of the workflow to get the latest checkpoint for.
|
||||
|
||||
Returns:
|
||||
The latest WorkflowCheckpoint, or None if no checkpoints exist.
|
||||
"""
|
||||
await self._ensure_container_proxy()
|
||||
|
||||
query = "SELECT * FROM c WHERE c.workflow_name = @workflow_name ORDER BY c.timestamp DESC OFFSET 0 LIMIT 1"
|
||||
parameters: list[dict[str, object]] = [
|
||||
{"name": "@workflow_name", "value": workflow_name},
|
||||
]
|
||||
|
||||
items = self._container_proxy.query_items( # type: ignore[union-attr]
|
||||
query=query,
|
||||
parameters=parameters,
|
||||
partition_key=workflow_name,
|
||||
)
|
||||
|
||||
async for item in items:
|
||||
checkpoint = self._document_to_checkpoint(item)
|
||||
logger.debug(
|
||||
"Latest checkpoint for workflow %s is %s",
|
||||
workflow_name,
|
||||
checkpoint.checkpoint_id,
|
||||
)
|
||||
return checkpoint
|
||||
|
||||
return None
|
||||
|
||||
async def list_checkpoint_ids(self, *, workflow_name: str) -> list[CheckpointID]:
|
||||
"""List checkpoint IDs for a given workflow name.
|
||||
|
||||
Args:
|
||||
workflow_name: The name of the workflow to list checkpoint IDs for.
|
||||
|
||||
Returns:
|
||||
A list of checkpoint IDs for the specified workflow name.
|
||||
"""
|
||||
await self._ensure_container_proxy()
|
||||
|
||||
query = "SELECT c.checkpoint_id FROM c WHERE c.workflow_name = @workflow_name ORDER BY c.timestamp ASC"
|
||||
parameters: list[dict[str, object]] = [
|
||||
{"name": "@workflow_name", "value": workflow_name},
|
||||
]
|
||||
|
||||
items = self._container_proxy.query_items( # type: ignore[union-attr]
|
||||
query=query,
|
||||
parameters=parameters,
|
||||
partition_key=workflow_name,
|
||||
)
|
||||
|
||||
checkpoint_ids: list[CheckpointID] = []
|
||||
async for item in items:
|
||||
cid = item.get("checkpoint_id")
|
||||
if isinstance(cid, str):
|
||||
checkpoint_ids.append(cid)
|
||||
return checkpoint_ids
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close the underlying Cosmos client when this storage owns it."""
|
||||
if self._owns_client and self._cosmos_client is not None:
|
||||
await self._cosmos_client.close()
|
||||
|
||||
async def __aenter__(self) -> CosmosCheckpointStorage:
|
||||
"""Async context manager entry."""
|
||||
return self
|
||||
|
||||
async def __aexit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc_val: BaseException | None,
|
||||
exc_tb: Any,
|
||||
) -> None:
|
||||
"""Async context manager exit."""
|
||||
try:
|
||||
await self.close()
|
||||
except Exception:
|
||||
if exc_type is None:
|
||||
raise
|
||||
|
||||
async def _ensure_container_proxy(self) -> None:
|
||||
"""Get or create the Cosmos DB database and container for storing checkpoints."""
|
||||
if self._container_proxy is not None:
|
||||
return
|
||||
if self._cosmos_client is None:
|
||||
raise RuntimeError("Cosmos client is not initialized.")
|
||||
|
||||
database = await self._cosmos_client.create_database_if_not_exists(id=self.database_name)
|
||||
self._container_proxy = await database.create_container_if_not_exists(
|
||||
id=self.container_name,
|
||||
partition_key=PartitionKey(path="/workflow_name"),
|
||||
)
|
||||
|
||||
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``,
|
||||
``_attachments``, ``_ts``) before decoding.
|
||||
"""
|
||||
# Remove Cosmos DB system properties and the composite 'id' field
|
||||
# (checkpoints use 'checkpoint_id', not 'id')
|
||||
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, allowed_types=self._allowed_types)
|
||||
return WorkflowCheckpoint.from_dict(decoded)
|
||||
|
||||
@staticmethod
|
||||
def _make_document_id(workflow_name: str, checkpoint_id: str) -> str:
|
||||
"""Create a composite Cosmos DB document ID.
|
||||
|
||||
Combines ``workflow_name`` and ``checkpoint_id`` to ensure global
|
||||
uniqueness across partitions.
|
||||
"""
|
||||
return f"{workflow_name}_{checkpoint_id}"
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,737 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
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
|
||||
|
||||
import pytest
|
||||
from agent_framework._workflows._checkpoint import WorkflowCheckpoint
|
||||
from agent_framework._workflows._checkpoint_encoding import encode_checkpoint_value
|
||||
from agent_framework.exceptions import SettingNotFoundError, WorkflowCheckpointException
|
||||
from azure.cosmos.aio import CosmosClient
|
||||
from azure.cosmos.exceptions import CosmosResourceNotFoundError
|
||||
|
||||
import agent_framework_azure_cosmos._checkpoint_storage as checkpoint_storage_module
|
||||
from agent_framework_azure_cosmos._checkpoint_storage import CosmosCheckpointStorage
|
||||
|
||||
skip_if_cosmos_integration_tests_disabled = pytest.mark.skipif(
|
||||
any(
|
||||
os.getenv(name, "") == ""
|
||||
for name in (
|
||||
"AZURE_COSMOS_ENDPOINT",
|
||||
"AZURE_COSMOS_KEY",
|
||||
"AZURE_COSMOS_DATABASE_NAME",
|
||||
"AZURE_COSMOS_CONTAINER_NAME",
|
||||
)
|
||||
),
|
||||
reason=(
|
||||
"AZURE_COSMOS_ENDPOINT, AZURE_COSMOS_KEY, AZURE_COSMOS_DATABASE_NAME, and "
|
||||
"AZURE_COSMOS_CONTAINER_NAME are required for Cosmos integration tests."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _to_async_iter(items: list[Any]) -> AsyncIterator[Any]:
|
||||
async def _iterator() -> AsyncIterator[Any]:
|
||||
for item in items:
|
||||
yield item
|
||||
|
||||
return _iterator()
|
||||
|
||||
|
||||
def _make_checkpoint(
|
||||
workflow_name: str = "test-workflow",
|
||||
checkpoint_id: str | None = None,
|
||||
previous_checkpoint_id: str | None = None,
|
||||
timestamp: str | None = None,
|
||||
) -> WorkflowCheckpoint:
|
||||
"""Create a minimal WorkflowCheckpoint for testing."""
|
||||
return WorkflowCheckpoint(
|
||||
workflow_name=workflow_name,
|
||||
graph_signature_hash="abc123",
|
||||
checkpoint_id=checkpoint_id or str(uuid.uuid4()),
|
||||
previous_checkpoint_id=previous_checkpoint_id,
|
||||
timestamp=timestamp or "2025-01-01T00:00:00+00:00",
|
||||
state={"counter": 42},
|
||||
iteration_count=1,
|
||||
)
|
||||
|
||||
|
||||
def _checkpoint_to_cosmos_document(checkpoint: WorkflowCheckpoint) -> dict[str, Any]:
|
||||
"""Simulate what a Cosmos DB document looks like after save."""
|
||||
encoded = encode_checkpoint_value(checkpoint.to_dict())
|
||||
doc: dict[str, Any] = {
|
||||
"id": f"{checkpoint.workflow_name}_{checkpoint.checkpoint_id}",
|
||||
"workflow_name": checkpoint.workflow_name,
|
||||
**encoded,
|
||||
# Cosmos system properties
|
||||
"_rid": "abc",
|
||||
"_self": "dbs/abc/colls/def/docs/ghi",
|
||||
"_etag": '"00000000-0000-0000-0000-000000000000"',
|
||||
"_attachments": "attachments/",
|
||||
"_ts": 1700000000,
|
||||
}
|
||||
return doc
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_container() -> MagicMock:
|
||||
container = MagicMock()
|
||||
container.query_items = MagicMock(return_value=_to_async_iter([]))
|
||||
container.upsert_item = AsyncMock(return_value={})
|
||||
container.delete_item = AsyncMock(return_value={})
|
||||
return container
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_cosmos_client(mock_container: MagicMock) -> MagicMock:
|
||||
database_client = MagicMock()
|
||||
database_client.create_container_if_not_exists = AsyncMock(return_value=mock_container)
|
||||
|
||||
client = MagicMock()
|
||||
client.create_database_if_not_exists = AsyncMock(return_value=database_client)
|
||||
client.close = AsyncMock()
|
||||
return client
|
||||
|
||||
|
||||
# --- Tests for initialization ---
|
||||
|
||||
|
||||
async def test_init_uses_provided_container_client(mock_container: MagicMock) -> None:
|
||||
storage = CosmosCheckpointStorage(container_client=mock_container)
|
||||
assert storage.database_name == ""
|
||||
assert storage.container_name == ""
|
||||
|
||||
|
||||
async def test_init_uses_provided_cosmos_client(mock_cosmos_client: MagicMock) -> None:
|
||||
storage = CosmosCheckpointStorage(
|
||||
cosmos_client=mock_cosmos_client,
|
||||
database_name="db1",
|
||||
container_name="checkpoints",
|
||||
)
|
||||
assert storage.database_name == "db1"
|
||||
assert storage.container_name == "checkpoints"
|
||||
|
||||
|
||||
async def test_init_missing_required_settings_raises(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("AZURE_COSMOS_ENDPOINT", raising=False)
|
||||
monkeypatch.delenv("AZURE_COSMOS_DATABASE_NAME", raising=False)
|
||||
monkeypatch.delenv("AZURE_COSMOS_CONTAINER_NAME", raising=False)
|
||||
monkeypatch.delenv("AZURE_COSMOS_KEY", raising=False)
|
||||
|
||||
with pytest.raises(SettingNotFoundError, match="database_name"):
|
||||
CosmosCheckpointStorage()
|
||||
|
||||
|
||||
async def test_init_constructs_client_with_credential(
|
||||
monkeypatch: pytest.MonkeyPatch, mock_cosmos_client: MagicMock
|
||||
) -> None:
|
||||
"""Uses key-based auth when a key string is provided, otherwise falls back to Azure credential (RBAC)."""
|
||||
mock_factory = MagicMock(return_value=mock_cosmos_client)
|
||||
monkeypatch.setattr(checkpoint_storage_module, "CosmosClient", mock_factory)
|
||||
monkeypatch.delenv("AZURE_COSMOS_KEY", raising=False)
|
||||
|
||||
# Simulate real-world pattern: use key if available, else RBAC credential
|
||||
cosmos_key = os.getenv("AZURE_COSMOS_KEY")
|
||||
credential: Any = cosmos_key if cosmos_key else MagicMock() # MagicMock simulates DefaultAzureCredential()
|
||||
|
||||
CosmosCheckpointStorage(
|
||||
endpoint="https://account.documents.azure.com:443/",
|
||||
credential=credential,
|
||||
database_name="db1",
|
||||
container_name="checkpoints",
|
||||
)
|
||||
|
||||
mock_factory.assert_called_once()
|
||||
kwargs = mock_factory.call_args.kwargs
|
||||
assert kwargs["url"] == "https://account.documents.azure.com:443/"
|
||||
assert kwargs["credential"] is credential
|
||||
|
||||
|
||||
async def test_init_creates_database_and_container(mock_cosmos_client: MagicMock) -> None:
|
||||
storage = CosmosCheckpointStorage(
|
||||
cosmos_client=mock_cosmos_client,
|
||||
database_name="db1",
|
||||
container_name="custom-checkpoints",
|
||||
)
|
||||
|
||||
await storage.list_checkpoint_ids(workflow_name="wf")
|
||||
|
||||
mock_cosmos_client.create_database_if_not_exists.assert_awaited_once_with(id="db1")
|
||||
database_client = mock_cosmos_client.create_database_if_not_exists.return_value
|
||||
assert database_client.create_container_if_not_exists.await_count == 1
|
||||
kwargs = database_client.create_container_if_not_exists.await_args.kwargs
|
||||
assert kwargs["id"] == "custom-checkpoints"
|
||||
|
||||
|
||||
# --- Tests for save ---
|
||||
|
||||
|
||||
async def test_save_upserts_document(mock_container: MagicMock) -> None:
|
||||
storage = CosmosCheckpointStorage(container_client=mock_container)
|
||||
checkpoint = _make_checkpoint()
|
||||
|
||||
result = await storage.save(checkpoint)
|
||||
|
||||
assert result == checkpoint.checkpoint_id
|
||||
mock_container.upsert_item.assert_awaited_once()
|
||||
document = mock_container.upsert_item.await_args.kwargs["body"]
|
||||
assert document["id"] == f"test-workflow_{checkpoint.checkpoint_id}"
|
||||
assert document["workflow_name"] == "test-workflow"
|
||||
assert document["graph_signature_hash"] == "abc123"
|
||||
assert document["state"]["counter"] == 42
|
||||
|
||||
|
||||
async def test_save_returns_checkpoint_id(mock_container: MagicMock) -> None:
|
||||
storage = CosmosCheckpointStorage(container_client=mock_container)
|
||||
checkpoint = _make_checkpoint(checkpoint_id="cp-123")
|
||||
|
||||
result = await storage.save(checkpoint)
|
||||
|
||||
assert result == "cp-123"
|
||||
|
||||
|
||||
# --- Tests for load ---
|
||||
|
||||
|
||||
async def test_load_returns_checkpoint(mock_container: MagicMock) -> None:
|
||||
checkpoint = _make_checkpoint(checkpoint_id="cp-load")
|
||||
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("cp-load")
|
||||
|
||||
assert loaded.checkpoint_id == "cp-load"
|
||||
assert loaded.workflow_name == "test-workflow"
|
||||
assert loaded.graph_signature_hash == "abc123"
|
||||
assert loaded.state["counter"] == 42
|
||||
|
||||
|
||||
async def test_load_nonexistent_raises(mock_container: MagicMock) -> None:
|
||||
mock_container.query_items.return_value = _to_async_iter([])
|
||||
|
||||
storage = CosmosCheckpointStorage(container_client=mock_container)
|
||||
|
||||
with pytest.raises(WorkflowCheckpointException, match="No checkpoint found"):
|
||||
await storage.load("nonexistent-id")
|
||||
|
||||
|
||||
async def test_load_queries_without_partition_key(mock_container: MagicMock) -> None:
|
||||
mock_container.query_items.return_value = _to_async_iter([])
|
||||
|
||||
storage = CosmosCheckpointStorage(container_client=mock_container)
|
||||
with suppress(WorkflowCheckpointException):
|
||||
await storage.load("cp-id")
|
||||
|
||||
kwargs = mock_container.query_items.call_args.kwargs
|
||||
assert "partition_key" not in kwargs
|
||||
|
||||
|
||||
async def test_load_multiple_workflows_same_checkpoint_id_raises(mock_container: MagicMock) -> None:
|
||||
cp1 = _make_checkpoint(checkpoint_id="shared-id", workflow_name="workflow-a")
|
||||
cp2 = _make_checkpoint(checkpoint_id="shared-id", workflow_name="workflow-b")
|
||||
mock_container.query_items.return_value = _to_async_iter([
|
||||
_checkpoint_to_cosmos_document(cp1),
|
||||
_checkpoint_to_cosmos_document(cp2),
|
||||
])
|
||||
|
||||
storage = CosmosCheckpointStorage(container_client=mock_container)
|
||||
|
||||
with pytest.raises(WorkflowCheckpointException, match="Multiple checkpoints found"):
|
||||
await storage.load("shared-id")
|
||||
|
||||
|
||||
# --- Tests for list_checkpoints ---
|
||||
|
||||
|
||||
async def test_list_checkpoints_returns_checkpoints_for_workflow(mock_container: MagicMock) -> None:
|
||||
cp1 = _make_checkpoint(checkpoint_id="cp-1", timestamp="2025-01-01T00:00:00+00:00")
|
||||
cp2 = _make_checkpoint(checkpoint_id="cp-2", timestamp="2025-01-02T00:00:00+00:00")
|
||||
mock_container.query_items.return_value = _to_async_iter([
|
||||
_checkpoint_to_cosmos_document(cp1),
|
||||
_checkpoint_to_cosmos_document(cp2),
|
||||
])
|
||||
|
||||
storage = CosmosCheckpointStorage(container_client=mock_container)
|
||||
results = await storage.list_checkpoints(workflow_name="test-workflow")
|
||||
|
||||
assert len(results) == 2
|
||||
assert results[0].checkpoint_id == "cp-1"
|
||||
assert results[1].checkpoint_id == "cp-2"
|
||||
|
||||
|
||||
async def test_list_checkpoints_uses_partition_key(mock_container: MagicMock) -> None:
|
||||
mock_container.query_items.return_value = _to_async_iter([])
|
||||
|
||||
storage = CosmosCheckpointStorage(container_client=mock_container)
|
||||
await storage.list_checkpoints(workflow_name="my-workflow")
|
||||
|
||||
kwargs = mock_container.query_items.call_args.kwargs
|
||||
assert kwargs["partition_key"] == "my-workflow"
|
||||
|
||||
|
||||
async def test_list_checkpoints_empty_returns_empty(mock_container: MagicMock) -> None:
|
||||
mock_container.query_items.return_value = _to_async_iter([])
|
||||
|
||||
storage = CosmosCheckpointStorage(container_client=mock_container)
|
||||
results = await storage.list_checkpoints(workflow_name="test-workflow")
|
||||
|
||||
assert results == []
|
||||
|
||||
|
||||
async def test_list_checkpoints_skips_malformed_documents(mock_container: MagicMock) -> None:
|
||||
valid_cp = _make_checkpoint(checkpoint_id="cp-valid")
|
||||
mock_container.query_items.return_value = _to_async_iter([
|
||||
{"id": "bad_doc", "workflow_name": "test-workflow", "not_a_checkpoint": True},
|
||||
_checkpoint_to_cosmos_document(valid_cp),
|
||||
])
|
||||
|
||||
storage = CosmosCheckpointStorage(container_client=mock_container)
|
||||
results = await storage.list_checkpoints(workflow_name="test-workflow")
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0].checkpoint_id == "cp-valid"
|
||||
|
||||
|
||||
# --- Tests for delete ---
|
||||
|
||||
|
||||
async def test_delete_existing_returns_true(mock_container: MagicMock) -> None:
|
||||
mock_container.query_items.return_value = _to_async_iter([
|
||||
{"id": "test-workflow_cp-del", "workflow_name": "test-workflow"},
|
||||
])
|
||||
|
||||
storage = CosmosCheckpointStorage(container_client=mock_container)
|
||||
result = await storage.delete("cp-del")
|
||||
|
||||
assert result is True
|
||||
mock_container.delete_item.assert_awaited_once_with(
|
||||
item="test-workflow_cp-del",
|
||||
partition_key="test-workflow",
|
||||
)
|
||||
|
||||
|
||||
async def test_delete_nonexistent_returns_false(mock_container: MagicMock) -> None:
|
||||
mock_container.query_items.return_value = _to_async_iter([])
|
||||
|
||||
storage = CosmosCheckpointStorage(container_client=mock_container)
|
||||
result = await storage.delete("nonexistent")
|
||||
|
||||
assert result is False
|
||||
mock_container.delete_item.assert_not_awaited()
|
||||
|
||||
|
||||
async def test_delete_cosmos_not_found_returns_false(mock_container: MagicMock) -> None:
|
||||
mock_container.query_items.return_value = _to_async_iter([
|
||||
{"id": "test-workflow_cp-del", "workflow_name": "test-workflow"},
|
||||
])
|
||||
mock_container.delete_item = AsyncMock(side_effect=CosmosResourceNotFoundError)
|
||||
|
||||
storage = CosmosCheckpointStorage(container_client=mock_container)
|
||||
result = await storage.delete("cp-del")
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
# --- Tests for get_latest ---
|
||||
|
||||
|
||||
async def test_get_latest_returns_latest_checkpoint(mock_container: MagicMock) -> None:
|
||||
cp = _make_checkpoint(checkpoint_id="cp-latest", timestamp="2025-06-01T00:00:00+00:00")
|
||||
mock_container.query_items.return_value = _to_async_iter([
|
||||
_checkpoint_to_cosmos_document(cp),
|
||||
])
|
||||
|
||||
storage = CosmosCheckpointStorage(container_client=mock_container)
|
||||
result = await storage.get_latest(workflow_name="test-workflow")
|
||||
|
||||
assert result is not None
|
||||
assert result.checkpoint_id == "cp-latest"
|
||||
|
||||
|
||||
async def test_get_latest_returns_none_when_empty(mock_container: MagicMock) -> None:
|
||||
mock_container.query_items.return_value = _to_async_iter([])
|
||||
|
||||
storage = CosmosCheckpointStorage(container_client=mock_container)
|
||||
result = await storage.get_latest(workflow_name="test-workflow")
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
async def test_get_latest_uses_order_by_desc_with_limit(mock_container: MagicMock) -> None:
|
||||
mock_container.query_items.return_value = _to_async_iter([])
|
||||
|
||||
storage = CosmosCheckpointStorage(container_client=mock_container)
|
||||
await storage.get_latest(workflow_name="test-workflow")
|
||||
|
||||
kwargs = mock_container.query_items.call_args.kwargs
|
||||
assert "ORDER BY c.timestamp DESC" in kwargs["query"]
|
||||
assert "OFFSET 0 LIMIT 1" in kwargs["query"]
|
||||
|
||||
|
||||
# --- Tests for list_checkpoint_ids ---
|
||||
|
||||
|
||||
async def test_list_checkpoint_ids_returns_ids(mock_container: MagicMock) -> None:
|
||||
mock_container.query_items.return_value = _to_async_iter([
|
||||
{"checkpoint_id": "cp-1"},
|
||||
{"checkpoint_id": "cp-2"},
|
||||
])
|
||||
|
||||
storage = CosmosCheckpointStorage(container_client=mock_container)
|
||||
ids = await storage.list_checkpoint_ids(workflow_name="test-workflow")
|
||||
|
||||
assert ids == ["cp-1", "cp-2"]
|
||||
|
||||
|
||||
async def test_list_checkpoint_ids_empty_returns_empty(mock_container: MagicMock) -> None:
|
||||
mock_container.query_items.return_value = _to_async_iter([])
|
||||
|
||||
storage = CosmosCheckpointStorage(container_client=mock_container)
|
||||
ids = await storage.list_checkpoint_ids(workflow_name="test-workflow")
|
||||
|
||||
assert ids == []
|
||||
|
||||
|
||||
# --- Tests for close and context manager ---
|
||||
|
||||
|
||||
async def test_close_closes_owned_client(monkeypatch: pytest.MonkeyPatch, mock_cosmos_client: MagicMock) -> None:
|
||||
mock_factory = MagicMock(return_value=mock_cosmos_client)
|
||||
monkeypatch.setattr(checkpoint_storage_module, "CosmosClient", mock_factory)
|
||||
|
||||
storage = CosmosCheckpointStorage(
|
||||
endpoint="https://account.documents.azure.com:443/",
|
||||
credential="key-123",
|
||||
database_name="db1",
|
||||
container_name="checkpoints",
|
||||
)
|
||||
|
||||
await storage.close()
|
||||
|
||||
mock_cosmos_client.close.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_close_does_not_close_external_client(mock_cosmos_client: MagicMock) -> None:
|
||||
storage = CosmosCheckpointStorage(
|
||||
cosmos_client=mock_cosmos_client,
|
||||
database_name="db1",
|
||||
container_name="checkpoints",
|
||||
)
|
||||
|
||||
await storage.close()
|
||||
|
||||
mock_cosmos_client.close.assert_not_awaited()
|
||||
|
||||
|
||||
async def test_context_manager_closes_owned_client(
|
||||
monkeypatch: pytest.MonkeyPatch, mock_cosmos_client: MagicMock
|
||||
) -> None:
|
||||
mock_factory = MagicMock(return_value=mock_cosmos_client)
|
||||
monkeypatch.setattr(checkpoint_storage_module, "CosmosClient", mock_factory)
|
||||
|
||||
async with CosmosCheckpointStorage(
|
||||
endpoint="https://account.documents.azure.com:443/",
|
||||
credential="key-123",
|
||||
database_name="db1",
|
||||
container_name="checkpoints",
|
||||
) as storage:
|
||||
assert storage is not None
|
||||
|
||||
mock_cosmos_client.close.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_context_manager_preserves_original_exception(mock_container: MagicMock) -> None:
|
||||
storage = CosmosCheckpointStorage(container_client=mock_container)
|
||||
|
||||
with (
|
||||
patch.object(storage, "close", AsyncMock(side_effect=RuntimeError("close failed"))),
|
||||
pytest.raises(ValueError, match="inner error"),
|
||||
):
|
||||
async with storage:
|
||||
raise ValueError("inner error")
|
||||
|
||||
|
||||
async def test_context_manager_reraises_close_error(mock_container: MagicMock) -> None:
|
||||
storage = CosmosCheckpointStorage(container_client=mock_container)
|
||||
|
||||
with (
|
||||
patch.object(storage, "close", AsyncMock(side_effect=RuntimeError("close failed"))),
|
||||
pytest.raises(RuntimeError, match="close failed"),
|
||||
):
|
||||
async with storage:
|
||||
pass # no inner exception — close error should propagate
|
||||
|
||||
|
||||
# --- Tests for save/load round-trip ---
|
||||
|
||||
|
||||
async def test_round_trip_preserves_data(mock_container: MagicMock) -> None:
|
||||
checkpoint = _make_checkpoint(
|
||||
checkpoint_id="cp-roundtrip",
|
||||
previous_checkpoint_id="cp-parent",
|
||||
)
|
||||
checkpoint.state = {"key": "value", "nested": {"a": 1}}
|
||||
checkpoint.metadata = {"superstep": 3}
|
||||
checkpoint.iteration_count = 5
|
||||
|
||||
saved_doc: dict[str, Any] = {}
|
||||
|
||||
async def capture_upsert(body: dict[str, Any]) -> dict[str, Any]:
|
||||
saved_doc.update(body)
|
||||
return body
|
||||
|
||||
mock_container.upsert_item = AsyncMock(side_effect=capture_upsert)
|
||||
|
||||
storage = CosmosCheckpointStorage(container_client=mock_container)
|
||||
await storage.save(checkpoint)
|
||||
|
||||
returned_doc = {
|
||||
**saved_doc,
|
||||
"_rid": "abc",
|
||||
"_self": "dbs/abc/colls/def/docs/ghi",
|
||||
"_etag": '"etag"',
|
||||
"_attachments": "attachments/",
|
||||
"_ts": 1700000000,
|
||||
}
|
||||
mock_container.query_items.return_value = _to_async_iter([returned_doc])
|
||||
|
||||
loaded = await storage.load("cp-roundtrip")
|
||||
|
||||
assert loaded.checkpoint_id == checkpoint.checkpoint_id
|
||||
assert loaded.workflow_name == checkpoint.workflow_name
|
||||
assert loaded.graph_signature_hash == checkpoint.graph_signature_hash
|
||||
assert loaded.previous_checkpoint_id == "cp-parent"
|
||||
assert loaded.state == {"key": "value", "nested": {"a": 1}}
|
||||
assert loaded.metadata == {"superstep": 3}
|
||||
assert loaded.iteration_count == 5
|
||||
assert loaded.version == "1.0"
|
||||
|
||||
|
||||
# --- Integration test ---
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@skip_if_cosmos_integration_tests_disabled
|
||||
async def test_cosmos_checkpoint_storage_roundtrip_with_emulator() -> None:
|
||||
endpoint = os.getenv("AZURE_COSMOS_ENDPOINT", "")
|
||||
key = os.getenv("AZURE_COSMOS_KEY", "")
|
||||
database_prefix = os.getenv("AZURE_COSMOS_DATABASE_NAME", "")
|
||||
container_prefix = os.getenv("AZURE_COSMOS_CONTAINER_NAME", "")
|
||||
unique = uuid.uuid4().hex[:8]
|
||||
database_name = f"{database_prefix}-cp-{unique}"
|
||||
container_name = f"{container_prefix}-cp-{unique}"
|
||||
|
||||
async with CosmosClient(url=endpoint, credential=key) as cosmos_client:
|
||||
await cosmos_client.create_database_if_not_exists(id=database_name)
|
||||
|
||||
storage = CosmosCheckpointStorage(
|
||||
cosmos_client=cosmos_client,
|
||||
database_name=database_name,
|
||||
container_name=container_name,
|
||||
)
|
||||
|
||||
try:
|
||||
# Save two checkpoints for the same workflow
|
||||
cp1 = _make_checkpoint(
|
||||
checkpoint_id="cp-int-1",
|
||||
workflow_name="integration-wf",
|
||||
timestamp="2025-01-01T00:00:00+00:00",
|
||||
)
|
||||
cp2 = _make_checkpoint(
|
||||
checkpoint_id="cp-int-2",
|
||||
workflow_name="integration-wf",
|
||||
previous_checkpoint_id="cp-int-1",
|
||||
timestamp="2025-01-02T00:00:00+00:00",
|
||||
)
|
||||
cp2.state = {"step": 2}
|
||||
|
||||
await storage.save(cp1)
|
||||
await storage.save(cp2)
|
||||
|
||||
# Load by ID
|
||||
loaded = await storage.load("cp-int-1")
|
||||
assert loaded.checkpoint_id == "cp-int-1"
|
||||
assert loaded.workflow_name == "integration-wf"
|
||||
|
||||
# List all checkpoints for workflow
|
||||
all_cps = await storage.list_checkpoints(workflow_name="integration-wf")
|
||||
assert len(all_cps) == 2
|
||||
|
||||
# List checkpoint IDs
|
||||
ids = await storage.list_checkpoint_ids(workflow_name="integration-wf")
|
||||
assert "cp-int-1" in ids
|
||||
assert "cp-int-2" in ids
|
||||
|
||||
# Get latest
|
||||
latest = await storage.get_latest(workflow_name="integration-wf")
|
||||
assert latest is not None
|
||||
assert latest.checkpoint_id == "cp-int-2"
|
||||
assert latest.state == {"step": 2}
|
||||
|
||||
# Delete
|
||||
assert await storage.delete("cp-int-1") is True
|
||||
assert await storage.delete("cp-int-1") is False
|
||||
|
||||
remaining = await storage.list_checkpoint_ids(workflow_name="integration-wf")
|
||||
assert remaining == ["cp-int-2"]
|
||||
|
||||
# Cross-workflow isolation
|
||||
other_cp = _make_checkpoint(
|
||||
checkpoint_id="cp-other",
|
||||
workflow_name="other-wf",
|
||||
)
|
||||
await storage.save(other_cp)
|
||||
wf_cps = await storage.list_checkpoints(workflow_name="integration-wf")
|
||||
assert len(wf_cps) == 1
|
||||
assert wf_cps[0].checkpoint_id == "cp-int-2"
|
||||
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user