mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
43
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5419b9f38 | ||
|
|
03e47b5232 | ||
|
|
46ab47b9e1 | ||
|
|
094f9903b3 | ||
|
|
8b71f9459a | ||
|
|
866a325b48 | ||
|
|
40e90c96c3 | ||
|
|
1e1eda65ce | ||
|
|
3a463b8bf6 | ||
|
|
74a5ea8dca | ||
|
|
df6041bcc1 | ||
|
|
e6c29f8fa4 | ||
|
|
2c35be877d | ||
|
|
0a27c74245 | ||
|
|
7c4837744b | ||
|
|
870f10829e | ||
|
|
5ba7f8aa6f | ||
|
|
35a0b51523 | ||
|
|
d28c841c50 | ||
|
|
7d305d461c | ||
|
|
8f4efe5fb9 | ||
|
|
362c4c5f84 | ||
|
|
27a6f47a3b | ||
|
|
198a3a1ab1 | ||
|
|
88347f6494 | ||
|
|
9b22ecd119 | ||
|
|
2eb0705ee0 | ||
|
|
dad3652f46 | ||
|
|
56fb634f0e | ||
|
|
56c3f8d825 | ||
|
|
0b69d7fd15 | ||
|
|
7b70f80036 | ||
|
|
da32e8cf80 | ||
|
|
62e02da698 | ||
|
|
63c0a51797 | ||
|
|
b00465d7be | ||
|
|
4adfd244ac | ||
|
|
932ceddf95 | ||
|
|
0989e68d1c | ||
|
|
b084d0461d | ||
|
|
5fe8941ff9 | ||
|
|
0dbcc9fe9d | ||
|
|
4d3e4f865f |
@@ -336,6 +336,53 @@ jobs:
|
||||
path: ./python/pytest.xml
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Foundry Hosting integration tests
|
||||
python-tests-foundry-hosting:
|
||||
name: Python Integration Tests - Foundry Hosting
|
||||
runs-on: ubuntu-latest
|
||||
environment: integration
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }}
|
||||
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }}
|
||||
defaults:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.checkout-ref }}
|
||||
persist-credentials: false
|
||||
- name: Set up python and install the project
|
||||
id: python-setup
|
||||
uses: ./.github/actions/python-setup
|
||||
with:
|
||||
python-version: ${{ env.UV_PYTHON }}
|
||||
os: ${{ runner.os }}
|
||||
- name: Azure CLI Login
|
||||
uses: azure/login@v2
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
- name: Test with pytest (Foundry Hosting integration)
|
||||
timeout-minutes: 15
|
||||
run: >
|
||||
uv run pytest --import-mode=importlib
|
||||
packages/foundry_hosting/tests
|
||||
-m integration
|
||||
-n logical --dist worksteal
|
||||
--timeout=120 --session-timeout=900 --timeout_method thread
|
||||
--retries 2 --retry-delay 5
|
||||
--junitxml=pytest.xml
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: test-results-foundry-hosting
|
||||
path: ./python/pytest.xml
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Azure Cosmos integration tests
|
||||
python-tests-cosmos:
|
||||
name: Python Integration Tests - Cosmos
|
||||
@@ -402,6 +449,7 @@ jobs:
|
||||
python-tests-misc-integration,
|
||||
python-tests-functions,
|
||||
python-tests-foundry,
|
||||
python-tests-foundry-hosting,
|
||||
python-tests-cosmos,
|
||||
]
|
||||
runs-on: ubuntu-latest
|
||||
@@ -465,6 +513,7 @@ jobs:
|
||||
python-tests-misc-integration,
|
||||
python-tests-functions,
|
||||
python-tests-foundry,
|
||||
python-tests-foundry-hosting,
|
||||
python-tests-cosmos
|
||||
]
|
||||
steps:
|
||||
|
||||
@@ -38,6 +38,7 @@ jobs:
|
||||
miscChanged: ${{ steps.filter.outputs.misc }}
|
||||
functionsChanged: ${{ steps.filter.outputs.functions }}
|
||||
foundryChanged: ${{ steps.filter.outputs.foundry }}
|
||||
foundryHostingChanged: ${{ steps.filter.outputs.foundry_hosting }}
|
||||
cosmosChanged: ${{ steps.filter.outputs.cosmos }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
@@ -80,6 +81,8 @@ jobs:
|
||||
- 'python/packages/foundry/**'
|
||||
- 'python/samples/**/providers/foundry/**'
|
||||
- 'python/samples/02-agents/embeddings/foundry_embeddings.py'
|
||||
foundry_hosting:
|
||||
- 'python/packages/foundry_hosting/**'
|
||||
cosmos:
|
||||
- 'python/packages/azure-cosmos/**'
|
||||
# run only if 'python' files were changed
|
||||
@@ -488,6 +491,67 @@ jobs:
|
||||
path: ./python/pytest.xml
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Foundry Hosting integration tests
|
||||
python-tests-foundry-hosting:
|
||||
name: Python Tests - Foundry Hosting Integration
|
||||
needs: paths-filter
|
||||
if: >
|
||||
github.event_name != 'pull_request' &&
|
||||
needs.paths-filter.outputs.pythonChanges == 'true' &&
|
||||
(github.event_name != 'merge_group' ||
|
||||
needs.paths-filter.outputs.foundryHostingChanged == 'true' ||
|
||||
needs.paths-filter.outputs.coreChanged == 'true')
|
||||
runs-on: ubuntu-latest
|
||||
environment: integration
|
||||
env:
|
||||
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }}
|
||||
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }}
|
||||
defaults:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up python and install the project
|
||||
id: python-setup
|
||||
uses: ./.github/actions/python-setup
|
||||
with:
|
||||
python-version: ${{ env.UV_PYTHON }}
|
||||
os: ${{ runner.os }}
|
||||
- name: Azure CLI Login
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: azure/login@v2
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
- name: Test with pytest (Foundry Hosting integration)
|
||||
timeout-minutes: 15
|
||||
run: >
|
||||
uv run pytest --import-mode=importlib
|
||||
packages/foundry_hosting/tests
|
||||
-m integration
|
||||
-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/pytest.xml
|
||||
summary: true
|
||||
display-options: fEX
|
||||
fail-on-empty: false
|
||||
title: Foundry Hosting integration test results
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: test-results-foundry-hosting
|
||||
path: ./python/pytest.xml
|
||||
if-no-files-found: ignore
|
||||
|
||||
# TODO: Add python-tests-lab
|
||||
|
||||
# Azure Cosmos integration tests
|
||||
@@ -569,6 +633,7 @@ jobs:
|
||||
python-tests-misc-integration,
|
||||
python-tests-functions,
|
||||
python-tests-foundry,
|
||||
python-tests-foundry-hosting,
|
||||
python-tests-cosmos,
|
||||
]
|
||||
runs-on: ubuntu-latest
|
||||
@@ -629,6 +694,7 @@ jobs:
|
||||
python-tests-misc-integration,
|
||||
python-tests-functions,
|
||||
python-tests-foundry,
|
||||
python-tests-foundry-hosting,
|
||||
python-tests-cosmos,
|
||||
]
|
||||
steps:
|
||||
|
||||
@@ -242,3 +242,7 @@ python/dotnet-ref
|
||||
# Generated filtered solution files (created by eng/scripts/New-FilteredSolution.ps1)
|
||||
dotnet/filtered-*.slnx
|
||||
**/*.lscache
|
||||
|
||||
# Local tool state
|
||||
.omc/
|
||||
.omx/
|
||||
|
||||
@@ -22,9 +22,9 @@
|
||||
<PackageVersion Include="Aspire.Microsoft.Azure.Cosmos" Version="$(AspireAppHostSdkVersion)" />
|
||||
<PackageVersion Include="CommunityToolkit.Aspire.OllamaSharp" Version="13.0.0" />
|
||||
<!-- Azure.* -->
|
||||
<PackageVersion Include="Azure.AI.AgentServer.Core" Version="1.0.0-beta.22" />
|
||||
<PackageVersion Include="Azure.AI.AgentServer.Invocations" Version="1.0.0-beta.1" />
|
||||
<PackageVersion Include="Azure.AI.AgentServer.Responses" Version="1.0.0-beta.3" />
|
||||
<PackageVersion Include="Azure.AI.AgentServer.Core" Version="1.0.0-beta.23" />
|
||||
<PackageVersion Include="Azure.AI.AgentServer.Invocations" Version="1.0.0-beta.3" />
|
||||
<PackageVersion Include="Azure.AI.AgentServer.Responses" Version="1.0.0-beta.4" />
|
||||
<PackageVersion Include="Azure.AI.Projects" Version="2.0.0" />
|
||||
<PackageVersion Include="Azure.AI.Agents.Persistent" Version="1.2.0-beta.10" />
|
||||
<PackageVersion Include="Azure.AI.OpenAI" Version="2.9.0-beta.1" />
|
||||
@@ -56,15 +56,15 @@
|
||||
<PackageVersion Include="System.Threading.Tasks.Extensions" Version="4.6.3" />
|
||||
<PackageVersion Include="System.Net.Security" Version="4.3.2" />
|
||||
<!-- OpenTelemetry -->
|
||||
<PackageVersion Include="OpenTelemetry" Version="1.15.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Api" Version="1.15.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Exporter.Console" Version="1.15.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Exporter.InMemory" Version="1.15.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.15.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.14.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.14.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.14.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.14.0" />
|
||||
<PackageVersion Include="OpenTelemetry" Version="1.15.3" />
|
||||
<PackageVersion Include="OpenTelemetry.Api" Version="1.15.3" />
|
||||
<PackageVersion Include="OpenTelemetry.Exporter.Console" Version="1.15.3" />
|
||||
<PackageVersion Include="OpenTelemetry.Exporter.InMemory" Version="1.15.3" />
|
||||
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.15.3" />
|
||||
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.15.3" />
|
||||
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.2" />
|
||||
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.15.1" />
|
||||
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.15.1" />
|
||||
<!-- Microsoft.AspNetCore.* -->
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.0" />
|
||||
@@ -188,4 +188,4 @@
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
@@ -160,6 +160,7 @@
|
||||
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step22_MemorySearch/Agent_Step22_MemorySearch.csproj" />
|
||||
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step23_LocalMCP/Agent_Step23_LocalMCP.csproj" />
|
||||
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step24_CodeInterpreterFileDownload/Agent_Step24_CodeInterpreterFileDownload.csproj" />
|
||||
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step25_ToolboxServerSideTools/Agent_Step25_ToolboxServerSideTools.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/02-agents/Evaluation/">
|
||||
<Project Path="samples/02-agents/Evaluation/Evaluation_SimpleEval/Evaluation_SimpleEval.csproj" />
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<!-- Central version prefix - applies to all nuget packages. -->
|
||||
<VersionPrefix>1.2.0</VersionPrefix>
|
||||
<VersionPrefix>1.3.0</VersionPrefix>
|
||||
<RCNumber>1</RCNumber>
|
||||
<DateSuffix>260421</DateSuffix>
|
||||
<DateSuffix>260423</DateSuffix>
|
||||
<PackageVersion Condition="'$(IsReleaseCandidate)' == 'true'">$(VersionPrefix)-rc$(RCNumber)</PackageVersion>
|
||||
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).$(DateSuffix).1</PackageVersion>
|
||||
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' == ''">$(VersionPrefix)-preview.$(DateSuffix).1</PackageVersion>
|
||||
<PackageVersion Condition="'$(IsReleased)' == 'true'">$(VersionPrefix)</PackageVersion>
|
||||
<GitTag>1.2.0</GitTag>
|
||||
<GitTag>1.3.0</GitTag>
|
||||
|
||||
<Configurations>Debug;Release;Publish</Configurations>
|
||||
<IsPackable>true</IsPackable>
|
||||
|
||||
@@ -5,16 +5,16 @@
|
||||
// This is provided for demonstration purposes only.
|
||||
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Executes file-based skill scripts as local subprocesses.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This runner uses the script's absolute path, converts the arguments
|
||||
/// to CLI flags, and returns captured output. It is intended for
|
||||
/// demonstration purposes only.
|
||||
/// This runner uses the script's absolute path and converts the arguments
|
||||
/// to CLI arguments. When the LLM sends a JSON array, each element is used
|
||||
/// as a positional argument. It is intended for demonstration purposes only.
|
||||
/// </remarks>
|
||||
internal static class SubprocessScriptRunner
|
||||
{
|
||||
@@ -24,7 +24,8 @@ internal static class SubprocessScriptRunner
|
||||
public static async Task<object?> RunAsync(
|
||||
AgentFileSkill skill,
|
||||
AgentFileSkillScript script,
|
||||
AIFunctionArguments arguments,
|
||||
JsonElement? arguments,
|
||||
IServiceProvider? serviceProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!File.Exists(script.FullPath))
|
||||
@@ -61,24 +62,27 @@ internal static class SubprocessScriptRunner
|
||||
startInfo.FileName = script.FullPath;
|
||||
}
|
||||
|
||||
if (arguments is not null)
|
||||
if (arguments is { ValueKind: JsonValueKind.Array } json)
|
||||
{
|
||||
foreach (var (key, value) in arguments)
|
||||
// Positional CLI arguments
|
||||
foreach (var element in json.EnumerateArray())
|
||||
{
|
||||
if (value is bool boolValue)
|
||||
if (element.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
if (boolValue)
|
||||
{
|
||||
startInfo.ArgumentList.Add(NormalizeKey(key));
|
||||
}
|
||||
}
|
||||
else if (value is not null)
|
||||
{
|
||||
startInfo.ArgumentList.Add(NormalizeKey(key));
|
||||
startInfo.ArgumentList.Add(value.ToString()!);
|
||||
throw new InvalidOperationException(
|
||||
$"File-based skill scripts only accept string CLI arguments but received a JSON element of kind '{element.ValueKind}'. " +
|
||||
"All array elements must be JSON strings.");
|
||||
}
|
||||
|
||||
startInfo.ArgumentList.Add(element.GetString()!);
|
||||
}
|
||||
}
|
||||
else if (arguments is not null && arguments.Value.ValueKind != JsonValueKind.Null && arguments.Value.ValueKind != JsonValueKind.Undefined)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Expected a JSON array of CLI arguments but received {arguments.Value.ValueKind}. " +
|
||||
"File-based skill scripts expect positional arguments as a JSON array of strings.");
|
||||
}
|
||||
|
||||
Process? process = null;
|
||||
try
|
||||
@@ -128,10 +132,4 @@ internal static class SubprocessScriptRunner
|
||||
process?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes a parameter key to a consistent --flag format.
|
||||
/// Models may return keys with or without leading dashes (e.g., "value" vs "--value").
|
||||
/// </summary>
|
||||
private static string NormalizeKey(string key) => "--" + key.TrimStart('-');
|
||||
}
|
||||
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample shows how to load a Foundry toolbox and pass its tools as server-side
|
||||
// tools when creating an agent. The Foundry platform handles tool execution — the agent
|
||||
// process does not invoke tools locally.
|
||||
|
||||
using System.ClientModel;
|
||||
using System.ClientModel.Primitives;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.AI.Projects.Agents;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using OpenAI.Responses;
|
||||
|
||||
#pragma warning disable OPENAI001 // Experimental API
|
||||
#pragma warning disable AAIP001 // AgentToolboxes is experimental
|
||||
#pragma warning disable CS8321 // Local functions may be commented-out alternatives
|
||||
|
||||
// Replace with your own Foundry toolbox name.
|
||||
const string ToolboxName = "research_toolbox";
|
||||
// Used only by CombineToolboxes — swap in a second toolbox you own.
|
||||
const string SecondToolboxName = "analysis_toolbox";
|
||||
// Replace with any question that exercises the tools configured in your toolbox.
|
||||
const string Query = "Introduce yourself and briefly describe the tools you can use to help me.";
|
||||
|
||||
string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT")
|
||||
?? throw new InvalidOperationException("Set FOUNDRY_PROJECT_ENDPOINT to your Foundry project endpoint.");
|
||||
string model = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini";
|
||||
|
||||
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
|
||||
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
|
||||
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
|
||||
var projectClient = new AIProjectClient(new Uri(endpoint), new DefaultAzureCredential());
|
||||
|
||||
await Main(projectClient, model, endpoint);
|
||||
// await CombineToolboxes(projectClient, model, endpoint);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main: single toolbox
|
||||
// ---------------------------------------------------------------------------
|
||||
static async Task Main(AIProjectClient projectClient, string model, string endpoint)
|
||||
{
|
||||
Console.WriteLine("=== Foundry Toolbox Server-Side Tools Example ===");
|
||||
|
||||
// Comment out if the toolbox already exists in your Foundry project.
|
||||
await CreateSampleToolboxAsync(ToolboxName, endpoint);
|
||||
|
||||
// Omit the version to resolve the toolbox's current default version at runtime.
|
||||
var tools = await projectClient.GetToolboxToolsAsync(ToolboxName);
|
||||
|
||||
AIAgent agent = projectClient
|
||||
.AsAIAgent(
|
||||
model: model,
|
||||
instructions: "You are a research assistant. Use the available tools to answer questions.",
|
||||
tools: tools.ToList());
|
||||
|
||||
Console.WriteLine($"User: {Query}");
|
||||
Console.WriteLine($"Result: {await agent.RunAsync(Query)}\n");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Alternative: combine tools from multiple toolboxes
|
||||
// ---------------------------------------------------------------------------
|
||||
static async Task CombineToolboxes(AIProjectClient projectClient, string model, string endpoint)
|
||||
{
|
||||
Console.WriteLine("=== Combine Toolboxes Example ===");
|
||||
|
||||
// Comment out if the toolboxes already exist in your Foundry project.
|
||||
await CreateSampleToolboxAsync(ToolboxName, endpoint);
|
||||
await CreateSampleToolboxAsync(SecondToolboxName, endpoint);
|
||||
|
||||
var toolboxA = await projectClient.GetToolboxToolsAsync(ToolboxName);
|
||||
var toolboxB = await projectClient.GetToolboxToolsAsync(SecondToolboxName);
|
||||
|
||||
var allTools = toolboxA.Concat(toolboxB).ToList();
|
||||
|
||||
AIAgent agent = projectClient
|
||||
.AsAIAgent(
|
||||
model: model,
|
||||
instructions: "You are a research assistant. Use all available tools to answer questions.",
|
||||
tools: allTools);
|
||||
|
||||
Console.WriteLine($"User: {Query}");
|
||||
Console.WriteLine($"Combined-toolbox result: {await agent.RunAsync(Query)}\n");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: create (or replace) a sample toolbox so the sample works out-of-the-box
|
||||
// ---------------------------------------------------------------------------
|
||||
static async Task CreateSampleToolboxAsync(string name, string endpoint)
|
||||
{
|
||||
// Toolboxes are normally configured in the Foundry portal or a deployment
|
||||
// script, not the application itself. This helper exists so the sample can
|
||||
// be run end-to-end without first setting a toolbox up by hand.
|
||||
|
||||
// The Foundry-Features header is currently required for toolbox CRUD operations.
|
||||
var options = new AgentAdministrationClientOptions();
|
||||
options.AddPolicy(new FoundryFeaturesPolicy("Toolboxes=V1Preview"), PipelinePosition.PerCall);
|
||||
var adminClient = new AgentAdministrationClient(
|
||||
new Uri(endpoint),
|
||||
new DefaultAzureCredential(),
|
||||
options);
|
||||
var toolboxClient = adminClient.GetAgentToolboxes();
|
||||
|
||||
// Delete existing toolbox if present (ignore 404).
|
||||
try
|
||||
{
|
||||
await toolboxClient.DeleteToolboxAsync(name);
|
||||
Console.WriteLine($"Deleted existing toolbox '{name}'");
|
||||
}
|
||||
catch (ClientResultException ex) when (ex.Status == 404)
|
||||
{
|
||||
// Toolbox does not exist — nothing to delete.
|
||||
}
|
||||
|
||||
// Create a fresh version with a single MCP tool.
|
||||
ProjectsAgentTool mcpTool = ProjectsAgentTool.AsProjectTool(ResponseTool.CreateMcpTool(
|
||||
serverLabel: "api-specs",
|
||||
serverUri: new Uri("https://gitmcp.io/Azure/azure-rest-api-specs"),
|
||||
toolCallApprovalPolicy: new McpToolCallApprovalPolicy(GlobalMcpToolCallApprovalPolicy.NeverRequireApproval)));
|
||||
|
||||
var created = (await toolboxClient.CreateToolboxVersionAsync(
|
||||
name: name,
|
||||
tools: [mcpTool],
|
||||
description: "Sample toolbox with an MCP tool — created by Agent_Step25 sample.")).Value;
|
||||
|
||||
Console.WriteLine($"Created toolbox '{created.Name}' v{created.Version} ({created.Tools.Count} tool(s))");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pipeline policy that adds the Foundry-Features header for toolbox CRUD
|
||||
// ---------------------------------------------------------------------------
|
||||
internal sealed class FoundryFeaturesPolicy(string feature) : PipelinePolicy
|
||||
{
|
||||
private const string FeatureHeader = "Foundry-Features";
|
||||
|
||||
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
message.Request.Headers.Add(FeatureHeader, feature);
|
||||
ProcessNext(message, pipeline, currentIndex);
|
||||
}
|
||||
|
||||
public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
message.Request.Headers.Add(FeatureHeader, feature);
|
||||
return ProcessNextAsync(message, pipeline, currentIndex);
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
# Agent_Step25_ToolboxServerSideTools
|
||||
|
||||
This sample demonstrates loading a named Foundry toolbox and passing its tools as
|
||||
**server-side tools** when creating an agent via `AsAIAgent()`.
|
||||
|
||||
When tools from a toolbox are passed this way, they are sent as tool definitions in
|
||||
the Responses API request. The Foundry platform handles tool execution — the agent
|
||||
process does not invoke tools locally.
|
||||
|
||||
This is the dotnet equivalent of the Python sample:
|
||||
`python/samples/02-agents/providers/foundry/foundry_chat_client_with_toolbox.py`
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A Microsoft Foundry project
|
||||
- `AZURE_AI_PROJECT_ENDPOINT` environment variable set to your Foundry project endpoint
|
||||
- `AZURE_AI_MODEL_DEPLOYMENT_NAME` environment variable set (defaults to `gpt-5.4-mini`)
|
||||
|
||||
The sample recreates the toolbox on each run, replacing any existing toolbox with
|
||||
the same name. Comment out the `CreateSampleToolboxAsync` call if you want to keep
|
||||
an existing toolbox unchanged.
|
||||
|
||||
## How it works
|
||||
|
||||
1. `projectClient.GetToolboxVersionAsync(name)` fetches the toolbox definition from the
|
||||
Foundry project API (resolving the default version if none is specified)
|
||||
2. `ToolboxVersion.ToAITools()` converts each tool definition to an `AITool` instance
|
||||
3. The tools are passed to `AsAIAgent(tools: ...)` which includes them in the Responses
|
||||
API request as server-side tool definitions
|
||||
|
||||
For a one-liner, use `projectClient.GetToolboxToolsAsync(name)` to fetch and convert in one call.
|
||||
|
||||
## Sample flows
|
||||
|
||||
| Flow | Description |
|
||||
|------|-------------|
|
||||
| `Main` (default) | Loads a single toolbox and runs an agent with its tools |
|
||||
| `CombineToolboxes` | Loads two toolboxes and merges their tools into one agent |
|
||||
|
||||
Uncomment the desired flow in the top-level statements to try each one.
|
||||
|
||||
## Running the sample
|
||||
|
||||
```bash
|
||||
dotnet run
|
||||
```
|
||||
+47
@@ -65,6 +65,53 @@ Workflow orchestration started for CancelOrder. Orchestration runId: abc123def45
|
||||
>
|
||||
> If not provided, a unique run ID is auto-generated.
|
||||
|
||||
### Wait for the Workflow Result
|
||||
|
||||
By default, the HTTP endpoint returns `202 Accepted` immediately with the run ID. If you want to wait for the workflow to complete and get the result in the response, add the `x-ms-wait-for-response: true` header:
|
||||
|
||||
Bash (Linux/macOS/WSL):
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:7071/api/workflows/CancelOrder/run \
|
||||
-H "Content-Type: text/plain" \
|
||||
-H "x-ms-wait-for-response: true" \
|
||||
-d "12345"
|
||||
```
|
||||
|
||||
PowerShell:
|
||||
|
||||
```powershell
|
||||
Invoke-RestMethod -Method Post `
|
||||
-Uri http://localhost:7071/api/workflows/CancelOrder/run `
|
||||
-ContentType text/plain `
|
||||
-Headers @{ "x-ms-wait-for-response" = "true" } `
|
||||
-Body "12345"
|
||||
```
|
||||
|
||||
The response will contain the workflow result as plain text (200 OK):
|
||||
|
||||
```text
|
||||
Cancellation email sent for order 12345 to jerry@example.com.
|
||||
```
|
||||
|
||||
To get the result as JSON, also include the `Accept: application/json` header:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:7071/api/workflows/CancelOrder/run \
|
||||
-H "Content-Type: text/plain" \
|
||||
-H "x-ms-wait-for-response: true" \
|
||||
-H "Accept: application/json" \
|
||||
-d "12345"
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"runId": "abc123def456",
|
||||
"workflowStatus": "Completed",
|
||||
"result": "Cancellation email sent for order 12345 to jerry@example.com."
|
||||
}
|
||||
```
|
||||
|
||||
In the function app logs, you will see the sequential execution of each executor:
|
||||
|
||||
```text
|
||||
|
||||
+22
@@ -7,6 +7,21 @@ Content-Type: text/plain
|
||||
|
||||
12345
|
||||
|
||||
### Cancel an order and wait for the result
|
||||
POST {{authority}}/api/workflows/CancelOrder/run
|
||||
Content-Type: text/plain
|
||||
x-ms-wait-for-response: true
|
||||
|
||||
12345
|
||||
|
||||
### Cancel an order and wait for the result (JSON response)
|
||||
POST {{authority}}/api/workflows/CancelOrder/run
|
||||
Content-Type: text/plain
|
||||
Accept: application/json
|
||||
x-ms-wait-for-response: true
|
||||
|
||||
12345
|
||||
|
||||
### Cancel an order with a custom run ID
|
||||
POST {{authority}}/api/workflows/CancelOrder/run?runId=my-custom-id-123
|
||||
Content-Type: text/plain
|
||||
@@ -19,6 +34,13 @@ Content-Type: text/plain
|
||||
|
||||
12345
|
||||
|
||||
### Get order status and wait for the result
|
||||
POST {{authority}}/api/workflows/OrderStatus/run
|
||||
Content-Type: text/plain
|
||||
x-ms-wait-for-response: true
|
||||
|
||||
12345
|
||||
|
||||
### Batch cancel orders with a complex JSON input
|
||||
POST {{authority}}/api/workflows/BatchCancelOrders/run
|
||||
Content-Type: application/json
|
||||
|
||||
+2
@@ -13,6 +13,8 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.AgentServer.Invocations" />
|
||||
<PackageReference Include="DotNetEnv" />
|
||||
<PackageReference Include="OpenTelemetry.Api" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- For contributors: uses ProjectReference to build against local source -->
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
#pragma warning disable OPENAI001
|
||||
#pragma warning disable AAIP001 // AgentToolboxes is experimental in Azure.AI.Projects.Agents
|
||||
|
||||
namespace Azure.AI.Projects;
|
||||
|
||||
/// <summary>
|
||||
/// Provides extension methods on <see cref="AIProjectClient"/> for fetching
|
||||
/// Foundry toolbox definitions as server-side tools.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// These extensions mirror Python's <c>FoundryChatClient.get_toolbox()</c> pattern,
|
||||
/// allowing a single call on the project client to retrieve tools ready for use
|
||||
/// with <c>AsAIAgent(model, instructions, tools: ...)</c>.
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
|
||||
public static class AIProjectClientToolboxExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Fetches a toolbox from the Foundry project and returns its tools as <see cref="AITool"/> instances
|
||||
/// ready for use as server-side tools in the Responses API.
|
||||
/// </summary>
|
||||
/// <param name="projectClient">The <see cref="AIProjectClient"/> to use. Cannot be <see langword="null"/>.</param>
|
||||
/// <param name="name">The name of the toolbox to fetch.</param>
|
||||
/// <param name="version">
|
||||
/// The specific toolbox version to fetch. When <see langword="null"/>, the toolbox's
|
||||
/// default version is resolved automatically.
|
||||
/// </param>
|
||||
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
|
||||
/// <returns>A read-only list of <see cref="AITool"/> instances from the toolbox.</returns>
|
||||
/// <exception cref="System.ArgumentNullException">
|
||||
/// Thrown when <paramref name="projectClient"/> or <paramref name="name"/> is <see langword="null"/>.
|
||||
/// </exception>
|
||||
public static async Task<IReadOnlyList<AITool>> GetToolboxToolsAsync(
|
||||
this AIProjectClient projectClient,
|
||||
string name,
|
||||
string? version = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Throw.IfNull(projectClient);
|
||||
Throw.IfNullOrWhitespace(name);
|
||||
|
||||
var toolboxClient = projectClient.AgentAdministrationClient.GetAgentToolboxes();
|
||||
var toolboxVersion = await FoundryToolbox.GetToolboxVersionCoreAsync(toolboxClient, name, version, cancellationToken).ConfigureAwait(false);
|
||||
return toolboxVersion.ToAITools();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.ClientModel;
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.Projects.Agents;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
using OpenAI.Responses;
|
||||
|
||||
#pragma warning disable OPENAI001
|
||||
#pragma warning disable AAIP001 // AgentToolboxes is experimental in Azure.AI.Projects.Agents
|
||||
#pragma warning disable IL2026 // ModelReaderWriter.Read<ResponseTool> uses reflection; suppressed for Azure SDK model types.
|
||||
#pragma warning disable IL3050 // ModelReaderWriter.Read<ResponseTool> requires dynamic code; suppressed for Azure SDK model types.
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Provides methods for fetching Foundry toolbox definitions and converting their tools
|
||||
/// to <see cref="AITool"/> instances for use as server-side tools in the Responses API.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// When tools from a toolbox are passed to a Foundry agent (e.g. via <c>AsAIAgent(model, instructions, tools: ...)</c>),
|
||||
/// they are sent as server-side tool definitions in the Responses API request. The Foundry platform
|
||||
/// handles tool execution — the agent process does not invoke tools locally.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This is the dotnet equivalent of Python's <c>FoundryChatClient.get_toolbox()</c> pattern.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
|
||||
public static class FoundryToolbox
|
||||
{
|
||||
/// <summary>
|
||||
/// Fetches a toolbox version from the Foundry project and returns the raw SDK <see cref="ToolboxVersion"/>.
|
||||
/// </summary>
|
||||
/// <param name="projectEndpoint">The Foundry project endpoint URI.</param>
|
||||
/// <param name="credential">The authentication credential used to access the Foundry project.</param>
|
||||
/// <param name="name">The name of the toolbox to fetch.</param>
|
||||
/// <param name="version">
|
||||
/// The specific toolbox version to fetch. When <see langword="null"/>, the toolbox's
|
||||
/// default version is resolved automatically (requires an additional API call).
|
||||
/// </param>
|
||||
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
|
||||
/// <returns>The <see cref="ToolboxVersion"/> containing tool definitions.</returns>
|
||||
/// <exception cref="ArgumentNullException">
|
||||
/// Thrown when <paramref name="projectEndpoint"/>, <paramref name="credential"/>, or <paramref name="name"/> is <see langword="null"/>.
|
||||
/// </exception>
|
||||
/// <exception cref="ClientResultException">Thrown when the Foundry project API returns an error.</exception>
|
||||
public static async Task<ToolboxVersion> GetToolboxVersionAsync(
|
||||
Uri projectEndpoint,
|
||||
AuthenticationTokenProvider credential,
|
||||
string name,
|
||||
string? version = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Throw.IfNull(projectEndpoint);
|
||||
Throw.IfNull(credential);
|
||||
Throw.IfNullOrWhitespace(name);
|
||||
|
||||
var toolboxClient = CreateToolboxClient(projectEndpoint, credential);
|
||||
return await GetToolboxVersionCoreAsync(toolboxClient, name, version, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches a toolbox from the Foundry project and returns its tools as <see cref="AITool"/> instances
|
||||
/// ready for use as server-side tools in the Responses API.
|
||||
/// </summary>
|
||||
/// <param name="projectEndpoint">The Foundry project endpoint URI.</param>
|
||||
/// <param name="credential">The authentication credential used to access the Foundry project.</param>
|
||||
/// <param name="name">The name of the toolbox to fetch.</param>
|
||||
/// <param name="version">
|
||||
/// The specific toolbox version to fetch. When <see langword="null"/>, the toolbox's
|
||||
/// default version is resolved automatically.
|
||||
/// </param>
|
||||
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
|
||||
/// <returns>A read-only list of <see cref="AITool"/> instances from the toolbox.</returns>
|
||||
/// <exception cref="ArgumentNullException">
|
||||
/// Thrown when <paramref name="projectEndpoint"/>, <paramref name="credential"/>, or <paramref name="name"/> is <see langword="null"/>.
|
||||
/// </exception>
|
||||
/// <exception cref="ClientResultException">Thrown when the Foundry project API returns an error.</exception>
|
||||
public static async Task<IReadOnlyList<AITool>> GetToolsAsync(
|
||||
Uri projectEndpoint,
|
||||
AuthenticationTokenProvider credential,
|
||||
string name,
|
||||
string? version = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var toolboxVersion = await GetToolboxVersionAsync(projectEndpoint, credential, name, version, cancellationToken).ConfigureAwait(false);
|
||||
return toolboxVersion.ToAITools();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts the tools in a <see cref="ToolboxVersion"/> to <see cref="AITool"/> instances
|
||||
/// suitable for use as server-side tools in the Responses API.
|
||||
/// </summary>
|
||||
/// <param name="toolboxVersion">The toolbox version whose tools to convert.</param>
|
||||
/// <returns>A read-only list of <see cref="AITool"/> instances.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="toolboxVersion"/> is <see langword="null"/>.</exception>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Each <see cref="ProjectsAgentTool"/> in the toolbox is cast to <see cref="ResponseTool"/>
|
||||
/// and converted via <c>AsAITool()</c>. Non-function hosted tools (MCP, web_search,
|
||||
/// code_interpreter, etc.) are included as server-side tool definitions — the Foundry
|
||||
/// platform handles their execution.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Non-function tools are sanitized to remove decoration fields (<c>name</c>, <c>description</c>)
|
||||
/// that the toolbox API returns but the Responses API rejects.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static IReadOnlyList<AITool> ToAITools(this ToolboxVersion toolboxVersion)
|
||||
{
|
||||
Throw.IfNull(toolboxVersion);
|
||||
|
||||
if (toolboxVersion.Tools?.Any() != true)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return toolboxVersion.Tools
|
||||
.Select(SanitizeAndConvert)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
#region Internal helpers (visible to unit tests via InternalsVisibleTo)
|
||||
|
||||
/// <summary>
|
||||
/// Sanitizes a <see cref="ProjectsAgentTool"/> by removing decoration fields that the
|
||||
/// toolbox API returns but the Responses API rejects, then converts to <see cref="AITool"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The Azure AI Projects toolbox API may return <c>name</c> and <c>description</c> on
|
||||
/// hosted tool objects (MCP, code_interpreter, file_search, etc.). The Responses API
|
||||
/// rejects at least <c>name</c> with "Unknown parameter: 'tools[0].name'". We strip
|
||||
/// these decoration fields for non-function tools. Function tools keep them since
|
||||
/// <c>name</c> and <c>description</c> are expected parts of the function schema.
|
||||
/// </remarks>
|
||||
internal static AITool SanitizeAndConvert(ProjectsAgentTool tool)
|
||||
{
|
||||
var toolJson = ModelReaderWriter.Write(tool, new ModelReaderWriterOptions("J"));
|
||||
var node = JsonNode.Parse(toolJson.ToString());
|
||||
if (node is not JsonObject obj)
|
||||
{
|
||||
return ((ResponseTool)tool).AsAITool();
|
||||
}
|
||||
|
||||
var toolType = obj["type"]?.GetValue<string>();
|
||||
|
||||
// Function tools need name/description — don't strip
|
||||
if (toolType is "function" or "custom")
|
||||
{
|
||||
return ((ResponseTool)tool).AsAITool();
|
||||
}
|
||||
|
||||
// Strip decoration fields that the Responses API rejects
|
||||
bool modified = false;
|
||||
modified |= obj.Remove("name");
|
||||
modified |= obj.Remove("description");
|
||||
|
||||
if (!modified)
|
||||
{
|
||||
return ((ResponseTool)tool).AsAITool();
|
||||
}
|
||||
|
||||
var sanitizedJson = obj.ToJsonString();
|
||||
var sanitizedTool = ModelReaderWriter.Read<ResponseTool>(BinaryData.FromString(sanitizedJson))!;
|
||||
return sanitizedTool.AsAITool();
|
||||
}
|
||||
|
||||
internal static async Task<ToolboxVersion> GetToolboxVersionAsync(
|
||||
Uri projectEndpoint,
|
||||
AuthenticationTokenProvider credential,
|
||||
string name,
|
||||
string? version,
|
||||
AgentAdministrationClientOptions? clientOptions,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Throw.IfNull(projectEndpoint);
|
||||
Throw.IfNull(credential);
|
||||
Throw.IfNullOrWhitespace(name);
|
||||
|
||||
var toolboxClient = CreateToolboxClient(projectEndpoint, credential, clientOptions);
|
||||
return await GetToolboxVersionCoreAsync(toolboxClient, name, version, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
internal static AgentToolboxes CreateToolboxClient(
|
||||
Uri projectEndpoint,
|
||||
AuthenticationTokenProvider credential,
|
||||
AgentAdministrationClientOptions? clientOptions = null)
|
||||
{
|
||||
clientOptions ??= new AgentAdministrationClientOptions();
|
||||
var adminClient = new AgentAdministrationClient(projectEndpoint, credential, clientOptions);
|
||||
return adminClient.GetAgentToolboxes();
|
||||
}
|
||||
|
||||
internal static async Task<ToolboxVersion> GetToolboxVersionCoreAsync(
|
||||
AgentToolboxes toolboxClient,
|
||||
string name,
|
||||
string? version,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (version is null)
|
||||
{
|
||||
var record = await toolboxClient.GetToolboxAsync(name, cancellationToken).ConfigureAwait(false);
|
||||
version = record.Value.DefaultVersion
|
||||
?? throw new InvalidOperationException($"Toolbox '{name}' does not have a default version. Specify an explicit version.");
|
||||
}
|
||||
|
||||
var result = await toolboxClient.GetToolboxVersionAsync(name, version, cancellationToken).ConfigureAwait(false);
|
||||
return result.Value;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -237,7 +237,7 @@ internal static class InputConverter
|
||||
{
|
||||
OutputItemMessage msg => ConvertOutputItemMessageToChat(msg),
|
||||
OutputItemFunctionToolCall funcCall => ConvertOutputItemFunctionCall(funcCall),
|
||||
FunctionToolCallOutputResource funcOutput => ConvertFunctionToolCallOutputResource(funcOutput),
|
||||
OutputItemFunctionToolCallOutput funcOutput => ConvertFunctionToolCallOutput(funcOutput),
|
||||
OutputItemReasoningItem => null,
|
||||
_ => null
|
||||
};
|
||||
@@ -332,7 +332,7 @@ internal static class InputConverter
|
||||
[new FunctionCallContent(funcCall.CallId, funcCall.Name, arguments)]);
|
||||
}
|
||||
|
||||
private static ChatMessage ConvertFunctionToolCallOutputResource(FunctionToolCallOutputResource funcOutput)
|
||||
private static ChatMessage ConvertFunctionToolCallOutput(OutputItemFunctionToolCallOutput funcOutput)
|
||||
{
|
||||
return new ChatMessage(
|
||||
ChatRole.Tool,
|
||||
|
||||
+1
@@ -34,6 +34,7 @@
|
||||
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="ModelContextProtocol" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -251,16 +251,25 @@ internal static class OutputConverter
|
||||
var outputTokens = details.OutputTokenCount ?? 0;
|
||||
var totalTokens = details.TotalTokenCount ?? 0;
|
||||
|
||||
var cachedTokens = details.AdditionalCounts?.TryGetValue("InputTokenDetails.CachedTokenCount", out var cached) ?? false
|
||||
? cached : 0;
|
||||
var reasoningTokens = details.AdditionalCounts?.TryGetValue("OutputTokenDetails.ReasoningTokenCount", out var reasoning) ?? false
|
||||
? reasoning : 0;
|
||||
|
||||
if (existing is not null)
|
||||
{
|
||||
inputTokens += existing.InputTokens;
|
||||
outputTokens += existing.OutputTokens;
|
||||
totalTokens += existing.TotalTokens;
|
||||
cachedTokens += existing.InputTokensDetails?.CachedTokens ?? 0;
|
||||
reasoningTokens += existing.OutputTokensDetails?.ReasoningTokens ?? 0;
|
||||
}
|
||||
|
||||
return AzureAIAgentServerResponsesModelFactory.ResponseUsage(
|
||||
return new ResponseUsage(
|
||||
inputTokens: inputTokens,
|
||||
inputTokensDetails: new ResponseUsageInputTokensDetails(cachedTokens),
|
||||
outputTokens: outputTokens,
|
||||
outputTokensDetails: new ResponseUsageOutputTokensDetails(reasoningTokens),
|
||||
totalTokens: totalTokens);
|
||||
}
|
||||
|
||||
|
||||
@@ -42,11 +42,19 @@ internal sealed class A2AAgentHandler : IAgentHandler
|
||||
/// <inheritdoc/>
|
||||
public Task ExecuteAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken)
|
||||
{
|
||||
// Handle task updates
|
||||
if (context.IsContinuation)
|
||||
{
|
||||
return this.HandleTaskUpdateAsync(context, eventQueue, cancellationToken);
|
||||
}
|
||||
|
||||
// Handle messages received via streaming endpoint
|
||||
if (context.StreamingResponse)
|
||||
{
|
||||
return this.HandleNewMessageStreamingAsync(context, eventQueue, cancellationToken);
|
||||
}
|
||||
|
||||
// Handle new messages received via non-streaming endpoint
|
||||
return this.HandleNewMessageAsync(context, eventQueue, cancellationToken);
|
||||
}
|
||||
|
||||
@@ -80,13 +88,19 @@ internal sealed class A2AAgentHandler : IAgentHandler
|
||||
? new AgentRunOptions { AllowBackgroundResponses = allowBackgroundResponses }
|
||||
: new AgentRunOptions { AllowBackgroundResponses = allowBackgroundResponses, AdditionalProperties = context.Metadata.ToAdditionalProperties() };
|
||||
|
||||
var response = await this._hostAgent.RunAsync(
|
||||
chatMessages,
|
||||
session: session,
|
||||
options: options,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await this._hostAgent.SaveSessionAsync(contextId, session, cancellationToken).ConfigureAwait(false);
|
||||
AgentResponse response;
|
||||
try
|
||||
{
|
||||
response = await this._hostAgent.RunAsync(
|
||||
chatMessages,
|
||||
session: session,
|
||||
options: options,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await this._hostAgent.SaveSessionAsync(contextId, session, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (response.ContinuationToken is null)
|
||||
{
|
||||
@@ -108,6 +122,39 @@ internal sealed class A2AAgentHandler : IAgentHandler
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleNewMessageStreamingAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken)
|
||||
{
|
||||
var contextId = context.ContextId ?? Guid.NewGuid().ToString("N");
|
||||
var session = await this._hostAgent.GetOrCreateSessionAsync(contextId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// AIAgent does not support resuming from arbitrary prior tasks.
|
||||
// Throw explicitly so the client gets a clear error rather than a response
|
||||
// that silently ignores the referenced task context.
|
||||
if (context.Message?.ReferenceTaskIds is { Count: > 0 })
|
||||
{
|
||||
throw new NotSupportedException("ReferenceTaskIds is not supported. AIAgent cannot resume from arbitrary prior task context.");
|
||||
}
|
||||
|
||||
List<ChatMessage> chatMessages = context.Message is not null ? [context.Message.ToChatMessage()] : [];
|
||||
|
||||
var options = context.Metadata is { Count: > 0 }
|
||||
? new AgentRunOptions { AdditionalProperties = context.Metadata.ToAdditionalProperties() }
|
||||
: null;
|
||||
|
||||
try
|
||||
{
|
||||
await foreach (var update in this._hostAgent.RunStreamingAsync(chatMessages, session, options, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
var message = CreateMessageFromUpdate(contextId, update);
|
||||
await eventQueue.EnqueueMessageAsync(message, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
await this._hostAgent.SaveSessionAsync(contextId, session, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleTaskUpdateAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken)
|
||||
{
|
||||
var contextId = context.ContextId ?? Guid.NewGuid().ToString("N");
|
||||
@@ -141,8 +188,10 @@ internal sealed class A2AAgentHandler : IAgentHandler
|
||||
await failUpdater.FailAsync(message: null, CancellationToken.None).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
|
||||
await this._hostAgent.SaveSessionAsync(contextId, session, cancellationToken).ConfigureAwait(false);
|
||||
finally
|
||||
{
|
||||
await this._hostAgent.SaveSessionAsync(contextId, session, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (response.ContinuationToken is null)
|
||||
{
|
||||
@@ -174,6 +223,16 @@ internal sealed class A2AAgentHandler : IAgentHandler
|
||||
Metadata = response.AdditionalProperties?.ToA2AMetadata()
|
||||
};
|
||||
|
||||
private static Message CreateMessageFromUpdate(string contextId, AgentResponseUpdate update) =>
|
||||
new()
|
||||
{
|
||||
MessageId = update.ResponseId ?? Guid.NewGuid().ToString("N"),
|
||||
ContextId = contextId,
|
||||
Role = Role.Agent,
|
||||
Parts = update.ToParts(),
|
||||
Metadata = update.AdditionalProperties?.ToA2AMetadata()
|
||||
};
|
||||
|
||||
private static List<ChatMessage> ExtractChatMessagesFromTaskHistory(AgentTask? agentTask)
|
||||
{
|
||||
if (agentTask?.History is not { Count: > 0 })
|
||||
|
||||
@@ -8,6 +8,26 @@ namespace Microsoft.Agents.AI.Hosting.A2A.Converters;
|
||||
|
||||
internal static class MessageConverter
|
||||
{
|
||||
public static List<Part> ToParts(this AgentResponseUpdate update)
|
||||
{
|
||||
if (update is null || update.Contents is not { Count: > 0 })
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var parts = new List<Part>();
|
||||
foreach (var content in update.Contents)
|
||||
{
|
||||
var part = content.ToPart();
|
||||
if (part is not null)
|
||||
{
|
||||
parts.Add(part);
|
||||
}
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
public static List<Part> ToParts(this IList<ChatMessage> chatMessages)
|
||||
{
|
||||
if (chatMessages is null || chatMessages.Count == 0)
|
||||
|
||||
@@ -21,6 +21,8 @@ internal static class BuiltInFunctions
|
||||
internal const string HttpPrefix = "http-";
|
||||
internal const string McpToolPrefix = "mcptool-";
|
||||
|
||||
private const string WaitForResponseHeaderName = "x-ms-wait-for-response";
|
||||
|
||||
internal static readonly string RunAgentHttpFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(RunAgentHttpAsync)}";
|
||||
internal static readonly string RunAgentEntityFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(InvokeAgentAsync)}";
|
||||
internal static readonly string RunAgentMcpToolFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(RunMcpToolAsync)}";
|
||||
@@ -62,6 +64,11 @@ internal static class BuiltInFunctions
|
||||
StartOrchestrationOptions? options = instanceId is not null ? new StartOrchestrationOptions(instanceId) : null;
|
||||
string resolvedInstanceId = await client.ScheduleNewOrchestrationInstanceAsync(orchestrationFunctionName, orchestrationInput, options);
|
||||
|
||||
if (ShouldWaitForResponse(req, defaultValue: false))
|
||||
{
|
||||
return await WaitForWorkflowCompletionAsync(req, client, context, resolvedInstanceId);
|
||||
}
|
||||
|
||||
HttpResponseData response = req.CreateResponse(HttpStatusCode.Accepted);
|
||||
await response.WriteStringAsync($"Workflow orchestration started for {workflowName}. Orchestration runId: {resolvedInstanceId}");
|
||||
return response;
|
||||
@@ -304,15 +311,7 @@ internal static class BuiltInFunctions
|
||||
}
|
||||
|
||||
// Check if we should wait for response (default is true)
|
||||
bool waitForResponse = true;
|
||||
if (req.Headers.TryGetValues("x-ms-wait-for-response", out IEnumerable<string>? waitForResponseValues))
|
||||
{
|
||||
string? waitForResponseValue = waitForResponseValues.FirstOrDefault();
|
||||
if (!string.IsNullOrEmpty(waitForResponseValue) && bool.TryParse(waitForResponseValue, out bool parsedValue))
|
||||
{
|
||||
waitForResponse = parsedValue;
|
||||
}
|
||||
}
|
||||
bool waitForResponse = ShouldWaitForResponse(req, defaultValue: true);
|
||||
|
||||
AIAgent agentProxy = client.AsDurableAgentProxy(context, agentName);
|
||||
|
||||
@@ -428,6 +427,95 @@ internal static class BuiltInFunctions
|
||||
return metadata.ReadOutputAs<DurableWorkflowResult>()?.Result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits for a workflow orchestration to complete and returns an appropriate HTTP response.
|
||||
/// </summary>
|
||||
private static async Task<HttpResponseData> WaitForWorkflowCompletionAsync(
|
||||
HttpRequestData req,
|
||||
DurableTaskClient client,
|
||||
FunctionContext context,
|
||||
string instanceId)
|
||||
{
|
||||
bool acceptsJson = AcceptsJson(req);
|
||||
|
||||
OrchestrationMetadata? metadata = await client.WaitForInstanceCompletionAsync(
|
||||
instanceId,
|
||||
getInputsAndOutputs: true,
|
||||
cancellation: context.CancellationToken);
|
||||
|
||||
if (metadata is null)
|
||||
{
|
||||
return await CreateErrorResponseAsync(req, context, HttpStatusCode.NotFound,
|
||||
$"No workflow orchestration with ID '{instanceId}' was found.", acceptsJson);
|
||||
}
|
||||
|
||||
if (metadata.RuntimeStatus is OrchestrationRuntimeStatus.Failed)
|
||||
{
|
||||
string errorMessage = metadata.FailureDetails?.ErrorMessage ?? "Unknown error";
|
||||
HttpResponseData failedResponse = req.CreateResponse(HttpStatusCode.OK);
|
||||
|
||||
if (acceptsJson)
|
||||
{
|
||||
await failedResponse.WriteAsJsonAsync(
|
||||
new WorkflowRunResponse(instanceId, metadata.RuntimeStatus.ToString(), Result: null, Error: errorMessage),
|
||||
context.CancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
failedResponse.Headers.Add("Content-Type", "text/plain");
|
||||
await failedResponse.WriteStringAsync(errorMessage, context.CancellationToken);
|
||||
}
|
||||
|
||||
return failedResponse;
|
||||
}
|
||||
|
||||
if (metadata.RuntimeStatus is not OrchestrationRuntimeStatus.Completed)
|
||||
{
|
||||
return await CreateErrorResponseAsync(req, context, HttpStatusCode.InternalServerError,
|
||||
$"Workflow orchestration '{instanceId}' ended with unexpected status '{metadata.RuntimeStatus}'.", acceptsJson);
|
||||
}
|
||||
|
||||
string? result = metadata.ReadOutputAs<DurableWorkflowResult>()?.Result;
|
||||
|
||||
HttpResponseData response = req.CreateResponse(HttpStatusCode.OK);
|
||||
|
||||
if (acceptsJson)
|
||||
{
|
||||
JsonElement? resultElement = null;
|
||||
if (!string.IsNullOrEmpty(result))
|
||||
{
|
||||
try
|
||||
{
|
||||
using JsonDocument doc = JsonDocument.Parse(result);
|
||||
resultElement = doc.RootElement.Clone();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Result is a plain string (not valid JSON) — serialize it as a JSON string element.
|
||||
var buffer = new System.Buffers.ArrayBufferWriter<byte>();
|
||||
using (var writer = new Utf8JsonWriter(buffer))
|
||||
{
|
||||
writer.WriteStringValue(result);
|
||||
}
|
||||
|
||||
using JsonDocument fallbackDoc = JsonDocument.Parse(buffer.WrittenMemory);
|
||||
resultElement = fallbackDoc.RootElement.Clone();
|
||||
}
|
||||
}
|
||||
|
||||
await response.WriteAsJsonAsync(
|
||||
new WorkflowRunResponse(instanceId, metadata.RuntimeStatus.ToString(), resultElement),
|
||||
context.CancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
response.Headers.Add("Content-Type", "text/plain");
|
||||
await response.WriteStringAsync(result ?? string.Empty, context.CancellationToken);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an error response with the specified status code and error message.
|
||||
/// </summary>
|
||||
@@ -435,18 +523,18 @@ internal static class BuiltInFunctions
|
||||
/// <param name="context">The function context.</param>
|
||||
/// <param name="statusCode">The HTTP status code.</param>
|
||||
/// <param name="errorMessage">The error message.</param>
|
||||
/// <param name="acceptsJson">Optional pre-computed value indicating whether the client accepts JSON. When <see langword="null"/>, the value is determined from the request's <c>Accept</c> header.</param>
|
||||
/// <returns>The HTTP response data containing the error.</returns>
|
||||
private static async Task<HttpResponseData> CreateErrorResponseAsync(
|
||||
HttpRequestData req,
|
||||
FunctionContext context,
|
||||
HttpStatusCode statusCode,
|
||||
string errorMessage)
|
||||
string errorMessage,
|
||||
bool? acceptsJson = null)
|
||||
{
|
||||
HttpResponseData response = req.CreateResponse(statusCode);
|
||||
bool acceptsJson = req.Headers.TryGetValues("Accept", out IEnumerable<string>? acceptValues) &&
|
||||
acceptValues.Contains("application/json", StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (acceptsJson)
|
||||
if (acceptsJson ?? AcceptsJson(req))
|
||||
{
|
||||
ErrorResponse errorResponse = new((int)statusCode, errorMessage);
|
||||
await response.WriteAsJsonAsync(errorResponse, context.CancellationToken);
|
||||
@@ -479,10 +567,7 @@ internal static class BuiltInFunctions
|
||||
HttpResponseData response = req.CreateResponse(statusCode);
|
||||
response.Headers.Add("x-ms-thread-id", sessionId);
|
||||
|
||||
bool acceptsJson = req.Headers.TryGetValues("Accept", out IEnumerable<string>? acceptValues) &&
|
||||
acceptValues.Contains("application/json", StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (acceptsJson)
|
||||
if (AcceptsJson(req))
|
||||
{
|
||||
AgentRunSuccessResponse successResponse = new((int)statusCode, sessionId, agentResponse);
|
||||
await response.WriteAsJsonAsync(successResponse, context.CancellationToken);
|
||||
@@ -511,10 +596,7 @@ internal static class BuiltInFunctions
|
||||
HttpResponseData response = req.CreateResponse(HttpStatusCode.Accepted);
|
||||
response.Headers.Add("x-ms-thread-id", sessionId);
|
||||
|
||||
bool acceptsJson = req.Headers.TryGetValues("Accept", out IEnumerable<string>? acceptValues) &&
|
||||
acceptValues.Contains("application/json", StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (acceptsJson)
|
||||
if (AcceptsJson(req))
|
||||
{
|
||||
AgentRunAcceptedResponse acceptedResponse = new((int)HttpStatusCode.Accepted, sessionId);
|
||||
await response.WriteAsJsonAsync(acceptedResponse, context.CancellationToken);
|
||||
@@ -528,6 +610,34 @@ internal static class BuiltInFunctions
|
||||
return response;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns <see langword="true"/> when the caller has requested waiting for the workflow/agent to complete,
|
||||
/// as indicated by the <c>x-ms-wait-for-response</c> header. Falls back to <paramref name="defaultValue"/>
|
||||
/// when the header is absent or not a valid boolean.
|
||||
/// </summary>
|
||||
private static bool ShouldWaitForResponse(HttpRequestData req, bool defaultValue)
|
||||
{
|
||||
if (req.Headers.TryGetValues(WaitForResponseHeaderName, out IEnumerable<string>? values) &&
|
||||
bool.TryParse(values.FirstOrDefault(), out bool parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns <see langword="true"/> when the request accepts the <c>application/json</c> media type.
|
||||
/// </summary>
|
||||
private static bool AcceptsJson(HttpRequestData req)
|
||||
{
|
||||
return req.Headers.TryGetValues("Accept", out IEnumerable<string>? acceptValues) &&
|
||||
acceptValues
|
||||
.SelectMany(v => v.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
.Select(v => v.Split(';', 2)[0].Trim())
|
||||
.Contains("application/json", StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string GetAgentName(FunctionContext context)
|
||||
{
|
||||
// Check if the function name starts with the HttpPrefix
|
||||
@@ -591,6 +701,19 @@ internal static class BuiltInFunctions
|
||||
[property: JsonPropertyName("eventName")] string? EventName,
|
||||
[property: JsonPropertyName("response")] JsonElement Response);
|
||||
|
||||
/// <summary>
|
||||
/// Represents a workflow run response when waiting for completion.
|
||||
/// </summary>
|
||||
/// <param name="RunId">The orchestration run ID.</param>
|
||||
/// <param name="WorkflowStatus">The orchestration runtime status (e.g., "Completed", "Failed").</param>
|
||||
/// <param name="Result">The workflow result as a JSON element so POCOs serialize as nested objects rather than escaped strings.</param>
|
||||
/// <param name="Error">An optional error message when the workflow has failed.</param>
|
||||
private sealed record WorkflowRunResponse(
|
||||
[property: JsonPropertyName("runId")] string RunId,
|
||||
[property: JsonPropertyName("workflowStatus")] string WorkflowStatus,
|
||||
[property: JsonPropertyName("result")] JsonElement? Result,
|
||||
[property: JsonPropertyName("error"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Error = null);
|
||||
|
||||
/// <summary>
|
||||
/// A service provider that combines the original service provider with an additional DurableTaskClient instance.
|
||||
/// </summary>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- Support returning workflow results from HTTP trigger endpoint ([#5321](https://github.com/microsoft/agent-framework/pull/5321))
|
||||
- Added MCP tool trigger support for durable workflows ([#4768](https://github.com/microsoft/agent-framework/pull/4768))
|
||||
- Added Azure Functions hosting support for durable workflows ([#4436](https://github.com/microsoft/agent-framework/pull/4436))
|
||||
|
||||
|
||||
@@ -26,6 +26,12 @@ public sealed class DeclarativeWorkflowOptions(ResponseAgentProvider agentProvid
|
||||
/// </summary>
|
||||
public IMcpToolHandler? McpToolHandler { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the HTTP request handler for executing <c>HttpRequestAction</c> actions within workflows.
|
||||
/// If not set, HTTP request actions will fail with an appropriate error message.
|
||||
/// </summary>
|
||||
public IHttpRequestHandler? HttpRequestHandler { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Defines the configuration settings for the workflow.
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Declarative;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="IHttpRequestHandler"/> built on <see cref="HttpClient"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This handler supports per-request authentication via an optional <c>httpClientProvider</c> callback that
|
||||
/// returns a pre-configured <see cref="HttpClient"/> for a given request (e.g. authenticated, custom handler).
|
||||
/// When the provider returns <see langword="null"/>, or no provider is supplied, a shared internal <see cref="HttpClient"/>
|
||||
/// is used.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The handler applies the per-request <see cref="HttpRequestInfo.Timeout"/> using a linked <see cref="CancellationTokenSource"/>
|
||||
/// so it does not mutate <see cref="HttpClient.Timeout"/> on shared instances.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class DefaultHttpRequestHandler : IHttpRequestHandler, IAsyncDisposable
|
||||
{
|
||||
private readonly Func<HttpRequestInfo, CancellationToken, Task<HttpClient?>>? _httpClientProvider;
|
||||
private readonly Lazy<HttpClient> _ownedHttpClient;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DefaultHttpRequestHandler"/> class that uses an
|
||||
/// internally owned <see cref="HttpClient"/> for all requests. The internal client is disposed
|
||||
/// when <see cref="DisposeAsync"/> is called.
|
||||
/// </summary>
|
||||
public DefaultHttpRequestHandler()
|
||||
: this(httpClientProvider: null)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DefaultHttpRequestHandler"/> class that uses the
|
||||
/// supplied <see cref="HttpClient"/> for all requests.
|
||||
/// </summary>
|
||||
/// <param name="httpClient">
|
||||
/// The <see cref="HttpClient"/> to use for all requests. The caller retains ownership of this
|
||||
/// instance; it is not disposed by <see cref="DisposeAsync"/>.
|
||||
/// </param>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="httpClient"/> is <see langword="null"/>.</exception>
|
||||
public DefaultHttpRequestHandler(HttpClient httpClient)
|
||||
: this(CreateSingleClientProvider(httpClient))
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DefaultHttpRequestHandler"/> class that selects
|
||||
/// an <see cref="HttpClient"/> per request via a caller-supplied callback — for example, to route
|
||||
/// different URLs through differently authenticated clients.
|
||||
/// </summary>
|
||||
/// <param name="httpClientProvider">
|
||||
/// An optional callback invoked for each request. The callback receives the <see cref="HttpRequestInfo"/>
|
||||
/// and should return a pre-configured <see cref="HttpClient"/> (e.g. with authentication or a custom
|
||||
/// transport). Return <see langword="null"/> to fall back to the handler's shared internal
|
||||
/// <see cref="HttpClient"/>.
|
||||
/// </param>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Ownership</b>: the caller is solely responsible for the lifetime of clients returned by this
|
||||
/// callback. <see cref="DefaultHttpRequestHandler"/> will <b>not</b> dispose provider-returned
|
||||
/// clients; only the handler's internally owned fallback client is disposed by <see cref="DisposeAsync"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Reuse</b>: callers are expected to cache and reuse clients (for example, keyed by base URL or
|
||||
/// auth scope) across requests. Returning a newly allocated <see cref="HttpClient"/> on every
|
||||
/// invocation will leak sockets and handler resources.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public DefaultHttpRequestHandler(Func<HttpRequestInfo, CancellationToken, Task<HttpClient?>>? httpClientProvider)
|
||||
{
|
||||
this._httpClientProvider = httpClientProvider;
|
||||
this._ownedHttpClient = new Lazy<HttpClient>(() => new HttpClient(), LazyThreadSafetyMode.ExecutionAndPublication);
|
||||
}
|
||||
|
||||
private static Func<HttpRequestInfo, CancellationToken, Task<HttpClient?>> CreateSingleClientProvider(HttpClient httpClient)
|
||||
{
|
||||
if (httpClient is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(httpClient));
|
||||
}
|
||||
|
||||
return (_, _) => Task.FromResult<HttpClient?>(httpClient);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<HttpRequestResult> SendAsync(HttpRequestInfo request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(request));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Url))
|
||||
{
|
||||
throw new ArgumentException("Request URL must be provided.", nameof(request));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Method))
|
||||
{
|
||||
throw new ArgumentException("Request method must be provided.", nameof(request));
|
||||
}
|
||||
|
||||
HttpClient? providedClient = null;
|
||||
if (this._httpClientProvider is not null)
|
||||
{
|
||||
providedClient = await this._httpClientProvider(request, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
HttpClient client = providedClient ?? this._ownedHttpClient.Value;
|
||||
|
||||
using HttpRequestMessage httpRequest = BuildHttpRequestMessage(request);
|
||||
|
||||
using CancellationTokenSource? timeoutCts = request.Timeout is { } timeout && timeout > TimeSpan.Zero
|
||||
? CancellationTokenSource.CreateLinkedTokenSource(cancellationToken)
|
||||
: null;
|
||||
|
||||
timeoutCts?.CancelAfter(request.Timeout!.Value);
|
||||
|
||||
CancellationToken effectiveToken = timeoutCts?.Token ?? cancellationToken;
|
||||
|
||||
using HttpResponseMessage httpResponse = await client
|
||||
.SendAsync(httpRequest, HttpCompletionOption.ResponseContentRead, effectiveToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
string? body = httpResponse.Content is null
|
||||
? null
|
||||
#if NET
|
||||
: await httpResponse.Content.ReadAsStringAsync(effectiveToken).ConfigureAwait(false);
|
||||
#else
|
||||
: await httpResponse.Content.ReadAsStringAsync().ConfigureAwait(false);
|
||||
#endif
|
||||
|
||||
Dictionary<string, IReadOnlyList<string>> headers = new(StringComparer.OrdinalIgnoreCase);
|
||||
AppendHeaders(headers, httpResponse.Headers);
|
||||
if (httpResponse.Content is not null)
|
||||
{
|
||||
AppendHeaders(headers, httpResponse.Content.Headers);
|
||||
}
|
||||
|
||||
return new HttpRequestResult
|
||||
{
|
||||
StatusCode = (int)httpResponse.StatusCode,
|
||||
IsSuccessStatusCode = httpResponse.IsSuccessStatusCode,
|
||||
Body = body,
|
||||
Headers = headers,
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
if (this._ownedHttpClient.IsValueCreated)
|
||||
{
|
||||
this._ownedHttpClient.Value.Dispose();
|
||||
}
|
||||
|
||||
return default;
|
||||
}
|
||||
|
||||
private static HttpRequestMessage BuildHttpRequestMessage(HttpRequestInfo request)
|
||||
{
|
||||
HttpMethod method = ResolveMethod(request.Method);
|
||||
string requestUri = ResolveRequestUri(request);
|
||||
HttpRequestMessage httpRequest = new(method, requestUri);
|
||||
|
||||
if (request.Body is not null)
|
||||
{
|
||||
string contentType = string.IsNullOrWhiteSpace(request.BodyContentType)
|
||||
? "text/plain"
|
||||
: request.BodyContentType!;
|
||||
|
||||
httpRequest.Content = new StringContent(request.Body, Encoding.UTF8);
|
||||
// Replace the default content-type header (including charset) with the declared type.
|
||||
httpRequest.Content.Headers.Remove("Content-Type");
|
||||
httpRequest.Content.Headers.TryAddWithoutValidation("Content-Type", contentType);
|
||||
}
|
||||
|
||||
if (request.Headers is not null)
|
||||
{
|
||||
foreach (KeyValuePair<string, string> header in request.Headers)
|
||||
{
|
||||
if (string.IsNullOrEmpty(header.Key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Content-* headers belong on HttpContent; all others belong on the request.
|
||||
if (header.Key.StartsWith("Content-", StringComparison.OrdinalIgnoreCase) && httpRequest.Content is not null)
|
||||
{
|
||||
httpRequest.Content.Headers.Remove(header.Key);
|
||||
httpRequest.Content.Headers.TryAddWithoutValidation(header.Key, header.Value);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!httpRequest.Headers.TryAddWithoutValidation(header.Key, header.Value))
|
||||
{
|
||||
httpRequest.Content?.Headers.TryAddWithoutValidation(header.Key, header.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return httpRequest;
|
||||
}
|
||||
|
||||
private static HttpMethod ResolveMethod(string method)
|
||||
{
|
||||
string normalized = method.Trim().ToUpperInvariant();
|
||||
return normalized switch
|
||||
{
|
||||
"GET" => HttpMethod.Get,
|
||||
"POST" => HttpMethod.Post,
|
||||
"PUT" => HttpMethod.Put,
|
||||
"DELETE" => HttpMethod.Delete,
|
||||
#if NET
|
||||
"PATCH" => HttpMethod.Patch,
|
||||
#else
|
||||
"PATCH" => new HttpMethod("PATCH"),
|
||||
#endif
|
||||
_ => new HttpMethod(normalized),
|
||||
};
|
||||
}
|
||||
|
||||
private static string ResolveRequestUri(HttpRequestInfo request)
|
||||
{
|
||||
string baseUrl = request.Url;
|
||||
if (request.QueryParameters is null || request.QueryParameters.Count == 0)
|
||||
{
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
StringBuilder queryBuilder = new();
|
||||
foreach (KeyValuePair<string, string> parameter in request.QueryParameters)
|
||||
{
|
||||
if (string.IsNullOrEmpty(parameter.Key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (queryBuilder.Length > 0)
|
||||
{
|
||||
queryBuilder.Append('&');
|
||||
}
|
||||
|
||||
queryBuilder.Append(Uri.EscapeDataString(parameter.Key))
|
||||
.Append('=')
|
||||
.Append(Uri.EscapeDataString(parameter.Value ?? string.Empty));
|
||||
}
|
||||
|
||||
if (queryBuilder.Length == 0)
|
||||
{
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
char separator = baseUrl.Contains('?') ? '&' : '?';
|
||||
return string.Concat(baseUrl, separator.ToString(), queryBuilder.ToString());
|
||||
}
|
||||
|
||||
private static void AppendHeaders(
|
||||
Dictionary<string, IReadOnlyList<string>> target,
|
||||
System.Net.Http.Headers.HttpHeaders source)
|
||||
{
|
||||
foreach (KeyValuePair<string, IEnumerable<string>> header in source)
|
||||
{
|
||||
string[] values = header.Value.ToArray();
|
||||
|
||||
if (target.TryGetValue(header.Key, out IReadOnlyList<string>? existing))
|
||||
{
|
||||
List<string> combined = new(existing);
|
||||
combined.AddRange(values);
|
||||
target[header.Key] = combined;
|
||||
}
|
||||
else
|
||||
{
|
||||
target[header.Key] = values;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Declarative;
|
||||
|
||||
/// <summary>
|
||||
/// Defines the contract for executing HTTP requests emitted by <c>HttpRequestAction</c> within declarative workflows.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This interface allows the HTTP request dispatch to be abstracted, enabling different implementations
|
||||
/// for local development, hosted workflows, authenticated scenarios, and testing.
|
||||
/// </remarks>
|
||||
public interface IHttpRequestHandler
|
||||
{
|
||||
/// <summary>
|
||||
/// Sends an HTTP request and returns the response.
|
||||
/// </summary>
|
||||
/// <param name="request">The HTTP request to send.</param>
|
||||
/// <param name="cancellationToken">A token to observe cancellation.</param>
|
||||
/// <returns>The <see cref="HttpRequestResult"/> describing the HTTP response.</returns>
|
||||
Task<HttpRequestResult> SendAsync(
|
||||
HttpRequestInfo request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Describes an HTTP request to be sent by an <see cref="IHttpRequestHandler"/>.
|
||||
/// </summary>
|
||||
[SuppressMessage("Design", "CA1056:URI-like properties should not be strings", Justification = "URL is carried as a string to preserve the declarative expression result and to avoid forcing handler implementations to construct a Uri eagerly.")]
|
||||
public sealed class HttpRequestInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the HTTP method to use (GET, POST, PUT, PATCH, DELETE).
|
||||
/// </summary>
|
||||
public string Method { get; init; } = "GET";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the absolute URL to send the request to.
|
||||
/// </summary>
|
||||
public string Url { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the headers to include on the request, excluding the <c>Content-Type</c> header (which is supplied via <see cref="BodyContentType"/>).
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string>? Headers { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the <c>Content-Type</c> of the request body, or <see langword="null"/> if no body is sent.
|
||||
/// </summary>
|
||||
public string? BodyContentType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the serialized request body, or <see langword="null"/> if no body is sent.
|
||||
/// </summary>
|
||||
public string? Body { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the maximum amount of time to wait for the request to complete, or <see langword="null"/> to use the handler default.
|
||||
/// </summary>
|
||||
public TimeSpan? Timeout { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the query parameters to append to the request URL, with values already formatted as strings.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string>? QueryParameters { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the name of the declared remote connection, or <see langword="null"/> if no connection is declared.
|
||||
/// This maps to the Foundry project connection Id and is only used when running in foundry service.
|
||||
/// </summary>
|
||||
public string? ConnectionName { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents the result of an HTTP request executed by an <see cref="IHttpRequestHandler"/>.
|
||||
/// </summary>
|
||||
public sealed class HttpRequestResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the HTTP status code returned by the server.
|
||||
/// </summary>
|
||||
public int StatusCode { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the status code is in the range 200-299.
|
||||
/// </summary>
|
||||
public bool IsSuccessStatusCode { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the response body, or <see langword="null"/> if no body was returned.
|
||||
/// </summary>
|
||||
public string? Body { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the response headers keyed by header name. Each header may have multiple values.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, IReadOnlyList<string>>? Headers { get; init; }
|
||||
}
|
||||
+12
-2
@@ -529,6 +529,18 @@ internal sealed class WorkflowActionVisitor : DialogActionVisitor
|
||||
this._workflowModel.AddNode(new DelegateActionExecutor(postId, this._workflowState, action.CompleteAsync), action.ParentId);
|
||||
}
|
||||
|
||||
protected override void Visit(HttpRequestAction item)
|
||||
{
|
||||
this.Trace(item);
|
||||
|
||||
if (this._workflowOptions.HttpRequestHandler is null)
|
||||
{
|
||||
throw new DeclarativeModelException("HTTP request handler not configured. Set HttpRequestHandler in DeclarativeWorkflowOptions to use HttpRequestAction actions.");
|
||||
}
|
||||
|
||||
this.ContinueWith(new HttpRequestExecutor(item, this._workflowOptions.HttpRequestHandler, this._workflowOptions.AgentProvider, this._workflowState));
|
||||
}
|
||||
|
||||
#region Not supported
|
||||
|
||||
protected override void Visit(AnswerQuestionWithAI item) => this.NotSupported(item);
|
||||
@@ -573,8 +585,6 @@ internal sealed class WorkflowActionVisitor : DialogActionVisitor
|
||||
|
||||
protected override void Visit(GetConversationMembers item) => this.NotSupported(item);
|
||||
|
||||
protected override void Visit(HttpRequestAction item) => this.NotSupported(item);
|
||||
|
||||
protected override void Visit(RecognizeIntent item) => this.NotSupported(item);
|
||||
|
||||
protected override void Visit(TransferConversation item) => this.NotSupported(item);
|
||||
|
||||
+346
@@ -0,0 +1,346 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.Extensions;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.Interpreter;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.Kit;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
|
||||
using Microsoft.Agents.ObjectModel;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.PowerFx.Types;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
|
||||
|
||||
/// <summary>
|
||||
/// Executor for the <see cref="HttpRequestAction"/> action.
|
||||
/// Dispatches the request through the configured <see cref="IHttpRequestHandler"/> and assigns
|
||||
/// the response body and headers to the declared property paths.
|
||||
/// </summary>
|
||||
internal sealed class HttpRequestExecutor(
|
||||
HttpRequestAction model,
|
||||
IHttpRequestHandler httpRequestHandler,
|
||||
ResponseAgentProvider agentProvider,
|
||||
WorkflowFormulaState state) :
|
||||
DeclarativeActionExecutor<HttpRequestAction>(model, state)
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
protected override async ValueTask<object?> ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
string method = this.GetMethod();
|
||||
string url = this.GetUrl();
|
||||
Dictionary<string, string>? headers = this.GetHeaders();
|
||||
Dictionary<string, string>? queryParameters = this.GetQueryParameters();
|
||||
(string? body, string? contentType) = this.GetBody();
|
||||
TimeSpan? timeout = this.GetTimeout();
|
||||
string? conversationId = this.GetConversationId();
|
||||
string? connectionName = this.GetConnectionName();
|
||||
|
||||
HttpRequestInfo requestInfo = new()
|
||||
{
|
||||
Method = method,
|
||||
Url = url,
|
||||
Headers = headers,
|
||||
QueryParameters = queryParameters,
|
||||
Body = body,
|
||||
BodyContentType = contentType,
|
||||
Timeout = timeout,
|
||||
ConnectionName = connectionName,
|
||||
};
|
||||
|
||||
HttpRequestResult result;
|
||||
try
|
||||
{
|
||||
result = await httpRequestHandler.SendAsync(requestInfo, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw this.Exception($"HTTP request to '{url}' timed out.");
|
||||
}
|
||||
catch (Exception exception) when (exception is not DeclarativeActionException)
|
||||
{
|
||||
throw this.Exception($"HTTP request to '{url}' failed: {exception.Message}", exception);
|
||||
}
|
||||
|
||||
if (result.IsSuccessStatusCode)
|
||||
{
|
||||
await this.AssignResponseAsync(context, result.Body).ConfigureAwait(false);
|
||||
await this.AssignResponseHeadersAsync(context, result.Headers).ConfigureAwait(false);
|
||||
await this.AddResponseToConversationAsync(conversationId, result.Body, cancellationToken).ConfigureAwait(false);
|
||||
return default;
|
||||
}
|
||||
|
||||
// Non-success status code - throw.
|
||||
// Also publish response headers for diagnostic purposes.
|
||||
await this.AssignResponseHeadersAsync(context, result.Headers).ConfigureAwait(false);
|
||||
|
||||
string bodyPreview = FormatBodyForDiagnostics(result.Body);
|
||||
string message = bodyPreview.Length == 0
|
||||
? $"HTTP request to '{url}' failed with status code {result.StatusCode}."
|
||||
: $"HTTP request to '{url}' failed with status code {result.StatusCode}. Body: '{bodyPreview}'";
|
||||
|
||||
throw this.Exception(message);
|
||||
}
|
||||
|
||||
// Response bodies can echo secrets (tokens, PII) and may be very large (multi-MB HTML error pages).
|
||||
// Exception messages are often logged and persisted, so we clip the body to bound both exposure
|
||||
// and message size. Full bodies are still available via the success path (assigned to Response).
|
||||
private const int MaxBodyDiagnosticLength = 256;
|
||||
private const string BodyTruncationSuffix = " \u2026 [truncated]";
|
||||
|
||||
private static string FormatBodyForDiagnostics(string? body)
|
||||
{
|
||||
if (string.IsNullOrEmpty(body))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
int sourceLen = body!.Length;
|
||||
bool truncated = sourceLen > MaxBodyDiagnosticLength;
|
||||
int copyLen = truncated ? MaxBodyDiagnosticLength : sourceLen;
|
||||
int finalLen = copyLen + (truncated ? BodyTruncationSuffix.Length : 0);
|
||||
|
||||
// Size the buffer for the final string so we only allocate once for the chars
|
||||
// and once for the string itself. For a 10 KB error body we touch 256 chars instead of 10,000.
|
||||
char[] buffer = new char[finalLen];
|
||||
for (int i = 0; i < copyLen; i++)
|
||||
{
|
||||
char c = body[i];
|
||||
buffer[i] = c is '\r' or '\n' or '\t' ? ' ' : c;
|
||||
}
|
||||
|
||||
if (truncated)
|
||||
{
|
||||
BodyTruncationSuffix.CopyTo(0, buffer, copyLen, BodyTruncationSuffix.Length);
|
||||
}
|
||||
|
||||
return new string(buffer);
|
||||
}
|
||||
|
||||
private async ValueTask AddResponseToConversationAsync(string? conversationId, string? responseBody, CancellationToken cancellationToken)
|
||||
{
|
||||
if (conversationId is null || string.IsNullOrEmpty(responseBody))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ChatMessage message = new(ChatRole.Assistant, responseBody);
|
||||
await agentProvider.CreateMessageAsync(conversationId, message, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async ValueTask AssignResponseAsync(IWorkflowContext context, string? responseBody)
|
||||
{
|
||||
if (this.Model.Response is not { Path: { } responsePath })
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await this.AssignAsync(responsePath, ParseResponseBody(responseBody), context).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async ValueTask AssignResponseHeadersAsync(IWorkflowContext context, IReadOnlyDictionary<string, IReadOnlyList<string>>? responseHeaders)
|
||||
{
|
||||
if (this.Model.ResponseHeaders is not { Path: { } headersPath })
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (responseHeaders is null || responseHeaders.Count == 0)
|
||||
{
|
||||
await this.AssignAsync(headersPath, FormulaValue.NewBlank(), context).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Flatten multi-value headers by joining with commas (standard HTTP header folding).
|
||||
Dictionary<string, object?> flattened = new(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (KeyValuePair<string, IReadOnlyList<string>> header in responseHeaders)
|
||||
{
|
||||
flattened[header.Key] = string.Join(",", header.Value);
|
||||
}
|
||||
|
||||
await this.AssignAsync(headersPath, flattened.ToFormula(), context).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static FormulaValue ParseResponseBody(string? responseBody)
|
||||
{
|
||||
if (string.IsNullOrEmpty(responseBody))
|
||||
{
|
||||
return FormulaValue.NewBlank();
|
||||
}
|
||||
|
||||
// Attempt to parse as JSON so records/tables are exposed naturally to the workflow.
|
||||
try
|
||||
{
|
||||
using JsonDocument jsonDocument = JsonDocument.Parse(responseBody);
|
||||
|
||||
object? parsedValue = jsonDocument.RootElement.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Object => jsonDocument.ParseRecord(VariableType.RecordType),
|
||||
JsonValueKind.Array => jsonDocument.ParseList(jsonDocument.RootElement.GetListTypeFromJson()),
|
||||
JsonValueKind.String => jsonDocument.RootElement.GetString(),
|
||||
JsonValueKind.Number => jsonDocument.RootElement.TryGetInt64(out long l)
|
||||
? l
|
||||
: jsonDocument.RootElement.GetDouble(),
|
||||
JsonValueKind.True => true,
|
||||
JsonValueKind.False => false,
|
||||
JsonValueKind.Null => null,
|
||||
_ => responseBody,
|
||||
};
|
||||
|
||||
return parsedValue.ToFormula();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Not valid JSON — return the raw string.
|
||||
return FormulaValue.New(responseBody);
|
||||
}
|
||||
}
|
||||
|
||||
private string GetMethod()
|
||||
{
|
||||
EnumExpression<HttpMethodTypeWrapper>? methodExpression = this.Model.Method;
|
||||
if (methodExpression is null)
|
||||
{
|
||||
return "GET";
|
||||
}
|
||||
|
||||
HttpMethodTypeWrapper wrapper = this.Evaluator.GetValue(methodExpression).Value;
|
||||
return !string.IsNullOrEmpty(wrapper.UnknownValue) ? wrapper.UnknownValue! : wrapper.Value.ToString().ToUpperInvariant();
|
||||
}
|
||||
|
||||
private string GetUrl() =>
|
||||
this.Evaluator.GetValue(
|
||||
Throw.IfNull(
|
||||
this.Model.Url,
|
||||
$"{nameof(this.Model)}.{nameof(this.Model.Url)}")).Value;
|
||||
|
||||
private Dictionary<string, string>? GetHeaders()
|
||||
{
|
||||
if (this.Model.Headers is null || this.Model.Headers.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
Dictionary<string, string> result = new(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (KeyValuePair<string, StringExpression> header in this.Model.Headers)
|
||||
{
|
||||
string value = this.Evaluator.GetValue(header.Value).Value;
|
||||
if (!string.IsNullOrEmpty(value))
|
||||
{
|
||||
result[header.Key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return result.Count == 0 ? null : result;
|
||||
}
|
||||
|
||||
private (string? Body, string? ContentType) GetBody()
|
||||
{
|
||||
switch (this.Model.Body)
|
||||
{
|
||||
case null:
|
||||
case NoRequestContent:
|
||||
return (null, null);
|
||||
|
||||
case JsonRequestContent jsonContent when jsonContent.Content is not null:
|
||||
{
|
||||
FormulaValue formula = this.Evaluator.GetValue(jsonContent.Content).Value.ToFormula();
|
||||
string json = formula.ToJson().ToJsonString();
|
||||
return (json, "application/json");
|
||||
}
|
||||
|
||||
case RawRequestContent rawContent:
|
||||
{
|
||||
string? content = rawContent.Content is null
|
||||
? null
|
||||
: this.Evaluator.GetValue(rawContent.Content).Value;
|
||||
|
||||
string? contentType = rawContent.ContentType is null
|
||||
? null
|
||||
: this.Evaluator.GetValue(rawContent.ContentType).Value;
|
||||
|
||||
return (content, string.IsNullOrEmpty(contentType) ? null : contentType);
|
||||
}
|
||||
|
||||
default:
|
||||
return (null, null);
|
||||
}
|
||||
}
|
||||
|
||||
private TimeSpan? GetTimeout()
|
||||
{
|
||||
if (this.Model.RequestTimeoutInMilliseconds is null || this.Model.RequestTimeoutInMillisecondsIsDefaultValue)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
long value = this.Evaluator.GetValue(this.Model.RequestTimeoutInMilliseconds).Value;
|
||||
return value > 0 ? TimeSpan.FromMilliseconds(value) : null;
|
||||
}
|
||||
|
||||
private Dictionary<string, string>? GetQueryParameters()
|
||||
{
|
||||
if (this.Model.QueryParameters is null || this.Model.QueryParameters.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
Dictionary<string, string> result = new(StringComparer.Ordinal);
|
||||
foreach (KeyValuePair<string, ValueExpression> parameter in this.Model.QueryParameters)
|
||||
{
|
||||
if (string.IsNullOrEmpty(parameter.Key) || parameter.Value is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
object? rawValue = this.Evaluator.GetValue(parameter.Value).Value.ToObject();
|
||||
string? formatted = FormatQueryValue(rawValue);
|
||||
if (formatted is not null)
|
||||
{
|
||||
result[parameter.Key] = formatted;
|
||||
}
|
||||
}
|
||||
|
||||
return result.Count == 0 ? null : result;
|
||||
}
|
||||
|
||||
private static string? FormatQueryValue(object? value) =>
|
||||
value switch
|
||||
{
|
||||
null => null,
|
||||
string s => s,
|
||||
bool b => b ? "true" : "false",
|
||||
IFormattable formattable => formattable.ToString(null, System.Globalization.CultureInfo.InvariantCulture),
|
||||
_ => value.ToString(),
|
||||
};
|
||||
|
||||
private string? GetConversationId()
|
||||
{
|
||||
if (this.Model.ConversationId is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string value = this.Evaluator.GetValue(this.Model.ConversationId).Value;
|
||||
return value.Length == 0 ? null : value;
|
||||
}
|
||||
|
||||
private string? GetConnectionName()
|
||||
{
|
||||
RemoteConnection? connection = this.Model.Connection;
|
||||
if (connection is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string? name = connection.Name is null
|
||||
? null
|
||||
: this.Evaluator.GetValue(connection.Name).Value;
|
||||
|
||||
return string.IsNullOrEmpty(name) ? null : name;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,20 @@
|
||||
<?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.AgentFileSkillScriptRunner.BeginInvoke(Microsoft.Agents.AI.AgentFileSkill,Microsoft.Agents.AI.AgentFileSkillScript,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken,System.AsyncCallback,System.Object)</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.AgentFileSkillScriptRunner.Invoke(Microsoft.Agents.AI.AgentFileSkill,Microsoft.Agents.AI.AgentFileSkillScript,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken)</Target>
|
||||
<Left>lib/net10.0/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/net10.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentInlineSkill.#ctor(Microsoft.Agents.AI.AgentSkillFrontmatter,System.String)</Target>
|
||||
@@ -29,6 +43,13 @@
|
||||
<Right>lib/net10.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken)</Target>
|
||||
<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.AgentSkillsProvider.#ctor(Microsoft.Agents.AI.AgentInlineSkill[])</Target>
|
||||
@@ -43,6 +64,20 @@
|
||||
<Right>lib/net10.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentFileSkillScriptRunner.BeginInvoke(Microsoft.Agents.AI.AgentFileSkill,Microsoft.Agents.AI.AgentFileSkillScript,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken,System.AsyncCallback,System.Object)</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.AgentFileSkillScriptRunner.Invoke(Microsoft.Agents.AI.AgentFileSkill,Microsoft.Agents.AI.AgentFileSkillScript,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken)</Target>
|
||||
<Left>lib/net472/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/net472/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentInlineSkill.#ctor(Microsoft.Agents.AI.AgentSkillFrontmatter,System.String)</Target>
|
||||
@@ -71,6 +106,13 @@
|
||||
<Right>lib/net472/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken)</Target>
|
||||
<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.AgentSkillsProvider.#ctor(Microsoft.Agents.AI.AgentInlineSkill[])</Target>
|
||||
@@ -85,6 +127,20 @@
|
||||
<Right>lib/net472/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentFileSkillScriptRunner.BeginInvoke(Microsoft.Agents.AI.AgentFileSkill,Microsoft.Agents.AI.AgentFileSkillScript,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken,System.AsyncCallback,System.Object)</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.AgentFileSkillScriptRunner.Invoke(Microsoft.Agents.AI.AgentFileSkill,Microsoft.Agents.AI.AgentFileSkillScript,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken)</Target>
|
||||
<Left>lib/net8.0/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/net8.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentInlineSkill.#ctor(Microsoft.Agents.AI.AgentSkillFrontmatter,System.String)</Target>
|
||||
@@ -113,6 +169,13 @@
|
||||
<Right>lib/net8.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken)</Target>
|
||||
<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.AgentSkillsProvider.#ctor(Microsoft.Agents.AI.AgentInlineSkill[])</Target>
|
||||
@@ -127,6 +190,20 @@
|
||||
<Right>lib/net8.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentFileSkillScriptRunner.BeginInvoke(Microsoft.Agents.AI.AgentFileSkill,Microsoft.Agents.AI.AgentFileSkillScript,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken,System.AsyncCallback,System.Object)</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.AgentFileSkillScriptRunner.Invoke(Microsoft.Agents.AI.AgentFileSkill,Microsoft.Agents.AI.AgentFileSkillScript,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken)</Target>
|
||||
<Left>lib/net9.0/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/net9.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentInlineSkill.#ctor(Microsoft.Agents.AI.AgentSkillFrontmatter,System.String)</Target>
|
||||
@@ -155,6 +232,13 @@
|
||||
<Right>lib/net9.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken)</Target>
|
||||
<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.AgentSkillsProvider.#ctor(Microsoft.Agents.AI.AgentInlineSkill[])</Target>
|
||||
@@ -169,6 +253,20 @@
|
||||
<Right>lib/net9.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentFileSkillScriptRunner.BeginInvoke(Microsoft.Agents.AI.AgentFileSkill,Microsoft.Agents.AI.AgentFileSkillScript,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken,System.AsyncCallback,System.Object)</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.AgentFileSkillScriptRunner.Invoke(Microsoft.Agents.AI.AgentFileSkill,Microsoft.Agents.AI.AgentFileSkillScript,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken)</Target>
|
||||
<Left>lib/netstandard2.0/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/netstandard2.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentInlineSkill.#ctor(Microsoft.Agents.AI.AgentSkillFrontmatter,System.String)</Target>
|
||||
@@ -197,6 +295,13 @@
|
||||
<Right>lib/netstandard2.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken)</Target>
|
||||
<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.AgentSkillsProvider.#ctor(Microsoft.Agents.AI.AgentInlineSkill[])</Target>
|
||||
@@ -211,4 +316,39 @@
|
||||
<Right>lib/netstandard2.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0005</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,System.Nullable{System.Text.Json.JsonElement},System.IServiceProvider,System.Threading.CancellationToken)</Target>
|
||||
<Left>lib/net10.0/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/net10.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0005</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,System.Nullable{System.Text.Json.JsonElement},System.IServiceProvider,System.Threading.CancellationToken)</Target>
|
||||
<Left>lib/net472/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/net472/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0005</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,System.Nullable{System.Text.Json.JsonElement},System.IServiceProvider,System.Threading.CancellationToken)</Target>
|
||||
<Left>lib/net8.0/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/net8.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0005</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,System.Nullable{System.Text.Json.JsonElement},System.IServiceProvider,System.Threading.CancellationToken)</Target>
|
||||
<Left>lib/net9.0/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/net9.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0005</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,System.Nullable{System.Text.Json.JsonElement},System.IServiceProvider,System.Threading.CancellationToken)</Target>
|
||||
<Left>lib/netstandard2.0/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/netstandard2.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
</Suppressions>
|
||||
@@ -35,7 +35,8 @@ public abstract class AgentSkill
|
||||
/// Gets the full skill content.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// For file-based skills this is the raw SKILL.md file content.
|
||||
/// For file-based skills this is the raw SKILL.md file content, optionally
|
||||
/// augmented with a synthesized scripts block when scripts are present.
|
||||
/// For code-defined skills this is a synthesized XML document
|
||||
/// containing name, description, and body (instructions, resources, scripts).
|
||||
/// </remarks>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
@@ -46,8 +46,9 @@ public abstract class AgentSkillScript
|
||||
/// Runs the script with the given arguments.
|
||||
/// </summary>
|
||||
/// <param name="skill">The skill that owns this script.</param>
|
||||
/// <param name="arguments">Arguments for script execution.</param>
|
||||
/// <param name="arguments">Raw JSON arguments for script execution, preserving the original format (object or array) sent by the caller.</param>
|
||||
/// <param name="serviceProvider">Optional service provider for dependency injection.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The script execution result.</returns>
|
||||
public abstract Task<object?> RunAsync(AgentSkill skill, AIFunctionArguments arguments, CancellationToken cancellationToken = default);
|
||||
public abstract Task<object?> RunAsync(AgentSkill skill, JsonElement? arguments, IServiceProvider? serviceProvider, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using System.Security;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
@@ -243,7 +244,7 @@ public sealed partial class AgentSkillsProvider : AIContextProvider
|
||||
}
|
||||
|
||||
AIFunction scriptFunction = AIFunctionFactory.Create(
|
||||
(string skillName, string scriptName, IDictionary<string, object?>? arguments = null, IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default) =>
|
||||
(string skillName, string scriptName, JsonElement? arguments = null, IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default) =>
|
||||
this.RunSkillScriptAsync(skills, skillName, scriptName, arguments, serviceProvider, cancellationToken),
|
||||
name: "run_skill_script",
|
||||
description: "Runs a script associated with a skill.");
|
||||
@@ -340,7 +341,7 @@ public sealed partial class AgentSkillsProvider : AIContextProvider
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<object?> RunSkillScriptAsync(IList<AgentSkill> skills, string skillName, string scriptName, IDictionary<string, object?>? arguments = null, IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default)
|
||||
private async Task<object?> RunSkillScriptAsync(IList<AgentSkill> skills, string skillName, string scriptName, JsonElement? arguments = null, IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(skillName))
|
||||
{
|
||||
@@ -366,7 +367,7 @@ public sealed partial class AgentSkillsProvider : AIContextProvider
|
||||
|
||||
try
|
||||
{
|
||||
return await script.RunAsync(skill, new AIFunctionArguments(arguments) { Services = serviceProvider }, cancellationToken).ConfigureAwait(false);
|
||||
return await script.RunAsync(skill, arguments, serviceProvider, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -15,6 +15,8 @@ public sealed class AgentFileSkill : AgentSkill
|
||||
{
|
||||
private readonly IReadOnlyList<AgentSkillResource> _resources;
|
||||
private readonly IReadOnlyList<AgentSkillScript> _scripts;
|
||||
private readonly string _originalContent;
|
||||
private string? _content;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AgentFileSkill"/> class.
|
||||
@@ -32,7 +34,7 @@ public sealed class AgentFileSkill : AgentSkill
|
||||
IReadOnlyList<AgentSkillScript>? scripts = null)
|
||||
{
|
||||
this.Frontmatter = Throw.IfNull(frontmatter);
|
||||
this.Content = Throw.IfNull(content);
|
||||
this._originalContent = Throw.IfNull(content);
|
||||
this.Path = Throw.IfNullOrWhitespace(path);
|
||||
this._resources = resources ?? [];
|
||||
this._scripts = scripts ?? [];
|
||||
@@ -42,7 +44,18 @@ public sealed class AgentFileSkill : AgentSkill
|
||||
public override AgentSkillFrontmatter Frontmatter { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string Content { get; }
|
||||
/// <remarks>
|
||||
/// Returns the raw SKILL.md content. When the skill has scripts, a
|
||||
/// <c><scripts><script name="..."><parameters_schema>...</parameters_schema></script></scripts></c>
|
||||
/// block is appended with a per-script entry describing the expected argument format.
|
||||
/// The result is cached after the first access.
|
||||
/// </remarks>
|
||||
public override string Content
|
||||
{
|
||||
get => this._content ??= this._scripts is { Count: > 0 }
|
||||
? this._originalContent + AgentInlineSkillContentBuilder.BuildScriptsBlock(this._scripts)
|
||||
: this._originalContent;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the directory path where the skill was discovered.
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
@@ -16,6 +16,11 @@ namespace Microsoft.Agents.AI;
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public sealed class AgentFileSkillScript : AgentSkillScript
|
||||
{
|
||||
/// <summary>
|
||||
/// Cached JSON schema element describing the expected argument format: a string array of CLI arguments.
|
||||
/// </summary>
|
||||
private static readonly JsonElement s_defaultSchema = CreateDefaultSchema();
|
||||
|
||||
private readonly AgentFileSkillScriptRunner? _runner;
|
||||
|
||||
/// <summary>
|
||||
@@ -37,7 +42,14 @@ public sealed class AgentFileSkillScript : AgentSkillScript
|
||||
public string FullPath { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task<object?> RunAsync(AgentSkill skill, AIFunctionArguments arguments, CancellationToken cancellationToken = default)
|
||||
/// <remarks>
|
||||
/// Returns a fixed schema describing a string array of CLI arguments:
|
||||
/// <c>{"type":"array","items":{"type":"string"}}</c>.
|
||||
/// </remarks>
|
||||
public override JsonElement? ParametersSchema => s_defaultSchema;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task<object?> RunAsync(AgentSkill skill, JsonElement? arguments, IServiceProvider? serviceProvider, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (skill is not AgentFileSkill fileSkill)
|
||||
{
|
||||
@@ -51,6 +63,12 @@ public sealed class AgentFileSkillScript : AgentSkillScript
|
||||
$"Supply a script runner when constructing {nameof(AgentFileSkillsSource)} to enable script execution.");
|
||||
}
|
||||
|
||||
return await this._runner(fileSkill, this, arguments, cancellationToken).ConfigureAwait(false);
|
||||
return await this._runner(fileSkill, this, arguments, serviceProvider, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static JsonElement CreateDefaultSchema()
|
||||
{
|
||||
using JsonDocument document = JsonDocument.Parse("""{"type":"array","items":{"type":"string"}}""");
|
||||
return document.RootElement.Clone();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
@@ -13,15 +14,19 @@ namespace Microsoft.Agents.AI;
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Implementations determine the execution strategy (e.g., local subprocess, hosted code execution environment).
|
||||
/// The <paramref name="arguments"/> parameter preserves the raw JSON sent by the caller, in the shape
|
||||
/// described by <see cref="AgentFileSkillScript.ParametersSchema"/>.
|
||||
/// </remarks>
|
||||
/// <param name="skill">The skill that owns the script.</param>
|
||||
/// <param name="script">The file-based script to run.</param>
|
||||
/// <param name="arguments">Optional arguments for the script, provided by the agent/LLM.</param>
|
||||
/// <param name="arguments">Raw JSON arguments for the script, in the shape described by <see cref="AgentFileSkillScript.ParametersSchema"/>.</param>
|
||||
/// <param name="serviceProvider">Optional service provider for dependency injection.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The script execution result.</returns>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public delegate Task<object?> AgentFileSkillScriptRunner(
|
||||
AgentFileSkill skill,
|
||||
AgentFileSkillScript script,
|
||||
AIFunctionArguments arguments,
|
||||
JsonElement? arguments,
|
||||
IServiceProvider? serviceProvider,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
+49
-25
@@ -59,36 +59,60 @@ internal static class AgentInlineSkillContentBuilder
|
||||
|
||||
if (scripts is { Count: > 0 })
|
||||
{
|
||||
sb.Append("\n\n<scripts>\n");
|
||||
foreach (var script in scripts)
|
||||
{
|
||||
var parametersSchema = script.ParametersSchema;
|
||||
|
||||
if (script.Description is null && parametersSchema is null)
|
||||
{
|
||||
sb.Append($" <script name=\"{EscapeXmlString(script.Name)}\"/>\n");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append(script.Description is not null
|
||||
? $" <script name=\"{EscapeXmlString(script.Name)}\" description=\"{EscapeXmlString(script.Description)}\">\n"
|
||||
: $" <script name=\"{EscapeXmlString(script.Name)}\">\n");
|
||||
|
||||
if (parametersSchema is not null)
|
||||
{
|
||||
sb.Append($" <parameters_schema>{EscapeXmlString(parametersSchema.Value.GetRawText(), preserveQuotes: true)}</parameters_schema>\n");
|
||||
}
|
||||
|
||||
sb.Append(" </script>\n");
|
||||
}
|
||||
}
|
||||
|
||||
sb.Append("</scripts>");
|
||||
sb.Append('\n');
|
||||
sb.Append(BuildScriptsBlock(scripts));
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a <c><scripts>...</scripts></c> XML block for the given scripts.
|
||||
/// Each script is emitted as a <c><script name="..."></c> element with optional
|
||||
/// <c>description</c> attribute and <c><parameters_schema></c> child element.
|
||||
/// </summary>
|
||||
/// <param name="scripts">The scripts to include in the block.</param>
|
||||
/// <returns>An XML string starting with <c>\n<scripts></c>, or an empty string if the list is empty.</returns>
|
||||
public static string BuildScriptsBlock(IReadOnlyList<AgentSkillScript> scripts)
|
||||
{
|
||||
_ = Throw.IfNull(scripts);
|
||||
|
||||
if (scripts.Count == 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.Append("\n<scripts>\n");
|
||||
|
||||
foreach (var script in scripts)
|
||||
{
|
||||
var parametersSchema = script.ParametersSchema;
|
||||
|
||||
if (script.Description is null && parametersSchema is null)
|
||||
{
|
||||
sb.Append($" <script name=\"{EscapeXmlString(script.Name)}\"/>\n");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append(script.Description is not null
|
||||
? $" <script name=\"{EscapeXmlString(script.Name)}\" description=\"{EscapeXmlString(script.Description)}\">\n"
|
||||
: $" <script name=\"{EscapeXmlString(script.Name)}\">\n");
|
||||
|
||||
if (parametersSchema is not null)
|
||||
{
|
||||
sb.Append($" <parameters_schema>{EscapeXmlString(parametersSchema.Value.GetRawText(), preserveQuotes: true)}</parameters_schema>\n");
|
||||
}
|
||||
|
||||
sb.Append(" </script>\n");
|
||||
}
|
||||
}
|
||||
|
||||
sb.Append("</scripts>");
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Escapes XML special characters: always escapes <c>&</c>, <c><</c>, <c>></c>,
|
||||
/// <c>"</c>, and <c>'</c>. When <paramref name="preserveQuotes"/> is <see langword="true"/>,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
@@ -67,8 +68,42 @@ internal sealed class AgentInlineSkillScript : AgentSkillScript
|
||||
public override JsonElement? ParametersSchema => this._function.JsonSchema;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task<object?> RunAsync(AgentSkill skill, AIFunctionArguments arguments, CancellationToken cancellationToken = default)
|
||||
public override async Task<object?> RunAsync(AgentSkill skill, JsonElement? arguments, IServiceProvider? serviceProvider, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await this._function.InvokeAsync(arguments, cancellationToken).ConfigureAwait(false);
|
||||
var funcArgs = ConvertToFunctionArguments(arguments);
|
||||
funcArgs.Services = serviceProvider;
|
||||
|
||||
return await this._function.InvokeAsync(funcArgs, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a raw <see cref="JsonElement"/> to <see cref="AIFunctionArguments"/> for delegate invocation.
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// Thrown when <paramref name="arguments"/> is provided but is not a JSON object.
|
||||
/// Inline skill scripts expect arguments as a JSON object whose properties map to the delegate's parameters.
|
||||
/// </exception>
|
||||
private static AIFunctionArguments ConvertToFunctionArguments(JsonElement? arguments)
|
||||
{
|
||||
if (arguments is null ||
|
||||
arguments.Value.ValueKind == JsonValueKind.Null ||
|
||||
arguments.Value.ValueKind == JsonValueKind.Undefined)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
if (arguments.Value.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Inline skill scripts expect arguments as a JSON object but received a JSON element of kind '{arguments.Value.ValueKind}'.");
|
||||
}
|
||||
|
||||
var dict = new Dictionary<string, object?>();
|
||||
foreach (var property in arguments.Value.EnumerateObject())
|
||||
{
|
||||
dict[property.Name] = property.Value;
|
||||
}
|
||||
|
||||
return new AIFunctionArguments(dict);
|
||||
}
|
||||
}
|
||||
|
||||
+2
-4
@@ -164,10 +164,8 @@ public class AgentFrameworkResponseHandlerTelemetryTests
|
||||
private static (CreateResponse request, ResponseContext context) BuildRequest(string? agentKey = null)
|
||||
{
|
||||
var request = agentKey is null
|
||||
? AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test")
|
||||
: AzureAIAgentServerResponsesModelFactory.CreateResponse(
|
||||
model: "test",
|
||||
agentReference: new AgentReference(agentKey));
|
||||
? new CreateResponse { Model = "test" }
|
||||
: new CreateResponse { Model = "test", AgentReference = new AgentReference(agentKey) };
|
||||
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
|
||||
+21
-27
@@ -34,7 +34,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test");
|
||||
var request = new CreateResponse { Model = "test" };
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_1", status = "completed", role = "user",
|
||||
@@ -72,9 +72,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(
|
||||
model: "test",
|
||||
agentReference: new AgentReference("my-agent"));
|
||||
var request = new CreateResponse { Model = "test", AgentReference = new AgentReference("my-agent") };
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_1", status = "completed", role = "user",
|
||||
@@ -109,7 +107,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test");
|
||||
var request = new CreateResponse { Model = "test" };
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_1", status = "completed", role = "user",
|
||||
@@ -158,7 +156,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "my-agent");
|
||||
var request = new CreateResponse { Model = "my-agent" };
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_1", status = "completed", role = "user",
|
||||
@@ -195,7 +193,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "");
|
||||
var request = new CreateResponse { Model = "" };
|
||||
var metadata = new Metadata();
|
||||
metadata.AdditionalProperties["entity_id"] = "entity-agent";
|
||||
request.Metadata = metadata;
|
||||
@@ -235,9 +233,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(
|
||||
model: "test",
|
||||
agentReference: new AgentReference("nonexistent-agent"));
|
||||
var request = new CreateResponse { Model = "test", AgentReference = new AgentReference("nonexistent-agent") };
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_1", status = "completed", role = "user",
|
||||
@@ -272,9 +268,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(
|
||||
model: "test",
|
||||
agentReference: new AgentReference("missing-agent"));
|
||||
var request = new CreateResponse { Model = "test", AgentReference = new AgentReference("missing-agent") };
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_1", status = "completed", role = "user",
|
||||
@@ -308,7 +302,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "");
|
||||
var request = new CreateResponse { Model = "" };
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_1", status = "completed", role = "user",
|
||||
@@ -342,7 +336,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test");
|
||||
var request = new CreateResponse { Model = "test" };
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_1", status = "completed", role = "user",
|
||||
@@ -387,7 +381,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test");
|
||||
var request = new CreateResponse { Model = "test" };
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_1", status = "completed", role = "user",
|
||||
@@ -435,7 +429,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test");
|
||||
var request = new CreateResponse { Model = "test" };
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_1", status = "completed", role = "user",
|
||||
@@ -478,7 +472,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test");
|
||||
var request = new CreateResponse { Model = "test" };
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_1", status = "completed", role = "user",
|
||||
@@ -517,9 +511,11 @@ public class AgentFrameworkResponseHandlerTests
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(
|
||||
model: "test",
|
||||
instructions: "You are a helpful assistant.");
|
||||
var request = new CreateResponse
|
||||
{
|
||||
Model = "test",
|
||||
Instructions = "You are a helpful assistant.",
|
||||
};
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_1", status = "completed", role = "user",
|
||||
@@ -557,7 +553,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test");
|
||||
var request = new CreateResponse { Model = "test" };
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_1", status = "completed", role = "user",
|
||||
@@ -598,9 +594,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(
|
||||
model: "test",
|
||||
agentReference: new AgentReference("agent-2"));
|
||||
var request = new CreateResponse { Model = "test", AgentReference = new AgentReference("agent-2") };
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_1", status = "completed", role = "user",
|
||||
@@ -637,7 +631,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test");
|
||||
var request = new CreateResponse { Model = "test" };
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_1", status = "completed", role = "user",
|
||||
@@ -674,7 +668,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test");
|
||||
var request = new CreateResponse { Model = "test" };
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_1", status = "completed", role = "user",
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.ClientModel;
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.AI.Projects.Agents;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
#pragma warning disable OPENAI001
|
||||
#pragma warning disable AAIP001
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="FoundryToolbox"/> class.
|
||||
/// </summary>
|
||||
public class FoundryToolboxTests
|
||||
{
|
||||
private static readonly Uri s_testEndpoint = new("https://test.services.ai.azure.com/api/projects/test-project");
|
||||
|
||||
#region Parameter validation tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetToolboxVersionAsync_NullEndpoint_ThrowsAsync()
|
||||
{
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() =>
|
||||
FoundryToolbox.GetToolboxVersionAsync(
|
||||
projectEndpoint: null!,
|
||||
credential: new FakeAuthenticationTokenProvider(),
|
||||
name: "test-toolbox"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetToolboxVersionAsync_NullCredential_ThrowsAsync()
|
||||
{
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() =>
|
||||
FoundryToolbox.GetToolboxVersionAsync(
|
||||
projectEndpoint: s_testEndpoint,
|
||||
credential: null!,
|
||||
name: "test-toolbox"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public async Task GetToolboxVersionAsync_InvalidName_ThrowsAsync(string? name)
|
||||
{
|
||||
await Assert.ThrowsAnyAsync<ArgumentException>(() =>
|
||||
FoundryToolbox.GetToolboxVersionAsync(
|
||||
projectEndpoint: s_testEndpoint,
|
||||
credential: new FakeAuthenticationTokenProvider(),
|
||||
name: name!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetToolsAsync_NullEndpoint_ThrowsAsync()
|
||||
{
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() =>
|
||||
FoundryToolbox.GetToolsAsync(
|
||||
projectEndpoint: null!,
|
||||
credential: new FakeAuthenticationTokenProvider(),
|
||||
name: "test-toolbox"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToAITools_NullToolboxVersion_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
FoundryToolbox.ToAITools(null!));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ToAITools conversion tests
|
||||
|
||||
[Fact]
|
||||
public void ToAITools_EmptyTools_ReturnsEmptyList()
|
||||
{
|
||||
var version = ProjectsAgentsModelFactory.ToolboxVersion(
|
||||
metadata: null,
|
||||
id: "ver-1",
|
||||
name: "empty-toolbox",
|
||||
version: "v1",
|
||||
description: "Empty",
|
||||
createdAt: DateTimeOffset.UtcNow,
|
||||
tools: Array.Empty<ProjectsAgentTool>(),
|
||||
policies: null);
|
||||
|
||||
var tools = version.ToAITools();
|
||||
|
||||
Assert.Empty(tools);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToAITools_NullTools_ReturnsEmptyList()
|
||||
{
|
||||
var version = ProjectsAgentsModelFactory.ToolboxVersion(
|
||||
metadata: null,
|
||||
id: "ver-1",
|
||||
name: "null-tools-toolbox",
|
||||
version: "v1",
|
||||
description: "Null tools",
|
||||
createdAt: DateTimeOffset.UtcNow,
|
||||
tools: null,
|
||||
policies: null);
|
||||
|
||||
var tools = version.ToAITools();
|
||||
|
||||
Assert.Empty(tools);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToAITools_WithCodeInterpreterTool_ReturnsAITool()
|
||||
{
|
||||
var json = TestDataUtil.GetToolboxVersionResponseJson();
|
||||
var version = ModelReaderWriter.Read<ToolboxVersion>(BinaryData.FromString(json))!;
|
||||
|
||||
var tools = version.ToAITools();
|
||||
|
||||
Assert.Single(tools);
|
||||
Assert.IsAssignableFrom<AITool>(tools[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToAITools_SanitizesDecorationFieldsOnNonFunctionTools()
|
||||
{
|
||||
var json = TestDataUtil.GetToolboxVersionWithDecorationFieldsJson();
|
||||
var version = ModelReaderWriter.Read<ToolboxVersion>(BinaryData.FromString(json))!;
|
||||
|
||||
var tools = version.ToAITools();
|
||||
|
||||
Assert.Single(tools);
|
||||
Assert.IsAssignableFrom<AITool>(tools[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SanitizeAndConvert_FunctionTool_PreservesNameAndDescription()
|
||||
{
|
||||
const string ToolJson = @"{""type"":""function"",""name"":""get_weather"",""description"":""Get weather"",""parameters"":{""type"":""object"",""properties"":{}}}";
|
||||
var tool = ModelReaderWriter.Read<ProjectsAgentTool>(BinaryData.FromString(ToolJson))!;
|
||||
|
||||
var aiTool = FoundryToolbox.SanitizeAndConvert(tool);
|
||||
|
||||
Assert.NotNull(aiTool);
|
||||
Assert.IsAssignableFrom<AITool>(aiTool);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SanitizeAndConvert_CodeInterpreterWithExtraFields_StripsDecorationFields()
|
||||
{
|
||||
const string ToolJson = @"{""type"":""code_interpreter"",""name"":""code_interpreter"",""description"":""Execute code""}";
|
||||
var tool = ModelReaderWriter.Read<ProjectsAgentTool>(BinaryData.FromString(ToolJson))!;
|
||||
|
||||
var aiTool = FoundryToolbox.SanitizeAndConvert(tool);
|
||||
|
||||
Assert.NotNull(aiTool);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Integration tests with mock HTTP
|
||||
|
||||
[Fact]
|
||||
public async Task GetToolboxVersionAsync_WithExplicitVersion_FetchesVersionDirectlyAsync()
|
||||
{
|
||||
var versionJson = TestDataUtil.GetToolboxVersionResponseJson();
|
||||
using var httpHandler = new HttpHandlerAssert((request) =>
|
||||
{
|
||||
Assert.Contains("/toolboxes/research_tools/versions/v5", request.RequestUri!.PathAndQuery);
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(versionJson, Encoding.UTF8, "application/json")
|
||||
};
|
||||
});
|
||||
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(httpHandler);
|
||||
#pragma warning restore CA5399
|
||||
var clientOptions = new AgentAdministrationClientOptions { Transport = new HttpClientPipelineTransport(httpClient) };
|
||||
|
||||
var result = await FoundryToolbox.GetToolboxVersionAsync(
|
||||
s_testEndpoint,
|
||||
new FakeAuthenticationTokenProvider(),
|
||||
"research_tools",
|
||||
version: "v5",
|
||||
clientOptions: clientOptions,
|
||||
cancellationToken: default);
|
||||
|
||||
Assert.Equal("research_tools", result.Name);
|
||||
Assert.Equal("v5", result.Version);
|
||||
Assert.Single(result.Tools);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetToolboxVersionAsync_WithoutVersion_ResolvesDefaultThenFetchesAsync()
|
||||
{
|
||||
var recordJson = TestDataUtil.GetToolboxRecordResponseJson();
|
||||
var versionJson = TestDataUtil.GetToolboxVersionResponseJson();
|
||||
var callCount = 0;
|
||||
|
||||
using var httpHandler = new HttpHandlerAssert((request) =>
|
||||
{
|
||||
callCount++;
|
||||
var path = request.RequestUri!.PathAndQuery;
|
||||
|
||||
if (!path.Contains("/versions/"))
|
||||
{
|
||||
Assert.Contains("/toolboxes/research_tools", path);
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(recordJson, Encoding.UTF8, "application/json")
|
||||
};
|
||||
}
|
||||
|
||||
Assert.Contains("/toolboxes/research_tools/versions/v5", path);
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(versionJson, Encoding.UTF8, "application/json")
|
||||
};
|
||||
});
|
||||
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(httpHandler);
|
||||
#pragma warning restore CA5399
|
||||
var clientOptions = new AgentAdministrationClientOptions { Transport = new HttpClientPipelineTransport(httpClient) };
|
||||
|
||||
var result = await FoundryToolbox.GetToolboxVersionAsync(
|
||||
s_testEndpoint,
|
||||
new FakeAuthenticationTokenProvider(),
|
||||
"research_tools",
|
||||
version: null,
|
||||
clientOptions: clientOptions,
|
||||
cancellationToken: default);
|
||||
|
||||
Assert.Equal(2, callCount);
|
||||
Assert.Equal("research_tools", result.Name);
|
||||
Assert.Equal("v5", result.Version);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetToolboxVersionAsync_ApiError_ThrowsClientResultExceptionAsync()
|
||||
{
|
||||
using var httpHandler = new HttpHandlerAssert((_) =>
|
||||
new HttpResponseMessage(HttpStatusCode.NotFound)
|
||||
{
|
||||
Content = new StringContent("{\"error\":\"not found\"}", Encoding.UTF8, "application/json")
|
||||
});
|
||||
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(httpHandler);
|
||||
#pragma warning restore CA5399
|
||||
var clientOptions = new AgentAdministrationClientOptions { Transport = new HttpClientPipelineTransport(httpClient) };
|
||||
|
||||
await Assert.ThrowsAsync<ClientResultException>(() =>
|
||||
FoundryToolbox.GetToolboxVersionAsync(
|
||||
s_testEndpoint,
|
||||
new FakeAuthenticationTokenProvider(),
|
||||
"nonexistent-toolbox",
|
||||
version: "v1",
|
||||
clientOptions: clientOptions,
|
||||
cancellationToken: default));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetToolsAsync_ReturnsConvertedAIToolsAsync()
|
||||
{
|
||||
var versionJson = TestDataUtil.GetToolboxVersionResponseJson();
|
||||
using var httpHandler = new HttpHandlerAssert((_) =>
|
||||
new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(versionJson, Encoding.UTF8, "application/json")
|
||||
});
|
||||
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(httpHandler);
|
||||
#pragma warning restore CA5399
|
||||
var clientOptions = new AgentAdministrationClientOptions { Transport = new HttpClientPipelineTransport(httpClient) };
|
||||
|
||||
var result = await FoundryToolbox.GetToolboxVersionAsync(
|
||||
s_testEndpoint,
|
||||
new FakeAuthenticationTokenProvider(),
|
||||
"research_tools",
|
||||
version: "v5",
|
||||
clientOptions: clientOptions,
|
||||
cancellationToken: default);
|
||||
|
||||
var tools = result.ToAITools();
|
||||
|
||||
Assert.Single(tools);
|
||||
Assert.IsAssignableFrom<AITool>(tools[0]);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region AIProjectClient extension tests
|
||||
|
||||
[Fact]
|
||||
public async Task AIProjectClientExtension_GetToolboxToolsAsync_ReturnsAIToolsAsync()
|
||||
{
|
||||
var versionJson = TestDataUtil.GetToolboxVersionResponseJson();
|
||||
using var httpHandler = new HttpHandlerAssert((_) =>
|
||||
new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(versionJson, Encoding.UTF8, "application/json")
|
||||
});
|
||||
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(httpHandler);
|
||||
#pragma warning restore CA5399
|
||||
var clientOptions = new AIProjectClientOptions();
|
||||
clientOptions.Transport = new HttpClientPipelineTransport(httpClient);
|
||||
var client = new AIProjectClient(s_testEndpoint, new FakeAuthenticationTokenProvider(), clientOptions);
|
||||
|
||||
var tools = await client.GetToolboxToolsAsync("research_tools", version: "v5");
|
||||
|
||||
Assert.Single(tools);
|
||||
Assert.IsAssignableFrom<AITool>(tools[0]);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Azure.AI.AgentServer.Responses;
|
||||
using Azure.AI.AgentServer.Responses.Models;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using Microsoft.Extensions.AI;
|
||||
@@ -146,11 +145,7 @@ public class InputConverterTests
|
||||
[Fact]
|
||||
public void ConvertToChatOptions_SetsTemperatureAndTopP()
|
||||
{
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(
|
||||
temperature: 0.7,
|
||||
topP: 0.9,
|
||||
maxOutputTokens: 1000,
|
||||
model: "gpt-4o");
|
||||
var request = new CreateResponse { Temperature = 0.7, TopP = 0.9, MaxOutputTokens = 1000, Model = "gpt-4o" };
|
||||
|
||||
var options = InputConverter.ConvertToChatOptions(request);
|
||||
|
||||
@@ -211,9 +206,9 @@ public class InputConverterTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertOutputItemsToMessages_FunctionToolCallOutputResource_ReturnsToolMessage()
|
||||
public void ConvertOutputItemsToMessages_FunctionToolCallOutput_ReturnsToolMessage()
|
||||
{
|
||||
var funcOutput = new FunctionToolCallOutputResource(
|
||||
var funcOutput = new OutputItemFunctionToolCallOutput(
|
||||
callId: "call_def",
|
||||
output: BinaryData.FromString("result data"));
|
||||
|
||||
@@ -229,8 +224,7 @@ public class InputConverterTests
|
||||
[Fact]
|
||||
public void ConvertOutputItemsToMessages_ReasoningItem_ReturnsNull()
|
||||
{
|
||||
var reasoning = AzureAIAgentServerResponsesModelFactory.OutputItemReasoningItem(
|
||||
id: "reason_001");
|
||||
var reasoning = new OutputItemReasoningItem("reason_001", []);
|
||||
|
||||
var messages = InputConverter.ConvertOutputItemsToMessages([reasoning]);
|
||||
|
||||
@@ -661,7 +655,7 @@ public class InputConverterTests
|
||||
[Fact]
|
||||
public void ConvertToChatOptions_ModelId_NotSetFromRequest()
|
||||
{
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "my-model");
|
||||
var request = new CreateResponse { Model = "my-model" };
|
||||
|
||||
var options = InputConverter.ConvertToChatOptions(request);
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ public class OutputConverterTests
|
||||
private static (ResponseEventStream stream, Mock<ResponseContext> mockContext) CreateTestStream()
|
||||
{
|
||||
var mockContext = new Mock<ResponseContext>("resp_" + new string('0', 46)) { CallBase = true };
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test-model");
|
||||
var request = new CreateResponse { Model = "test-model" };
|
||||
var stream = new ResponseEventStream(mockContext.Object, request);
|
||||
return (stream, mockContext);
|
||||
}
|
||||
|
||||
+3
-5
@@ -160,9 +160,7 @@ public class WorkflowIntegrationTests
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(
|
||||
model: "test",
|
||||
agentReference: new AgentReference("my-workflow"));
|
||||
var request = new CreateResponse { Model = "test", AgentReference = new AgentReference("my-workflow") };
|
||||
request.Input = CreateUserInput("Test keyed workflow");
|
||||
var mockContext = CreateMockContext();
|
||||
|
||||
@@ -363,7 +361,7 @@ public class WorkflowIntegrationTests
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test");
|
||||
var request = new CreateResponse { Model = "test" };
|
||||
request.Input = CreateUserInput(userMessage);
|
||||
var mockContext = CreateMockContext();
|
||||
|
||||
@@ -393,7 +391,7 @@ public class WorkflowIntegrationTests
|
||||
private static (ResponseEventStream stream, Mock<ResponseContext> mockContext) CreateTestStream()
|
||||
{
|
||||
var mockContext = new Mock<ResponseContext>("resp_" + new string('0', 46)) { CallBase = true };
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test-model");
|
||||
var request = new CreateResponse { Model = "test-model" };
|
||||
var stream = new ResponseEventStream(mockContext.Object, request);
|
||||
return (stream, mockContext);
|
||||
}
|
||||
|
||||
+11
-2
@@ -10,7 +10,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="'$(TargetFrameworkIdentifier)' != '.NETCoreApp'">
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="'$(TargetFrameworkIdentifier)' == '.NETCoreApp'">
|
||||
@@ -34,7 +34,7 @@
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Evaluation tests require net8.0+ (MEAI.Evaluation does not support legacy TFMs) -->
|
||||
<!-- FoundryEval tests require net8.0+ (MEAI.Evaluation does not support legacy TFMs) -->
|
||||
<ItemGroup Condition="!$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0'))">
|
||||
<Compile Remove="FoundryEvalConverterTests.cs" />
|
||||
<Compile Remove="FoundryEvalsTests.cs" />
|
||||
@@ -50,6 +50,15 @@
|
||||
<None Update="TestData\OpenAIDefaultResponse.json">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="TestData\ToolboxRecordResponse.json">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="TestData\ToolboxVersionResponse.json">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="TestData\ToolboxVersionWithDecorationFields.json">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"id": "tbx-123",
|
||||
"name": "research_tools",
|
||||
"default_version": "v5"
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"metadata": {},
|
||||
"id": "tbv-research_tools-v5",
|
||||
"name": "research_tools",
|
||||
"version": "v5",
|
||||
"description": "Example research toolbox",
|
||||
"created_at": 1775779200,
|
||||
"tools": [
|
||||
{ "type": "code_interpreter" }
|
||||
]
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"metadata": {},
|
||||
"id": "tbv-dirty-v1",
|
||||
"name": "dirty_toolbox",
|
||||
"version": "v1",
|
||||
"description": "Toolbox with decoration fields on tools",
|
||||
"created_at": 1775779200,
|
||||
"tools": [
|
||||
{ "type": "code_interpreter", "name": "code_interpreter", "description": "Execute Python code" }
|
||||
]
|
||||
}
|
||||
@@ -14,6 +14,9 @@ internal static class TestDataUtil
|
||||
private static readonly string s_agentResponseJson = File.ReadAllText("TestData/AgentResponse.json");
|
||||
private static readonly string s_agentVersionResponseJson = File.ReadAllText("TestData/AgentVersionResponse.json");
|
||||
private static readonly string s_openAIDefaultResponseJson = File.ReadAllText("TestData/OpenAIDefaultResponse.json");
|
||||
private static readonly string s_toolboxRecordResponseJson = File.ReadAllText("TestData/ToolboxRecordResponse.json");
|
||||
private static readonly string s_toolboxVersionResponseJson = File.ReadAllText("TestData/ToolboxVersionResponse.json");
|
||||
private static readonly string s_toolboxVersionWithDecorationFieldsJson = File.ReadAllText("TestData/ToolboxVersionWithDecorationFields.json");
|
||||
|
||||
private const string AgentDefinitionPlaceholder = "\"agent-definition-placeholder\"";
|
||||
|
||||
@@ -162,4 +165,19 @@ internal static class TestDataUtil
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the toolbox record response JSON.
|
||||
/// </summary>
|
||||
public static string GetToolboxRecordResponseJson() => s_toolboxRecordResponseJson;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the toolbox version response JSON.
|
||||
/// </summary>
|
||||
public static string GetToolboxVersionResponseJson() => s_toolboxVersionResponseJson;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the toolbox version response JSON with decoration fields on tools.
|
||||
/// </summary>
|
||||
public static string GetToolboxVersionWithDecorationFieldsJson() => s_toolboxVersionWithDecorationFieldsJson;
|
||||
}
|
||||
|
||||
@@ -586,6 +586,457 @@ public sealed class A2AAgentHandlerTests
|
||||
|
||||
#pragma warning restore MEAI001
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that in streaming mode, each update from RunStreamingAsync produces a message event.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_Streaming_EnqueuesMessageForEachUpdateAsync()
|
||||
{
|
||||
// Arrange
|
||||
AgentResponseUpdate[] updates =
|
||||
[
|
||||
new AgentResponseUpdate(ChatRole.Assistant, "chunk 1") { ResponseId = "r1" },
|
||||
new AgentResponseUpdate(ChatRole.Assistant, "chunk 2") { ResponseId = "r2" }
|
||||
];
|
||||
A2AAgentHandler handler = CreateHandler(CreateStreamingAgentMock(updates));
|
||||
|
||||
// Act
|
||||
var events = await CollectEventsAsync(handler, new RequestContext
|
||||
{
|
||||
StreamingResponse = true,
|
||||
TaskId = "",
|
||||
ContextId = "ctx",
|
||||
Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, events.Messages.Count);
|
||||
Assert.Equal("chunk 1", events.Messages[0].Parts![0].Text);
|
||||
Assert.Equal("chunk 2", events.Messages[1].Parts![0].Text);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that in streaming mode, when metadata is present, options with AdditionalProperties
|
||||
/// are passed to RunStreamingAsync.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_Streaming_WithMetadata_PassesOptionsWithAdditionalPropertiesAsync()
|
||||
{
|
||||
// Arrange
|
||||
AgentRunOptions? capturedOptions = null;
|
||||
A2AAgentHandler handler = CreateHandler(CreateStreamingAgentMockWithOptionsCapture(
|
||||
options => capturedOptions = options));
|
||||
|
||||
// Act
|
||||
await InvokeExecuteAsync(handler, new RequestContext
|
||||
{
|
||||
StreamingResponse = true,
|
||||
TaskId = "",
|
||||
ContextId = "ctx",
|
||||
Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] },
|
||||
Metadata = new Dictionary<string, JsonElement>
|
||||
{
|
||||
["key1"] = JsonSerializer.SerializeToElement("value1")
|
||||
}
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedOptions);
|
||||
Assert.NotNull(capturedOptions.AdditionalProperties);
|
||||
Assert.Equal("value1", capturedOptions.AdditionalProperties["key1"]?.ToString());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that in streaming mode, when metadata is null, null options are passed to RunStreamingAsync.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_Streaming_WithNullMetadata_PassesNullOptionsAsync()
|
||||
{
|
||||
// Arrange
|
||||
AgentRunOptions? capturedOptions = null;
|
||||
bool optionsCaptured = false;
|
||||
A2AAgentHandler handler = CreateHandler(CreateStreamingAgentMockWithOptionsCapture(
|
||||
options => { capturedOptions = options; optionsCaptured = true; }));
|
||||
|
||||
// Act
|
||||
await InvokeExecuteAsync(handler, new RequestContext
|
||||
{
|
||||
StreamingResponse = true,
|
||||
TaskId = "",
|
||||
ContextId = "ctx",
|
||||
Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.True(optionsCaptured);
|
||||
Assert.Null(capturedOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that in streaming mode, ReferenceTaskIds throws NotSupportedException.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_Streaming_WithReferenceTaskIds_ThrowsNotSupportedExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
A2AAgentHandler handler = CreateHandler(CreateStreamingAgentMock([]));
|
||||
|
||||
// Act & Assert
|
||||
var eventQueue = new AgentEventQueue();
|
||||
await Assert.ThrowsAsync<NotSupportedException>(() =>
|
||||
handler.ExecuteAsync(
|
||||
new RequestContext
|
||||
{
|
||||
StreamingResponse = true,
|
||||
TaskId = "",
|
||||
ContextId = "ctx",
|
||||
Message = new Message
|
||||
{
|
||||
MessageId = "test-id",
|
||||
Role = Role.User,
|
||||
Parts = [new Part { Text = "Hello" }],
|
||||
ReferenceTaskIds = ["other-task-id"]
|
||||
}
|
||||
},
|
||||
eventQueue,
|
||||
CancellationToken.None));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that in streaming mode, when ContextId is null, a new one is generated.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_Streaming_WhenContextIdIsNull_GeneratesContextIdAsync()
|
||||
{
|
||||
// Arrange
|
||||
AgentResponseUpdate[] updates =
|
||||
[
|
||||
new AgentResponseUpdate(ChatRole.Assistant, "Reply") { ResponseId = "r1" }
|
||||
];
|
||||
A2AAgentHandler handler = CreateHandler(CreateStreamingAgentMock(updates));
|
||||
|
||||
// Act
|
||||
var events = await CollectEventsAsync(handler, new RequestContext
|
||||
{
|
||||
StreamingResponse = true,
|
||||
TaskId = "",
|
||||
ContextId = null!,
|
||||
Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
|
||||
});
|
||||
|
||||
// Assert
|
||||
Message message = Assert.Single(events.Messages);
|
||||
Assert.NotNull(message.ContextId);
|
||||
Assert.NotEmpty(message.ContextId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that in streaming mode, the provided ContextId is used in the response.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_Streaming_UsesProvidedContextIdAsync()
|
||||
{
|
||||
// Arrange
|
||||
AgentResponseUpdate[] updates =
|
||||
[
|
||||
new AgentResponseUpdate(ChatRole.Assistant, "Reply") { ResponseId = "r1" }
|
||||
];
|
||||
A2AAgentHandler handler = CreateHandler(CreateStreamingAgentMock(updates));
|
||||
|
||||
// Act
|
||||
var events = await CollectEventsAsync(handler, new RequestContext
|
||||
{
|
||||
StreamingResponse = true,
|
||||
TaskId = "",
|
||||
ContextId = "my-streaming-ctx",
|
||||
Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
|
||||
});
|
||||
|
||||
// Assert
|
||||
Message message = Assert.Single(events.Messages);
|
||||
Assert.Equal("my-streaming-ctx", message.ContextId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that in streaming mode, when Message is null, the handler succeeds with empty messages.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_Streaming_WhenMessageIsNull_SucceedsWithEmptyMessagesAsync()
|
||||
{
|
||||
// Arrange
|
||||
AgentResponseUpdate[] updates =
|
||||
[
|
||||
new AgentResponseUpdate(ChatRole.Assistant, "Reply") { ResponseId = "r1" }
|
||||
];
|
||||
A2AAgentHandler handler = CreateHandler(CreateStreamingAgentMock(updates));
|
||||
|
||||
// Act
|
||||
var events = await CollectEventsAsync(handler, new RequestContext
|
||||
{
|
||||
StreamingResponse = true,
|
||||
TaskId = "",
|
||||
ContextId = "ctx",
|
||||
Message = null!
|
||||
});
|
||||
|
||||
// Assert
|
||||
Message message = Assert.Single(events.Messages);
|
||||
Assert.Equal("ctx", message.ContextId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that in streaming mode, the ResponseId from the update is used as the MessageId in the response.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_Streaming_ResponseIdIsUsedAsMessageIdAsync()
|
||||
{
|
||||
// Arrange
|
||||
AgentResponseUpdate[] updates =
|
||||
[
|
||||
new AgentResponseUpdate(ChatRole.Assistant, "chunk") { ResponseId = "resp-42" }
|
||||
];
|
||||
A2AAgentHandler handler = CreateHandler(CreateStreamingAgentMock(updates));
|
||||
|
||||
// Act
|
||||
var events = await CollectEventsAsync(handler, new RequestContext
|
||||
{
|
||||
StreamingResponse = true,
|
||||
TaskId = "",
|
||||
ContextId = "ctx",
|
||||
Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
|
||||
});
|
||||
|
||||
// Assert
|
||||
Message message = Assert.Single(events.Messages);
|
||||
Assert.Equal("resp-42", message.MessageId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that in streaming mode, when ResponseId is null, a MessageId is still generated.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_Streaming_WhenResponseIdIsNull_GeneratesMessageIdAsync()
|
||||
{
|
||||
// Arrange
|
||||
AgentResponseUpdate[] updates =
|
||||
[
|
||||
new AgentResponseUpdate(ChatRole.Assistant, "chunk") { ResponseId = null }
|
||||
];
|
||||
A2AAgentHandler handler = CreateHandler(CreateStreamingAgentMock(updates));
|
||||
|
||||
// Act
|
||||
var events = await CollectEventsAsync(handler, new RequestContext
|
||||
{
|
||||
StreamingResponse = true,
|
||||
TaskId = "",
|
||||
ContextId = "ctx",
|
||||
Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
|
||||
});
|
||||
|
||||
// Assert
|
||||
Message message = Assert.Single(events.Messages);
|
||||
Assert.NotNull(message.MessageId);
|
||||
Assert.NotEmpty(message.MessageId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that in streaming mode, when the update has AdditionalProperties, the message has metadata.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_Streaming_WithResponseAdditionalProperties_ReturnsMessageWithMetadataAsync()
|
||||
{
|
||||
// Arrange
|
||||
AdditionalPropertiesDictionary additionalProps = new()
|
||||
{
|
||||
["streamKey"] = "streamValue"
|
||||
};
|
||||
AgentResponseUpdate[] updates =
|
||||
[
|
||||
new AgentResponseUpdate(ChatRole.Assistant, "chunk") { ResponseId = "r1", AdditionalProperties = additionalProps }
|
||||
];
|
||||
A2AAgentHandler handler = CreateHandler(CreateStreamingAgentMock(updates));
|
||||
|
||||
// Act
|
||||
var events = await CollectEventsAsync(handler, new RequestContext
|
||||
{
|
||||
StreamingResponse = true,
|
||||
TaskId = "",
|
||||
ContextId = "ctx",
|
||||
Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
|
||||
});
|
||||
|
||||
// Assert
|
||||
Message message = Assert.Single(events.Messages);
|
||||
Assert.NotNull(message.Metadata);
|
||||
Assert.True(message.Metadata.ContainsKey("streamKey"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that in streaming mode, when the update has null AdditionalProperties, the message has null metadata.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_Streaming_WithNullAdditionalProperties_ReturnsMessageWithNullMetadataAsync()
|
||||
{
|
||||
// Arrange
|
||||
AgentResponseUpdate[] updates =
|
||||
[
|
||||
new AgentResponseUpdate(ChatRole.Assistant, "chunk") { ResponseId = "r1", AdditionalProperties = null }
|
||||
];
|
||||
A2AAgentHandler handler = CreateHandler(CreateStreamingAgentMock(updates));
|
||||
|
||||
// Act
|
||||
var events = await CollectEventsAsync(handler, new RequestContext
|
||||
{
|
||||
StreamingResponse = true,
|
||||
TaskId = "",
|
||||
ContextId = "ctx",
|
||||
Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
|
||||
});
|
||||
|
||||
// Assert
|
||||
Message message = Assert.Single(events.Messages);
|
||||
Assert.Null(message.Metadata);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that in streaming mode, the session is saved after all updates are processed.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_Streaming_SavesSessionAfterProcessingAsync()
|
||||
{
|
||||
// Arrange
|
||||
var mockSessionStore = new Mock<AgentSessionStore>();
|
||||
mockSessionStore
|
||||
.Setup(x => x.GetSessionAsync(
|
||||
It.IsAny<AIAgent>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new TestAgentSession());
|
||||
mockSessionStore
|
||||
.Setup(x => x.SaveSessionAsync(
|
||||
It.IsAny<AIAgent>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<AgentSession>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Returns(ValueTask.CompletedTask);
|
||||
|
||||
AgentResponseUpdate[] updates =
|
||||
[
|
||||
new AgentResponseUpdate(ChatRole.Assistant, "chunk") { ResponseId = "r1" }
|
||||
];
|
||||
A2AAgentHandler handler = CreateHandler(CreateStreamingAgentMock(updates), agentSessionStore: mockSessionStore.Object);
|
||||
|
||||
// Act
|
||||
await InvokeExecuteAsync(handler, new RequestContext
|
||||
{
|
||||
StreamingResponse = true,
|
||||
TaskId = "",
|
||||
ContextId = "ctx-stream",
|
||||
Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
|
||||
});
|
||||
|
||||
// Assert - verify session was saved
|
||||
mockSessionStore.Verify(
|
||||
x => x.SaveSessionAsync(
|
||||
It.IsAny<AIAgent>(),
|
||||
It.Is<string>(s => s == "ctx-stream"),
|
||||
It.IsAny<AgentSession>(),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that in streaming mode, when RunStreamingAsync yields no updates,
|
||||
/// no messages are enqueued and the session is still saved.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_Streaming_WhenNoUpdates_EnqueuesNoMessagesAndSavesSessionAsync()
|
||||
{
|
||||
// Arrange
|
||||
var mockSessionStore = new Mock<AgentSessionStore>();
|
||||
mockSessionStore
|
||||
.Setup(x => x.GetSessionAsync(
|
||||
It.IsAny<AIAgent>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new TestAgentSession());
|
||||
mockSessionStore
|
||||
.Setup(x => x.SaveSessionAsync(
|
||||
It.IsAny<AIAgent>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<AgentSession>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Returns(ValueTask.CompletedTask);
|
||||
|
||||
A2AAgentHandler handler = CreateHandler(CreateStreamingAgentMock([]), agentSessionStore: mockSessionStore.Object);
|
||||
|
||||
// Act
|
||||
var events = await CollectEventsAsync(handler, new RequestContext
|
||||
{
|
||||
StreamingResponse = true,
|
||||
TaskId = "",
|
||||
ContextId = "ctx",
|
||||
Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.Empty(events.Messages);
|
||||
mockSessionStore.Verify(
|
||||
x => x.SaveSessionAsync(
|
||||
It.IsAny<AIAgent>(),
|
||||
It.Is<string>(s => s == "ctx"),
|
||||
It.IsAny<AgentSession>(),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the CancellationToken is propagated to RunStreamingAsync in the streaming path.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_Streaming_CancellationTokenIsPropagatedToRunStreamingAsync()
|
||||
{
|
||||
// Arrange
|
||||
CancellationToken capturedToken = default;
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
Mock<AIAgent> agentMock = new() { CallBase = true };
|
||||
agentMock.SetupGet(x => x.Name).Returns("TestAgent");
|
||||
agentMock
|
||||
.Protected()
|
||||
.Setup<ValueTask<AgentSession>>("CreateSessionCoreAsync", ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(new TestAgentSession());
|
||||
agentMock
|
||||
.Protected()
|
||||
.Setup<IAsyncEnumerable<AgentResponseUpdate>>("RunCoreStreamingAsync",
|
||||
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
|
||||
ItExpr.IsAny<AgentSession?>(),
|
||||
ItExpr.IsAny<AgentRunOptions?>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Callback<IEnumerable<ChatMessage>, AgentSession?, AgentRunOptions?, CancellationToken>(
|
||||
(_, _, _, ct) => capturedToken = ct)
|
||||
.Returns(() => ToAsyncEnumerableAsync([new AgentResponseUpdate(ChatRole.Assistant, "reply") { ResponseId = "r1" }]));
|
||||
|
||||
A2AAgentHandler handler = CreateHandler(agentMock);
|
||||
|
||||
// Act
|
||||
var eventQueue = new AgentEventQueue();
|
||||
await handler.ExecuteAsync(
|
||||
new RequestContext
|
||||
{
|
||||
TaskId = "",
|
||||
ContextId = "ctx",
|
||||
StreamingResponse = true,
|
||||
Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
|
||||
},
|
||||
eventQueue,
|
||||
cts.Token);
|
||||
eventQueue.Complete(null);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(cts.Token, capturedToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when no session store is provided, the handler uses InMemoryAgentSessionStore
|
||||
/// and can execute successfully.
|
||||
@@ -821,6 +1272,308 @@ public sealed class A2AAgentHandlerTests
|
||||
Assert.True(capturedOptions.AllowBackgroundResponses);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that in the non-streaming path, SaveSessionAsync is called with
|
||||
/// CancellationToken.None even when RunAsync throws an exception.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_NonStreaming_WhenRunAsyncThrows_SavesSessionWithUncancelledTokenAsync()
|
||||
{
|
||||
// Arrange
|
||||
var mockSessionStore = new Mock<AgentSessionStore>();
|
||||
mockSessionStore
|
||||
.Setup(x => x.GetSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new TestAgentSession());
|
||||
mockSessionStore
|
||||
.Setup(x => x.SaveSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<AgentSession>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(ValueTask.CompletedTask);
|
||||
|
||||
Mock<AIAgent> agentMock = new() { CallBase = true };
|
||||
agentMock.SetupGet(x => x.Name).Returns("TestAgent");
|
||||
agentMock.Protected()
|
||||
.Setup<ValueTask<AgentSession>>("CreateSessionCoreAsync", ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(new TestAgentSession());
|
||||
agentMock.Protected()
|
||||
.Setup<Task<AgentResponse>>("RunCoreAsync",
|
||||
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
|
||||
ItExpr.IsAny<AgentSession?>(),
|
||||
ItExpr.IsAny<AgentRunOptions?>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ThrowsAsync(new InvalidOperationException("Agent failed"));
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
A2AAgentHandler handler = CreateHandler(agentMock, agentSessionStore: mockSessionStore.Object);
|
||||
|
||||
// Act
|
||||
var eventQueue = new AgentEventQueue();
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
handler.ExecuteAsync(
|
||||
new RequestContext
|
||||
{
|
||||
TaskId = "", ContextId = "ctx", StreamingResponse = false,
|
||||
Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
|
||||
},
|
||||
eventQueue,
|
||||
cts.Token));
|
||||
|
||||
// Assert - SaveSessionAsync was called with CancellationToken.None despite the exception
|
||||
mockSessionStore.Verify(
|
||||
x => x.SaveSessionAsync(
|
||||
It.IsAny<AIAgent>(),
|
||||
It.Is<string>(s => s == "ctx"),
|
||||
It.IsAny<AgentSession>(),
|
||||
It.Is<CancellationToken>(ct => ct == CancellationToken.None)),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that in the streaming path, SaveSessionAsync is called with
|
||||
/// CancellationToken.None even when RunStreamingAsync throws an exception.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_Streaming_WhenRunStreamingAsyncThrows_SavesSessionWithUncancelledTokenAsync()
|
||||
{
|
||||
// Arrange
|
||||
var mockSessionStore = new Mock<AgentSessionStore>();
|
||||
mockSessionStore
|
||||
.Setup(x => x.GetSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new TestAgentSession());
|
||||
mockSessionStore
|
||||
.Setup(x => x.SaveSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<AgentSession>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(ValueTask.CompletedTask);
|
||||
|
||||
Mock<AIAgent> agentMock = new() { CallBase = true };
|
||||
agentMock.SetupGet(x => x.Name).Returns("TestAgent");
|
||||
agentMock.Protected()
|
||||
.Setup<ValueTask<AgentSession>>("CreateSessionCoreAsync", ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(new TestAgentSession());
|
||||
agentMock.Protected()
|
||||
.Setup<IAsyncEnumerable<AgentResponseUpdate>>("RunCoreStreamingAsync",
|
||||
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
|
||||
ItExpr.IsAny<AgentSession?>(),
|
||||
ItExpr.IsAny<AgentRunOptions?>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Returns(() => ToThrowingAsyncEnumerableAsync(new InvalidOperationException("Stream failed")));
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
A2AAgentHandler handler = CreateHandler(agentMock, agentSessionStore: mockSessionStore.Object);
|
||||
|
||||
// Act
|
||||
var eventQueue = new AgentEventQueue();
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
handler.ExecuteAsync(
|
||||
new RequestContext
|
||||
{
|
||||
TaskId = "", ContextId = "ctx-stream", StreamingResponse = true,
|
||||
Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
|
||||
},
|
||||
eventQueue,
|
||||
cts.Token));
|
||||
|
||||
// Assert - SaveSessionAsync was called with CancellationToken.None despite the exception
|
||||
mockSessionStore.Verify(
|
||||
x => x.SaveSessionAsync(
|
||||
It.IsAny<AIAgent>(),
|
||||
It.Is<string>(s => s == "ctx-stream"),
|
||||
It.IsAny<AgentSession>(),
|
||||
It.Is<CancellationToken>(ct => ct == CancellationToken.None)),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that on the continuation path, SaveSessionAsync is called with
|
||||
/// CancellationToken.None even when RunAsync throws an exception.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_OnContinuation_WhenRunAsyncThrows_SavesSessionWithUncancelledTokenAsync()
|
||||
{
|
||||
// Arrange
|
||||
var mockSessionStore = new Mock<AgentSessionStore>();
|
||||
mockSessionStore
|
||||
.Setup(x => x.GetSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new TestAgentSession());
|
||||
mockSessionStore
|
||||
.Setup(x => x.SaveSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<AgentSession>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(ValueTask.CompletedTask);
|
||||
|
||||
Mock<AIAgent> agentMock = new() { CallBase = true };
|
||||
agentMock.SetupGet(x => x.Name).Returns("TestAgent");
|
||||
agentMock.Protected()
|
||||
.Setup<ValueTask<AgentSession>>("CreateSessionCoreAsync", ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(new TestAgentSession());
|
||||
agentMock.Protected()
|
||||
.Setup<Task<AgentResponse>>("RunCoreAsync",
|
||||
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
|
||||
ItExpr.IsAny<AgentSession?>(),
|
||||
ItExpr.IsAny<AgentRunOptions?>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ThrowsAsync(new InvalidOperationException("Agent failed"));
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
A2AAgentHandler handler = CreateHandler(agentMock, agentSessionStore: mockSessionStore.Object);
|
||||
|
||||
// Act
|
||||
var eventQueue = new AgentEventQueue();
|
||||
var events = new EventCollector();
|
||||
var readerTask = ReadEventsAsync(eventQueue, events);
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
handler.ExecuteAsync(
|
||||
new RequestContext
|
||||
{
|
||||
StreamingResponse = false,
|
||||
TaskId = "task-1", ContextId = "ctx-cont",
|
||||
Message = new Message { MessageId = "empty", Role = Role.User, Parts = [] },
|
||||
Task = new AgentTask { Id = "task-1", ContextId = "ctx-cont", History = [new Message { Role = Role.User, Parts = [new Part { Text = "Hello" }] }] }
|
||||
},
|
||||
eventQueue,
|
||||
cts.Token));
|
||||
eventQueue.Complete(null);
|
||||
await readerTask;
|
||||
|
||||
// Assert - SaveSessionAsync was called with CancellationToken.None despite the exception
|
||||
mockSessionStore.Verify(
|
||||
x => x.SaveSessionAsync(
|
||||
It.IsAny<AIAgent>(),
|
||||
It.Is<string>(s => s == "ctx-cont"),
|
||||
It.IsAny<AgentSession>(),
|
||||
It.Is<CancellationToken>(ct => ct == CancellationToken.None)),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that in the non-streaming path, SaveSessionAsync is called with
|
||||
/// CancellationToken.None rather than the caller's cancellation token.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_NonStreaming_SavesSessionWithUncancelledTokenAsync()
|
||||
{
|
||||
// Arrange
|
||||
var mockSessionStore = new Mock<AgentSessionStore>();
|
||||
mockSessionStore
|
||||
.Setup(x => x.GetSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new TestAgentSession());
|
||||
mockSessionStore
|
||||
.Setup(x => x.SaveSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<AgentSession>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(ValueTask.CompletedTask);
|
||||
|
||||
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Reply")]);
|
||||
A2AAgentHandler handler = CreateHandler(CreateAgentMockWithResponse(response), agentSessionStore: mockSessionStore.Object);
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
// Act
|
||||
var eventQueue = new AgentEventQueue();
|
||||
await handler.ExecuteAsync(
|
||||
new RequestContext
|
||||
{
|
||||
TaskId = "", ContextId = "ctx", StreamingResponse = false,
|
||||
Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
|
||||
},
|
||||
eventQueue,
|
||||
cts.Token);
|
||||
eventQueue.Complete(null);
|
||||
|
||||
// Assert - SaveSessionAsync was called with CancellationToken.None, not the caller's token
|
||||
mockSessionStore.Verify(
|
||||
x => x.SaveSessionAsync(
|
||||
It.IsAny<AIAgent>(),
|
||||
It.Is<string>(s => s == "ctx"),
|
||||
It.IsAny<AgentSession>(),
|
||||
It.Is<CancellationToken>(ct => ct == CancellationToken.None)),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that in the streaming path, SaveSessionAsync is called with
|
||||
/// CancellationToken.None rather than the caller's cancellation token.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_Streaming_SavesSessionWithUncancelledTokenAsync()
|
||||
{
|
||||
// Arrange
|
||||
var mockSessionStore = new Mock<AgentSessionStore>();
|
||||
mockSessionStore
|
||||
.Setup(x => x.GetSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new TestAgentSession());
|
||||
mockSessionStore
|
||||
.Setup(x => x.SaveSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<AgentSession>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(ValueTask.CompletedTask);
|
||||
|
||||
AgentResponseUpdate[] updates = [new AgentResponseUpdate(ChatRole.Assistant, "chunk") { ResponseId = "r1" }];
|
||||
A2AAgentHandler handler = CreateHandler(CreateStreamingAgentMock(updates), agentSessionStore: mockSessionStore.Object);
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
// Act
|
||||
var eventQueue = new AgentEventQueue();
|
||||
await handler.ExecuteAsync(
|
||||
new RequestContext
|
||||
{
|
||||
TaskId = "", ContextId = "ctx-stream", StreamingResponse = true,
|
||||
Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
|
||||
},
|
||||
eventQueue,
|
||||
cts.Token);
|
||||
eventQueue.Complete(null);
|
||||
|
||||
// Assert - SaveSessionAsync was called with CancellationToken.None, not the caller's token
|
||||
mockSessionStore.Verify(
|
||||
x => x.SaveSessionAsync(
|
||||
It.IsAny<AIAgent>(),
|
||||
It.Is<string>(s => s == "ctx-stream"),
|
||||
It.IsAny<AgentSession>(),
|
||||
It.Is<CancellationToken>(ct => ct == CancellationToken.None)),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that on the continuation path, SaveSessionAsync is called with
|
||||
/// CancellationToken.None rather than the caller's cancellation token.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_OnContinuation_SavesSessionWithUncancelledTokenAsync()
|
||||
{
|
||||
// Arrange
|
||||
var mockSessionStore = new Mock<AgentSessionStore>();
|
||||
mockSessionStore
|
||||
.Setup(x => x.GetSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new TestAgentSession());
|
||||
mockSessionStore
|
||||
.Setup(x => x.SaveSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<AgentSession>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(ValueTask.CompletedTask);
|
||||
|
||||
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Done!")]);
|
||||
A2AAgentHandler handler = CreateHandler(CreateAgentMockWithResponse(response), agentSessionStore: mockSessionStore.Object);
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
// Act
|
||||
var eventQueue = new AgentEventQueue();
|
||||
var events = new EventCollector();
|
||||
var readerTask = ReadEventsAsync(eventQueue, events);
|
||||
await handler.ExecuteAsync(
|
||||
new RequestContext
|
||||
{
|
||||
StreamingResponse = false,
|
||||
TaskId = "task-1", ContextId = "ctx-cont",
|
||||
Message = new Message { MessageId = "empty", Role = Role.User, Parts = [] },
|
||||
Task = new AgentTask { Id = "task-1", ContextId = "ctx-cont", History = [new Message { Role = Role.User, Parts = [new Part { Text = "Hello" }] }] }
|
||||
},
|
||||
eventQueue,
|
||||
cts.Token);
|
||||
eventQueue.Complete(null);
|
||||
await readerTask;
|
||||
|
||||
// Assert - SaveSessionAsync was called with CancellationToken.None, not the caller's token
|
||||
mockSessionStore.Verify(
|
||||
x => x.SaveSessionAsync(
|
||||
It.IsAny<AIAgent>(),
|
||||
It.Is<string>(s => s == "ctx-cont"),
|
||||
It.IsAny<AgentSession>(),
|
||||
It.Is<CancellationToken>(ct => ct == CancellationToken.None)),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
private static A2AAgentHandler CreateHandler(
|
||||
Mock<AIAgent> agentMock,
|
||||
AgentRunMode? runMode = null,
|
||||
@@ -905,6 +1658,68 @@ public sealed class A2AAgentHandlerTests
|
||||
return agentMock;
|
||||
}
|
||||
|
||||
private static Mock<AIAgent> CreateStreamingAgentMock(IEnumerable<AgentResponseUpdate> updates)
|
||||
{
|
||||
Mock<AIAgent> agentMock = new() { CallBase = true };
|
||||
agentMock.SetupGet(x => x.Name).Returns("TestAgent");
|
||||
agentMock
|
||||
.Protected()
|
||||
.Setup<ValueTask<AgentSession>>("CreateSessionCoreAsync", ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(new TestAgentSession());
|
||||
agentMock
|
||||
.Protected()
|
||||
.Setup<IAsyncEnumerable<AgentResponseUpdate>>("RunCoreStreamingAsync",
|
||||
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
|
||||
ItExpr.IsAny<AgentSession?>(),
|
||||
ItExpr.IsAny<AgentRunOptions?>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Returns(() => ToAsyncEnumerableAsync(updates));
|
||||
|
||||
return agentMock;
|
||||
}
|
||||
|
||||
private static Mock<AIAgent> CreateStreamingAgentMockWithOptionsCapture(
|
||||
Action<AgentRunOptions?> optionsCallback)
|
||||
{
|
||||
Mock<AIAgent> agentMock = new() { CallBase = true };
|
||||
agentMock.SetupGet(x => x.Name).Returns("TestAgent");
|
||||
agentMock
|
||||
.Protected()
|
||||
.Setup<ValueTask<AgentSession>>("CreateSessionCoreAsync", ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(new TestAgentSession());
|
||||
agentMock
|
||||
.Protected()
|
||||
.Setup<IAsyncEnumerable<AgentResponseUpdate>>("RunCoreStreamingAsync",
|
||||
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
|
||||
ItExpr.IsAny<AgentSession?>(),
|
||||
ItExpr.IsAny<AgentRunOptions?>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Callback<IEnumerable<ChatMessage>, AgentSession?, AgentRunOptions?, CancellationToken>(
|
||||
(_, _, options, _) => optionsCallback(options))
|
||||
.Returns(() => ToAsyncEnumerableAsync([new AgentResponseUpdate(ChatRole.Assistant, "reply") { ResponseId = "r1" }]));
|
||||
|
||||
return agentMock;
|
||||
}
|
||||
|
||||
private static async IAsyncEnumerable<T> ToAsyncEnumerableAsync<T>(IEnumerable<T> items)
|
||||
{
|
||||
await Task.Yield();
|
||||
foreach (var item in items)
|
||||
{
|
||||
yield return item;
|
||||
}
|
||||
}
|
||||
|
||||
private static async IAsyncEnumerable<AgentResponseUpdate> ToThrowingAsyncEnumerableAsync(Exception exception)
|
||||
{
|
||||
await Task.Yield();
|
||||
throw exception;
|
||||
|
||||
#pragma warning disable CS0162 // Unreachable code detected - yield is required for async iterator
|
||||
yield break;
|
||||
#pragma warning restore CS0162
|
||||
}
|
||||
|
||||
private static async Task InvokeExecuteAsync(A2AAgentHandler handler, RequestContext context)
|
||||
{
|
||||
var eventQueue = new AgentEventQueue();
|
||||
|
||||
+63
@@ -147,4 +147,67 @@ public class MessageConverterTests
|
||||
Assert.Equal("First message", result[0].Text);
|
||||
Assert.Equal("Second message", result[1].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToParts_AgentResponseUpdate_WithNoContents_ReturnsEmptyList()
|
||||
{
|
||||
// Arrange
|
||||
var update = new AgentResponseUpdate();
|
||||
|
||||
// Act
|
||||
var result = update.ToParts();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToParts_AgentResponseUpdate_WithTextContent_ReturnsTextPart()
|
||||
{
|
||||
// Arrange
|
||||
var update = new AgentResponseUpdate(ChatRole.Assistant, "Hello from streaming!");
|
||||
|
||||
// Act
|
||||
var result = update.ToParts();
|
||||
|
||||
// Assert
|
||||
Assert.Single(result);
|
||||
Assert.Equal("Hello from streaming!", result[0].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToParts_AgentResponseUpdate_WithMultipleContents_ReturnsAllParts()
|
||||
{
|
||||
// Arrange
|
||||
var update = new AgentResponseUpdate(ChatRole.Assistant, [
|
||||
new TextContent("First chunk"),
|
||||
new TextContent("Second chunk")
|
||||
]);
|
||||
|
||||
// Act
|
||||
var result = update.ToParts();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, result.Count);
|
||||
Assert.Equal("First chunk", result[0].Text);
|
||||
Assert.Equal("Second chunk", result[1].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToParts_AgentResponseUpdate_WithUnsupportedContent_FiltersOutNulls()
|
||||
{
|
||||
// Arrange - FunctionCallContent maps to null Part since it's not a supported A2A content type
|
||||
var update = new AgentResponseUpdate(ChatRole.Assistant, [
|
||||
new TextContent("Supported text"),
|
||||
new FunctionCallContent("call-1", "myFunction")
|
||||
]);
|
||||
|
||||
// Act
|
||||
var result = update.ToParts();
|
||||
|
||||
// Assert - only the text part should be returned
|
||||
Assert.Single(result);
|
||||
Assert.Equal("Supported text", result[0].Text);
|
||||
}
|
||||
}
|
||||
|
||||
+40
@@ -3,6 +3,7 @@
|
||||
using System.Diagnostics;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ModelContextProtocol.Client;
|
||||
@@ -125,6 +126,45 @@ public sealed class WorkflowSamplesValidation(ITestOutputHelper outputHelper) :
|
||||
},
|
||||
message: "OrderStatus workflow completed",
|
||||
timeout: s_orchestrationTimeout);
|
||||
|
||||
// Test the CancelOrder workflow with x-ms-wait-for-response header
|
||||
this._outputHelper.WriteLine("Starting CancelOrder workflow with x-ms-wait-for-response: true...");
|
||||
|
||||
using HttpRequestMessage waitRequest = new(HttpMethod.Post, cancelOrderUri);
|
||||
waitRequest.Content = new StringContent("55555", Encoding.UTF8, "text/plain");
|
||||
waitRequest.Headers.Add("x-ms-wait-for-response", "true");
|
||||
using HttpResponseMessage waitResponse = await s_sharedHttpClient.SendAsync(waitRequest);
|
||||
|
||||
Assert.True(waitResponse.IsSuccessStatusCode, $"CancelOrder wait-for-response request failed with status: {waitResponse.StatusCode}");
|
||||
string waitResponseText = await waitResponse.Content.ReadAsStringAsync();
|
||||
this._outputHelper.WriteLine($"CancelOrder wait-for-response result: {waitResponseText}");
|
||||
|
||||
// The response should contain the workflow result (not just "started for CancelOrder")
|
||||
Assert.DoesNotContain("Workflow orchestration started", waitResponseText);
|
||||
Assert.Contains("55555", waitResponseText);
|
||||
|
||||
// Test the wait-for-response with Accept: application/json header
|
||||
this._outputHelper.WriteLine("Starting CancelOrder workflow with x-ms-wait-for-response and Accept: application/json...");
|
||||
|
||||
using HttpRequestMessage jsonWaitRequest = new(HttpMethod.Post, cancelOrderUri);
|
||||
jsonWaitRequest.Content = new StringContent("77777", Encoding.UTF8, "text/plain");
|
||||
jsonWaitRequest.Headers.Add("x-ms-wait-for-response", "true");
|
||||
jsonWaitRequest.Headers.Add("Accept", "application/json");
|
||||
|
||||
using CancellationTokenSource jsonWaitCts = new(s_orchestrationTimeout);
|
||||
using HttpResponseMessage jsonWaitResponse = await s_sharedHttpClient.SendAsync(jsonWaitRequest, jsonWaitCts.Token);
|
||||
|
||||
Assert.True(jsonWaitResponse.IsSuccessStatusCode, $"CancelOrder JSON wait-for-response request failed with status: {jsonWaitResponse.StatusCode}");
|
||||
string jsonWaitResponseText = await jsonWaitResponse.Content.ReadAsStringAsync();
|
||||
this._outputHelper.WriteLine($"CancelOrder JSON wait-for-response result: {jsonWaitResponseText}");
|
||||
|
||||
using JsonDocument jsonDoc = JsonDocument.Parse(jsonWaitResponseText);
|
||||
JsonElement root = jsonDoc.RootElement;
|
||||
Assert.True(root.TryGetProperty("runId", out _), "JSON response missing 'runId' property");
|
||||
Assert.True(root.TryGetProperty("workflowStatus", out JsonElement statusEl), "JSON response missing 'workflowStatus' property");
|
||||
Assert.Equal("Completed", statusEl.GetString());
|
||||
Assert.True(root.TryGetProperty("result", out JsonElement resultEl), "JSON response missing 'result' property");
|
||||
Assert.Contains("77777", resultEl.GetString());
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
|
||||
@@ -128,8 +127,9 @@ public sealed class AgentClassSkillTests
|
||||
// Act — script with custom type deserialization
|
||||
var script = skill.Scripts![0];
|
||||
var inputJson = JsonSerializer.SerializeToElement(new LookupRequest { Query = "test", MaxResults = 5 }, jso);
|
||||
var args = new AIFunctionArguments { ["request"] = inputJson };
|
||||
var scriptResult = await script.RunAsync(skill, args, CancellationToken.None);
|
||||
using var argsDoc = JsonDocument.Parse($$"""{ "request": {{inputJson.GetRawText()}} }""");
|
||||
var args = argsDoc.RootElement;
|
||||
var scriptResult = await script.RunAsync(skill, args, null, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(scriptResult);
|
||||
@@ -173,12 +173,14 @@ public sealed class AgentClassSkillTests
|
||||
|
||||
// Act & Assert — static method
|
||||
var doWorkScript = skill.Scripts!.First(s => s.Name == "do-work");
|
||||
var doWorkResult = await doWorkScript.RunAsync(skill, new AIFunctionArguments { ["input"] = "hello" }, CancellationToken.None);
|
||||
using var doWorkDoc = JsonDocument.Parse("""{"input":"hello"}""");
|
||||
var doWorkResult = await doWorkScript.RunAsync(skill, doWorkDoc.RootElement, null, CancellationToken.None);
|
||||
Assert.Equal("HELLO", doWorkResult?.ToString());
|
||||
|
||||
// Act & Assert — instance method
|
||||
var appendScript = skill.Scripts!.First(s => s.Name == "append");
|
||||
var appendResult = await appendScript.RunAsync(skill, new AIFunctionArguments { ["input"] = "test" }, CancellationToken.None);
|
||||
using var appendDoc = JsonDocument.Parse("""{"input":"test"}""");
|
||||
var appendResult = await appendScript.RunAsync(skill, appendDoc.RootElement, null, CancellationToken.None);
|
||||
Assert.Equal("test-suffix", appendResult?.ToString());
|
||||
}
|
||||
|
||||
@@ -367,7 +369,7 @@ public sealed class AgentClassSkillTests
|
||||
// Act & Assert — all scripts produce values
|
||||
foreach (var script in skill.Scripts!)
|
||||
{
|
||||
var result = await script.RunAsync(skill, new AIFunctionArguments(), CancellationToken.None);
|
||||
var result = await script.RunAsync(skill, null, null, CancellationToken.None);
|
||||
Assert.NotNull(result);
|
||||
}
|
||||
}
|
||||
@@ -382,8 +384,9 @@ public sealed class AgentClassSkillTests
|
||||
// Act & Assert — script with custom JSO
|
||||
var script = skill.Scripts![0];
|
||||
var inputJson = JsonSerializer.SerializeToElement(new LookupRequest { Query = "test", MaxResults = 3 }, jso);
|
||||
var args = new AIFunctionArguments { ["request"] = inputJson };
|
||||
var scriptResult = await script.RunAsync(skill, args, CancellationToken.None);
|
||||
using var argsDoc = JsonDocument.Parse($$"""{ "request": {{inputJson.GetRawText()}} }""");
|
||||
var args = argsDoc.RootElement;
|
||||
var scriptResult = await script.RunAsync(skill, args, null, CancellationToken.None);
|
||||
Assert.NotNull(scriptResult);
|
||||
Assert.Contains("test", scriptResult!.ToString()!);
|
||||
Assert.Contains("3", scriptResult!.ToString()!);
|
||||
@@ -497,8 +500,9 @@ public sealed class AgentClassSkillTests
|
||||
var script = skill.Scripts!.First(s => s.Name == "Lookup");
|
||||
var jso = SkillTestJsonContext.Default.Options;
|
||||
var inputJson = JsonSerializer.SerializeToElement(new LookupRequest { Query = "fallback", MaxResults = 7 }, jso);
|
||||
var args = new AIFunctionArguments { ["request"] = inputJson };
|
||||
var result = await script.RunAsync(skill, args, CancellationToken.None);
|
||||
using var argsDoc = JsonDocument.Parse($$"""{ "request": {{inputJson.GetRawText()}} }""");
|
||||
var args = argsDoc.RootElement;
|
||||
var result = await script.RunAsync(skill, args, null, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
@@ -531,8 +535,9 @@ public sealed class AgentClassSkillTests
|
||||
var script = skill.Scripts!.First(s => s.Name == "Lookup");
|
||||
var jso = SkillTestJsonContext.Default.Options;
|
||||
var inputJson = JsonSerializer.SerializeToElement(new LookupRequest { Query = "explicit", MaxResults = 2 }, jso);
|
||||
var args = new AIFunctionArguments { ["request"] = inputJson };
|
||||
var result = await script.RunAsync(skill, args, CancellationToken.None);
|
||||
using var argsDoc = JsonDocument.Parse($$"""{ "request": {{inputJson.GetRawText()}} }""");
|
||||
var args = argsDoc.RootElement;
|
||||
var result = await script.RunAsync(skill, args, null, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
|
||||
+181
-10
@@ -1,9 +1,9 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
|
||||
|
||||
@@ -16,13 +16,13 @@ public sealed class AgentFileSkillScriptTests
|
||||
public async Task RunAsync_SkillIsNotAgentFileSkill_ThrowsInvalidOperationExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
static Task<object?> RunnerAsync(AgentFileSkill s, AgentFileSkillScript sc, AIFunctionArguments a, CancellationToken ct) => Task.FromResult<object?>("result");
|
||||
static Task<object?> RunnerAsync(AgentFileSkill s, AgentFileSkillScript sc, JsonElement? a, IServiceProvider? sp, CancellationToken ct) => Task.FromResult<object?>("result");
|
||||
var script = CreateScript("test-script", "/path/to/script.py", RunnerAsync);
|
||||
var nonFileSkill = new TestAgentSkill("my-skill", "A skill", "Instructions.");
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => script.RunAsync(nonFileSkill, new AIFunctionArguments(), CancellationToken.None));
|
||||
() => script.RunAsync(nonFileSkill, null, null, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -30,7 +30,7 @@ public sealed class AgentFileSkillScriptTests
|
||||
{
|
||||
// Arrange
|
||||
var runnerCalled = false;
|
||||
Task<object?> runnerAsync(AgentFileSkill skill, AgentFileSkillScript scriptArg, AIFunctionArguments args, CancellationToken ct)
|
||||
Task<object?> runnerAsync(AgentFileSkill skill, AgentFileSkillScript scriptArg, JsonElement? args, IServiceProvider? sp, CancellationToken ct)
|
||||
{
|
||||
runnerCalled = true;
|
||||
return Task.FromResult<object?>("executed");
|
||||
@@ -42,7 +42,7 @@ public sealed class AgentFileSkillScriptTests
|
||||
"/skills/my-skill");
|
||||
|
||||
// Act
|
||||
var result = await script.RunAsync(fileSkill, new AIFunctionArguments(), CancellationToken.None);
|
||||
var result = await script.RunAsync(fileSkill, null, null, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.True(runnerCalled);
|
||||
@@ -55,7 +55,7 @@ public sealed class AgentFileSkillScriptTests
|
||||
// Arrange
|
||||
AgentFileSkill? capturedSkill = null;
|
||||
AgentFileSkillScript? capturedScript = null;
|
||||
Task<object?> runnerAsync(AgentFileSkill skill, AgentFileSkillScript scriptArg, AIFunctionArguments args, CancellationToken ct)
|
||||
Task<object?> runnerAsync(AgentFileSkill skill, AgentFileSkillScript scriptArg, JsonElement? args, IServiceProvider? sp, CancellationToken ct)
|
||||
{
|
||||
capturedSkill = skill;
|
||||
capturedScript = scriptArg;
|
||||
@@ -68,7 +68,7 @@ public sealed class AgentFileSkillScriptTests
|
||||
"/skills/owner-skill");
|
||||
|
||||
// Act
|
||||
await script.RunAsync(fileSkill, new AIFunctionArguments(), CancellationToken.None);
|
||||
await script.RunAsync(fileSkill, null, null, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Same(fileSkill, capturedSkill);
|
||||
@@ -79,7 +79,7 @@ public sealed class AgentFileSkillScriptTests
|
||||
public void Script_HasCorrectNameAndPath()
|
||||
{
|
||||
// Arrange & Act
|
||||
static Task<object?> RunnerAsync(AgentFileSkill s, AgentFileSkillScript sc, AIFunctionArguments a, CancellationToken ct) => Task.FromResult<object?>(null);
|
||||
static Task<object?> RunnerAsync(AgentFileSkill s, AgentFileSkillScript sc, JsonElement? a, IServiceProvider? sp, CancellationToken ct) => Task.FromResult<object?>(null);
|
||||
var script = CreateScript("my-script", "/path/to/my-script.py", RunnerAsync);
|
||||
|
||||
// Assert
|
||||
@@ -87,10 +87,173 @@ public sealed class AgentFileSkillScriptTests
|
||||
Assert.Equal("/path/to/my-script.py", script.FullPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParametersSchema_ReturnsExpectedArraySchema()
|
||||
{
|
||||
// Arrange
|
||||
static Task<object?> RunnerAsync(AgentFileSkill s, AgentFileSkillScript sc, JsonElement? a, IServiceProvider? sp, CancellationToken ct) => Task.FromResult<object?>(null);
|
||||
var script = CreateScript("my-script", "/path/to/script.py", RunnerAsync);
|
||||
|
||||
// Act
|
||||
var schema = script.ParametersSchema;
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(schema);
|
||||
var raw = schema!.Value.GetRawText();
|
||||
Assert.Contains("\"type\":\"array\"", raw);
|
||||
Assert.Contains("\"items\":{\"type\":\"string\"}", raw);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Content_WithScripts_AppendsPerScriptEntries()
|
||||
{
|
||||
// Arrange
|
||||
static Task<object?> RunnerAsync(AgentFileSkill s, AgentFileSkillScript sc, JsonElement? a, IServiceProvider? sp, CancellationToken ct) => Task.FromResult<object?>(null);
|
||||
var script1 = CreateScript("build", "/scripts/build.sh", RunnerAsync);
|
||||
var script2 = CreateScript("deploy", "/scripts/deploy.sh", RunnerAsync);
|
||||
var fileSkill = new AgentFileSkill(
|
||||
new AgentSkillFrontmatter("my-skill", "A skill"),
|
||||
"Original content",
|
||||
"/skills/my-skill",
|
||||
scripts: [script1, script2]);
|
||||
|
||||
// Act
|
||||
var content = fileSkill.Content;
|
||||
|
||||
// Assert — content starts with original and appends per-script entries
|
||||
Assert.StartsWith("Original content", content);
|
||||
Assert.Contains("<scripts>", content);
|
||||
Assert.Contains("<script name=\"build\">", content);
|
||||
Assert.Contains("<script name=\"deploy\">", content);
|
||||
Assert.Contains("<parameters_schema>", content);
|
||||
Assert.Contains("</scripts>", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Content_WithoutScripts_ReturnsOriginalContent()
|
||||
{
|
||||
// Arrange
|
||||
var fileSkill = new AgentFileSkill(
|
||||
new AgentSkillFrontmatter("my-skill", "A skill"),
|
||||
"Original content only",
|
||||
"/skills/my-skill");
|
||||
|
||||
// Act
|
||||
var content = fileSkill.Content;
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Original content only", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Content_WithScripts_IsCached()
|
||||
{
|
||||
// Arrange
|
||||
static Task<object?> RunnerAsync(AgentFileSkill s, AgentFileSkillScript sc, JsonElement? a, IServiceProvider? sp, CancellationToken ct) => Task.FromResult<object?>(null);
|
||||
var script = CreateScript("test", "/scripts/test.sh", RunnerAsync);
|
||||
var fileSkill = new AgentFileSkill(
|
||||
new AgentSkillFrontmatter("my-skill", "A skill"),
|
||||
"Content",
|
||||
"/skills/my-skill",
|
||||
scripts: [script]);
|
||||
|
||||
// Act
|
||||
var content1 = fileSkill.Content;
|
||||
var content2 = fileSkill.Content;
|
||||
|
||||
// Assert
|
||||
Assert.Same(content1, content2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ForwardsJsonArrayArgumentsToRunnerAsync()
|
||||
{
|
||||
// Arrange
|
||||
JsonElement? capturedArgs = null;
|
||||
Task<object?> runnerAsync(AgentFileSkill skill, AgentFileSkillScript scriptArg, JsonElement? args, IServiceProvider? sp, CancellationToken ct)
|
||||
{
|
||||
capturedArgs = args;
|
||||
return Task.FromResult<object?>("done");
|
||||
}
|
||||
var script = CreateScript("array-test", "/scripts/test.sh", runnerAsync);
|
||||
var fileSkill = new AgentFileSkill(
|
||||
new AgentSkillFrontmatter("my-skill", "A skill"),
|
||||
"Content",
|
||||
"/skills/my-skill");
|
||||
using var arrayArgsDoc = JsonDocument.Parse("""["arg1","arg2","arg3"]""");
|
||||
var arrayArgs = arrayArgsDoc.RootElement;
|
||||
|
||||
// Act
|
||||
await script.RunAsync(fileSkill, arrayArgs, null, CancellationToken.None);
|
||||
|
||||
// Assert — the raw JSON array is forwarded unchanged
|
||||
Assert.NotNull(capturedArgs);
|
||||
Assert.Equal(JsonValueKind.Array, capturedArgs!.Value.ValueKind);
|
||||
Assert.Equal("""["arg1","arg2","arg3"]""", capturedArgs.Value.GetRawText());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ForwardsServiceProviderToRunnerAsync()
|
||||
{
|
||||
// Arrange
|
||||
IServiceProvider? capturedProvider = null;
|
||||
Task<object?> runnerAsync(AgentFileSkill skill, AgentFileSkillScript scriptArg, JsonElement? args, IServiceProvider? sp, CancellationToken ct)
|
||||
{
|
||||
capturedProvider = sp;
|
||||
return Task.FromResult<object?>("done");
|
||||
}
|
||||
var script = CreateScript("sp-test", "/scripts/test.sh", runnerAsync);
|
||||
var fileSkill = new AgentFileSkill(
|
||||
new AgentSkillFrontmatter("my-skill", "A skill"),
|
||||
"Content",
|
||||
"/skills/my-skill");
|
||||
var mockProvider = new TestServiceProvider();
|
||||
|
||||
// Act
|
||||
await script.RunAsync(fileSkill, null, mockProvider, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Same(mockProvider, capturedProvider);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_NoRunner_ThrowsInvalidOperationExceptionAsync()
|
||||
{
|
||||
// Arrange — create script without a runner
|
||||
var script = CreateScript("no-runner", "/scripts/test.sh", runner: null);
|
||||
var fileSkill = new AgentFileSkill(
|
||||
new AgentSkillFrontmatter("my-skill", "A skill"),
|
||||
"Content",
|
||||
"/skills/my-skill");
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => script.RunAsync(fileSkill, null, null, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Content_WithScripts_ContainsDefaultParametersSchema()
|
||||
{
|
||||
// Arrange
|
||||
static Task<object?> RunnerAsync(AgentFileSkill s, AgentFileSkillScript sc, JsonElement? a, IServiceProvider? sp, CancellationToken ct) => Task.FromResult<object?>(null);
|
||||
var script = CreateScript("test", "/scripts/test.sh", RunnerAsync);
|
||||
var fileSkill = new AgentFileSkill(
|
||||
new AgentSkillFrontmatter("my-skill", "A skill"),
|
||||
"Original content",
|
||||
"/skills/my-skill",
|
||||
scripts: [script]);
|
||||
|
||||
// Act
|
||||
var content = fileSkill.Content;
|
||||
|
||||
// Assert — the appended block contains the actual default schema from AgentFileSkillScript
|
||||
Assert.Contains("""{"type":"array","items":{"type":"string"}}""", content);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper to create an <see cref="AgentFileSkillScript"/> via reflection since the constructor is internal.
|
||||
/// </summary>
|
||||
private static AgentFileSkillScript CreateScript(string name, string fullPath, AgentFileSkillScriptRunner executor)
|
||||
private static AgentFileSkillScript CreateScript(string name, string fullPath, AgentFileSkillScriptRunner? runner)
|
||||
{
|
||||
var ctor = typeof(AgentFileSkillScript).GetConstructor(
|
||||
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance,
|
||||
@@ -98,6 +261,14 @@ public sealed class AgentFileSkillScriptTests
|
||||
[typeof(string), typeof(string), typeof(AgentFileSkillScriptRunner)],
|
||||
null) ?? throw new InvalidOperationException("Could not find internal constructor.");
|
||||
|
||||
return (AgentFileSkillScript)ctor.Invoke([name, fullPath, executor]);
|
||||
return (AgentFileSkillScript)ctor.Invoke([name, fullPath, runner]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimal <see cref="IServiceProvider"/> for testing service forwarding.
|
||||
/// </summary>
|
||||
private sealed class TestServiceProvider : IServiceProvider
|
||||
{
|
||||
public object? GetService(Type serviceType) => null;
|
||||
}
|
||||
}
|
||||
|
||||
+13
-15
@@ -3,9 +3,9 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
|
||||
|
||||
@@ -15,7 +15,7 @@ namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
|
||||
public sealed class AgentFileSkillsSourceScriptTests : IDisposable
|
||||
{
|
||||
private static readonly string[] s_rubyExtension = new[] { ".rb" };
|
||||
private static readonly AgentFileSkillScriptRunner s_noOpExecutor = (skill, script, args, ct) => Task.FromResult<object?>(null);
|
||||
private static readonly AgentFileSkillScriptRunner s_noOpExecutor = (skill, script, args, sp, ct) => Task.FromResult<object?>(null);
|
||||
|
||||
private readonly string _testRoot;
|
||||
|
||||
@@ -139,7 +139,7 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable
|
||||
var executorCalled = false;
|
||||
var source = new AgentFileSkillsSource(
|
||||
this._testRoot,
|
||||
(skill, script, args, ct) =>
|
||||
(skill, script, args, sp, ct) =>
|
||||
{
|
||||
executorCalled = true;
|
||||
Assert.Equal("exec-skill", skill.Frontmatter.Name);
|
||||
@@ -150,7 +150,7 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync(CancellationToken.None);
|
||||
var scriptResult = await skills[0].Scripts![0].RunAsync(skills[0], new AIFunctionArguments(), CancellationToken.None);
|
||||
var scriptResult = await skills[0].Scripts![0].RunAsync(skills[0], null, null, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.True(executorCalled);
|
||||
@@ -178,7 +178,7 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable
|
||||
var script = skills[0].Scripts![0];
|
||||
|
||||
// Assert — running the script throws because no runner was provided
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => script.RunAsync(skills[0], new AIFunctionArguments(), CancellationToken.None));
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => script.RunAsync(skills[0], null, null, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -204,10 +204,10 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable
|
||||
{
|
||||
// Arrange
|
||||
CreateSkillWithScript(this._testRoot, "args-skill", "Args test", "Body.", "scripts/test.py", "print('ok')");
|
||||
AIFunctionArguments? capturedArgs = null;
|
||||
JsonElement? capturedArgs = null;
|
||||
var source = new AgentFileSkillsSource(
|
||||
this._testRoot,
|
||||
(skill, script, args, ct) =>
|
||||
(skill, script, args, sp, ct) =>
|
||||
{
|
||||
capturedArgs = args;
|
||||
return Task.FromResult<object?>("done");
|
||||
@@ -215,17 +215,15 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync(CancellationToken.None);
|
||||
var arguments = new AIFunctionArguments
|
||||
{
|
||||
["value"] = 26.2,
|
||||
["factor"] = 1.60934
|
||||
};
|
||||
await skills[0].Scripts![0].RunAsync(skills[0], arguments, CancellationToken.None);
|
||||
using var argumentsDoc = JsonDocument.Parse("""{"value":26.2,"factor":1.60934}""");
|
||||
var arguments = argumentsDoc.RootElement;
|
||||
await skills[0].Scripts![0].RunAsync(skills[0], arguments, null, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedArgs);
|
||||
Assert.Equal(26.2, capturedArgs["value"]);
|
||||
Assert.Equal(1.60934, capturedArgs["factor"]);
|
||||
Assert.Equal(JsonValueKind.Object, capturedArgs!.Value.ValueKind);
|
||||
Assert.Equal(26.2, capturedArgs.Value.GetProperty("value").GetDouble());
|
||||
Assert.Equal(1.60934, capturedArgs.Value.GetProperty("factor").GetDouble());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
+72
-12
@@ -5,7 +5,6 @@ using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
|
||||
|
||||
@@ -22,7 +21,7 @@ public sealed class AgentInlineSkillScriptTests
|
||||
var skill = new AgentInlineSkill("test-skill", "Test.", "Instructions.");
|
||||
|
||||
// Act
|
||||
var result = await script.RunAsync(skill, new AIFunctionArguments(), CancellationToken.None);
|
||||
var result = await script.RunAsync(skill, null, null, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("hello", result?.ToString());
|
||||
@@ -34,10 +33,11 @@ public sealed class AgentInlineSkillScriptTests
|
||||
// Arrange
|
||||
var script = new AgentInlineSkillScript("add", (int a, int b) => a + b);
|
||||
var skill = new AgentInlineSkill("calc-skill", "Calc.", "Instructions.");
|
||||
var args = new AIFunctionArguments { ["a"] = 3, ["b"] = 7 };
|
||||
using var argsDoc = JsonDocument.Parse("""{"a":3,"b":7}""");
|
||||
var args = argsDoc.RootElement;
|
||||
|
||||
// Act
|
||||
var result = await script.RunAsync(skill, args, CancellationToken.None);
|
||||
var result = await script.RunAsync(skill, args, null, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(10, int.Parse(result?.ToString()!));
|
||||
@@ -129,10 +129,11 @@ public sealed class AgentInlineSkillScriptTests
|
||||
}, serializerOptions: jso);
|
||||
var skill = new AgentInlineSkill("test-skill", "Test.", "Instructions.");
|
||||
var inputJson = JsonSerializer.SerializeToElement(new LookupRequest { Query = "test", MaxResults = 5 }, jso);
|
||||
var args = new AIFunctionArguments { ["request"] = inputJson };
|
||||
using var argsDoc = JsonDocument.Parse($$"""{ "request": {{inputJson.GetRawText()}} }""");
|
||||
var args = argsDoc.RootElement;
|
||||
|
||||
// Act
|
||||
var result = await script.RunAsync(skill, args, CancellationToken.None);
|
||||
var result = await script.RunAsync(skill, args, null, CancellationToken.None);
|
||||
|
||||
// Assert — the custom input type was deserialized and the response was produced
|
||||
Assert.NotNull(result);
|
||||
@@ -145,10 +146,11 @@ public sealed class AgentInlineSkillScriptTests
|
||||
// Arrange
|
||||
var script = new AgentInlineSkillScript("echo", (string message) => message);
|
||||
var skill = new AgentInlineSkill("test-skill", "Test.", "Instructions.");
|
||||
var args = new AIFunctionArguments { ["message"] = "hello world" };
|
||||
using var argsDoc = JsonDocument.Parse("""{"message":"hello world"}""");
|
||||
var args = argsDoc.RootElement;
|
||||
|
||||
// Act
|
||||
var result = await script.RunAsync(skill, args, CancellationToken.None);
|
||||
var result = await script.RunAsync(skill, args, null, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("hello world", result?.ToString());
|
||||
@@ -175,10 +177,11 @@ public sealed class AgentInlineSkillScriptTests
|
||||
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" };
|
||||
using var argsDoc = JsonDocument.Parse("""{"input":"hello"}""");
|
||||
var args = argsDoc.RootElement;
|
||||
|
||||
// Act
|
||||
var result = await script.RunAsync(skill, args, CancellationToken.None);
|
||||
var result = await script.RunAsync(skill, args, null, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("HELLO", result?.ToString());
|
||||
@@ -191,10 +194,11 @@ public sealed class AgentInlineSkillScriptTests
|
||||
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" };
|
||||
using var argsDoc2 = JsonDocument.Parse("""{"input":"test"}""");
|
||||
var args2 = argsDoc2.RootElement;
|
||||
|
||||
// Act
|
||||
var result = await script.RunAsync(skill, args, CancellationToken.None);
|
||||
var result = await script.RunAsync(skill, args2, null, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("test-suffix", result?.ToString());
|
||||
@@ -223,7 +227,63 @@ public sealed class AgentInlineSkillScriptTests
|
||||
Assert.Contains("input", schema!.Value.GetRawText());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_WithNonObjectArguments_ThrowsInvalidOperationExceptionAsync()
|
||||
{
|
||||
// Arrange — inline scripts require a JSON object for arguments
|
||||
var script = new AgentInlineSkillScript("noop", () => "ok");
|
||||
var skill = new AgentInlineSkill("test-skill", "Test.", "Instructions.");
|
||||
using var arrayArgsDoc = JsonDocument.Parse("""["a","b"]""");
|
||||
var arrayArgs = arrayArgsDoc.RootElement;
|
||||
|
||||
// Act & Assert — non-object JSON should fail fast rather than silently dropping arguments
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => script.RunAsync(skill, arrayArgs, null, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_WithNullArguments_TreatsAsNoArgumentsAsync()
|
||||
{
|
||||
// Arrange — a parameterless delegate should succeed when given null arguments
|
||||
var script = new AgentInlineSkillScript("noop", () => "ok");
|
||||
var skill = new AgentInlineSkill("test-skill", "Test.", "Instructions.");
|
||||
|
||||
// Act
|
||||
var result = await script.RunAsync(skill, null, null, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("ok", result?.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ServiceProviderIsForwardedAsync()
|
||||
{
|
||||
// Arrange — delegate that resolves a service from the IServiceProvider
|
||||
IServiceProvider? capturedProvider = null;
|
||||
var script = new AgentInlineSkillScript("svc-test", (IServiceProvider sp) =>
|
||||
{
|
||||
capturedProvider = sp;
|
||||
return "done";
|
||||
});
|
||||
var skill = new AgentInlineSkill("test-skill", "Test.", "Instructions.");
|
||||
var mockProvider = new TestServiceProvider();
|
||||
|
||||
// Act
|
||||
await script.RunAsync(skill, null, mockProvider, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Same(mockProvider, capturedProvider);
|
||||
}
|
||||
|
||||
private static string StaticScriptHelper(string input) => input.ToUpperInvariant();
|
||||
|
||||
private string InstanceScriptHelper(string input) => input + "-suffix";
|
||||
|
||||
/// <summary>
|
||||
/// Minimal <see cref="IServiceProvider"/> for testing service forwarding.
|
||||
/// </summary>
|
||||
private sealed class TestServiceProvider : IServiceProvider
|
||||
{
|
||||
public object? GetService(Type serviceType) => null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -433,10 +433,11 @@ public sealed class AgentInlineSkillTests
|
||||
TotalCount = request.MaxResults,
|
||||
});
|
||||
var inputJson = JsonSerializer.SerializeToElement(new LookupRequest { Query = "test", MaxResults = 3 }, jso);
|
||||
var args = new AIFunctionArguments { ["request"] = inputJson };
|
||||
using var argsDoc = JsonDocument.Parse($$"""{ "request": {{inputJson.GetRawText()}} }""");
|
||||
var args = argsDoc.RootElement;
|
||||
|
||||
// Act
|
||||
var result = await skill.Scripts![0].RunAsync(skill, args, CancellationToken.None);
|
||||
var result = await skill.Scripts![0].RunAsync(skill, args, null, CancellationToken.None);
|
||||
|
||||
// Assert — the custom input was deserialized via skill-level JSO and response was produced
|
||||
Assert.NotNull(result);
|
||||
@@ -456,10 +457,11 @@ public sealed class AgentInlineSkillTests
|
||||
TotalCount = request.MaxResults,
|
||||
}, serializerOptions: scriptJso);
|
||||
var inputJson = JsonSerializer.SerializeToElement(new LookupRequest { Query = "override", MaxResults = 7 }, scriptJso);
|
||||
var args = new AIFunctionArguments { ["request"] = inputJson };
|
||||
using var argsDoc = JsonDocument.Parse($$"""{ "request": {{inputJson.GetRawText()}} }""");
|
||||
var args = argsDoc.RootElement;
|
||||
|
||||
// Act
|
||||
var result = await skill.Scripts![0].RunAsync(skill, args, CancellationToken.None);
|
||||
var result = await skill.Scripts![0].RunAsync(skill, args, null, CancellationToken.None);
|
||||
|
||||
// Assert — per-script JSO takes effect and custom types are properly marshaled
|
||||
Assert.NotNull(result);
|
||||
|
||||
@@ -4,6 +4,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
@@ -15,7 +16,7 @@ namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
|
||||
/// </summary>
|
||||
public sealed class AgentSkillsProviderTests : IDisposable
|
||||
{
|
||||
private static readonly AgentFileSkillScriptRunner s_noOpExecutor = (skill, script, args, ct) => Task.FromResult<object?>(null);
|
||||
private static readonly AgentFileSkillScriptRunner s_noOpExecutor = (skill, script, args, sp, ct) => Task.FromResult<object?>(null);
|
||||
private readonly string _testRoot;
|
||||
private readonly TestAIAgent _agent = new();
|
||||
|
||||
@@ -462,7 +463,7 @@ public sealed class AgentSkillsProviderTests : IDisposable
|
||||
// Act — call UseFileScriptRunner AFTER UseFileSkill (the bug scenario)
|
||||
var provider = new AgentSkillsProviderBuilder()
|
||||
.UseFileSkill(this._testRoot)
|
||||
.UseFileScriptRunner((skill, script, args, ct) =>
|
||||
.UseFileScriptRunner((skill, script, args, sp, ct) =>
|
||||
{
|
||||
executorCalled = true;
|
||||
return Task.FromResult<object?>("executed");
|
||||
@@ -487,6 +488,62 @@ public sealed class AgentSkillsProviderTests : IDisposable
|
||||
Assert.True(executorCalled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunSkillScript_ForwardsJsonArgumentsAndServiceProviderToRunnerAsync()
|
||||
{
|
||||
// Arrange — create a skill with a script file
|
||||
string skillDir = Path.Combine(this._testRoot, "fwd-skill");
|
||||
Directory.CreateDirectory(Path.Combine(skillDir, "scripts"));
|
||||
File.WriteAllText(
|
||||
Path.Combine(skillDir, "SKILL.md"),
|
||||
"---\nname: fwd-skill\ndescription: Forwarding test\n---\nBody.");
|
||||
File.WriteAllText(
|
||||
Path.Combine(skillDir, "scripts", "run.py"),
|
||||
"print('ok')");
|
||||
|
||||
JsonElement? capturedArgs = null;
|
||||
IServiceProvider? capturedServiceProvider = null;
|
||||
|
||||
var provider = new AgentSkillsProviderBuilder()
|
||||
.UseFileSkill(this._testRoot)
|
||||
.UseFileScriptRunner((skill, script, args, sp, ct) =>
|
||||
{
|
||||
capturedArgs = args;
|
||||
capturedServiceProvider = sp;
|
||||
return Task.FromResult<object?>("executed");
|
||||
})
|
||||
.Build();
|
||||
|
||||
var mockServiceProvider = new TestServiceProvider();
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
||||
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
var runScriptTool = result.Tools!.First(t => t.Name == "run_skill_script") as AIFunction;
|
||||
|
||||
// Act — invoke with JsonElement arguments and a service provider
|
||||
using var argsJsonDoc = JsonDocument.Parse("""["arg1","arg2"]""");
|
||||
var argsJson = argsJsonDoc.RootElement;
|
||||
await runScriptTool!.InvokeAsync(new AIFunctionArguments(new Dictionary<string, object?>
|
||||
{
|
||||
["skillName"] = "fwd-skill",
|
||||
["scriptName"] = "scripts/run.py",
|
||||
["arguments"] = argsJson,
|
||||
})
|
||||
{
|
||||
Services = mockServiceProvider,
|
||||
});
|
||||
|
||||
// Assert — JsonElement arguments and service provider are forwarded to the runner
|
||||
Assert.NotNull(capturedArgs);
|
||||
Assert.Equal(JsonValueKind.Array, capturedArgs!.Value.ValueKind);
|
||||
Assert.Equal("""["arg1","arg2"]""", capturedArgs.Value.GetRawText());
|
||||
Assert.Same(mockServiceProvider, capturedServiceProvider);
|
||||
}
|
||||
|
||||
private sealed class TestServiceProvider : IServiceProvider
|
||||
{
|
||||
public object? GetService(Type serviceType) => null;
|
||||
}
|
||||
|
||||
private static void CreateSkillIn(string root, string name, string description, string body)
|
||||
{
|
||||
string skillDir = Path.Combine(root, name);
|
||||
|
||||
@@ -15,7 +15,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
private static readonly string[] s_customExtensions = [".custom"];
|
||||
private static readonly string[] s_validExtensions = [".md", ".json", ".custom"];
|
||||
private static readonly string[] s_mixedValidInvalidExtensions = [".md", "json"];
|
||||
private static readonly AgentFileSkillScriptRunner s_noOpExecutor = (skill, script, args, ct) => Task.FromResult<object?>(null);
|
||||
private static readonly AgentFileSkillScriptRunner s_noOpExecutor = (skill, script, args, sp, ct) => Task.FromResult<object?>(null);
|
||||
|
||||
private readonly string _testRoot;
|
||||
|
||||
|
||||
+13
-2
@@ -60,10 +60,20 @@ public abstract class IntegrationTest : IDisposable
|
||||
|
||||
protected async ValueTask<DeclarativeWorkflowOptions> CreateOptionsAsync(bool externalConversation = false, params IEnumerable<AIFunction> functionTools)
|
||||
{
|
||||
return await this.CreateOptionsAsync(externalConversation, mcpToolProvider: null, functionTools).ConfigureAwait(false);
|
||||
return await this.CreateOptionsAsync(externalConversation, mcpToolProvider: null, httpRequestHandler: null, functionTools).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
protected async ValueTask<DeclarativeWorkflowOptions> CreateOptionsAsync(bool externalConversation, IMcpToolHandler? mcpToolProvider, params IEnumerable<AIFunction> functionTools)
|
||||
{
|
||||
return await this.CreateOptionsAsync(externalConversation, mcpToolProvider, httpRequestHandler: null, functionTools).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
protected async ValueTask<DeclarativeWorkflowOptions> CreateOptionsAsync(bool externalConversation, IHttpRequestHandler? httpRequestHandler, params IEnumerable<AIFunction> functionTools)
|
||||
{
|
||||
return await this.CreateOptionsAsync(externalConversation, mcpToolProvider: null, httpRequestHandler, functionTools).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
protected async ValueTask<DeclarativeWorkflowOptions> CreateOptionsAsync(bool externalConversation, IMcpToolHandler? mcpToolProvider, IHttpRequestHandler? httpRequestHandler, params IEnumerable<AIFunction> functionTools)
|
||||
{
|
||||
AzureAgentProvider agentProvider =
|
||||
new(this.TestEndpoint, TestAzureCliCredentials.CreateAzureCliCredential())
|
||||
@@ -82,7 +92,8 @@ public abstract class IntegrationTest : IDisposable
|
||||
{
|
||||
ConversationId = conversationId,
|
||||
LoggerFactory = this.Output,
|
||||
McpToolHandler = mcpToolProvider
|
||||
McpToolHandler = mcpToolProvider,
|
||||
HttpRequestHandler = httpRequestHandler,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+43
@@ -45,6 +45,15 @@ public sealed class InvokeToolWorkflowTest(ITestOutputHelper output) : Integrati
|
||||
|
||||
#endregion
|
||||
|
||||
#region InvokeHttpRequest Tests
|
||||
|
||||
[RetryTheory(3, 5000)]
|
||||
[InlineData("HttpRequest.yaml", "visibility: public")]
|
||||
public Task ValidateHttpRequestAsync(string workflowFileName, string? expectedResultContains) =>
|
||||
this.RunHttpRequestTestAsync(workflowFileName, expectedResultContains);
|
||||
|
||||
#endregion
|
||||
|
||||
#region InvokeFunctionTool Test Helpers
|
||||
|
||||
/// <summary>
|
||||
@@ -250,6 +259,40 @@ public sealed class InvokeToolWorkflowTest(ITestOutputHelper output) : Integrati
|
||||
|
||||
#endregion
|
||||
|
||||
#region InvokeHttpRequest Test Helpers
|
||||
|
||||
/// <summary>
|
||||
/// Runs an HttpRequestAction workflow test with the specified configuration.
|
||||
/// </summary>
|
||||
private async Task RunHttpRequestTestAsync(
|
||||
string workflowFileName,
|
||||
string? expectedResultContains = null)
|
||||
{
|
||||
// Arrange
|
||||
string workflowPath = GetWorkflowPath(workflowFileName);
|
||||
await using DefaultHttpRequestHandler httpRequestHandler = new();
|
||||
DeclarativeWorkflowOptions workflowOptions = await this.CreateOptionsAsync(
|
||||
externalConversation: false,
|
||||
httpRequestHandler: httpRequestHandler);
|
||||
|
||||
Workflow workflow = DeclarativeWorkflowBuilder.Build<string>(workflowPath, workflowOptions);
|
||||
WorkflowHarness harness = new(workflow, runId: Path.GetFileNameWithoutExtension(workflowPath));
|
||||
|
||||
// Act
|
||||
WorkflowEvents workflowEvents = await harness.RunWorkflowAsync("start").ConfigureAwait(false);
|
||||
|
||||
// Assert - Verify executor and action events
|
||||
AssertWorkflowEventsEmitted(workflowEvents);
|
||||
|
||||
// Assert - Verify expected result if specified
|
||||
if (expectedResultContains is not null)
|
||||
{
|
||||
AssertResultContains(workflowEvents, expectedResultContains);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Shared Helpers
|
||||
|
||||
private static void AssertWorkflowEventsEmitted(WorkflowEvents workflowEvents)
|
||||
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
#
|
||||
# This workflow tests invoking HttpRequestAction end-to-end.
|
||||
# Uses the public GitHub API (unauthenticated) to fetch repo metadata.
|
||||
#
|
||||
kind: Workflow
|
||||
trigger:
|
||||
|
||||
kind: OnConversationStart
|
||||
id: workflow_http_request_test
|
||||
actions:
|
||||
|
||||
# Set the repo owner used to form the request URL.
|
||||
- kind: SetVariable
|
||||
id: set_repo_owner
|
||||
variable: Local.RepoOwner
|
||||
value: dotnet
|
||||
|
||||
# Invoke the GitHub repo API.
|
||||
- kind: HttpRequestAction
|
||||
id: fetch_repo_info
|
||||
conversationId: =System.ConversationId
|
||||
method: GET
|
||||
url: =Concatenate("https://api.github.com/repos/", Local.RepoOwner, "/runtime")
|
||||
headers:
|
||||
Accept: application/vnd.github+json
|
||||
User-Agent: agent-framework-integration-test
|
||||
response: Local.RepoInfo
|
||||
|
||||
# Surface the Repo visibility field from the parsed JSON response.
|
||||
- kind: SendMessage
|
||||
id: show_visibility
|
||||
message: "visibility: {Local.RepoInfo.visibility}"
|
||||
+22
-2
@@ -181,6 +181,7 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow
|
||||
[InlineData("ResetVariable.yaml", 2, "clear_var")]
|
||||
[InlineData("MixedScopes.yaml", 2, "activity_input")]
|
||||
[InlineData("CaseInsensitive.yaml", 6, "end_when_match")]
|
||||
[InlineData("HttpRequest.yaml", 1, "http_request")]
|
||||
public async Task ExecuteActionAsync(string workflowFile, int expectedCount, string expectedId)
|
||||
{
|
||||
await this.RunWorkflowAsync(workflowFile);
|
||||
@@ -200,7 +201,6 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow
|
||||
[InlineData(typeof(EmitEvent.Builder))]
|
||||
[InlineData(typeof(GetActivityMembers.Builder))]
|
||||
[InlineData(typeof(GetConversationMembers.Builder))]
|
||||
[InlineData(typeof(HttpRequestAction.Builder))]
|
||||
[InlineData(typeof(InvokeAIBuilderModelAction.Builder))]
|
||||
[InlineData(typeof(InvokeConnectorAction.Builder))]
|
||||
[InlineData(typeof(InvokeCustomModelAction.Builder))]
|
||||
@@ -266,6 +266,7 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow
|
||||
[InlineData("SendActivity.yaml", "activity_input")]
|
||||
[InlineData("SetVariable.yaml", "set_var")]
|
||||
[InlineData("SetTextVariable.yaml", "set_text")]
|
||||
[InlineData("HttpRequest.yaml", "http_request")]
|
||||
public async Task CancelRunAsync(string workflowPath, string expectedExecutedId)
|
||||
{
|
||||
// Arrange
|
||||
@@ -374,7 +375,12 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow
|
||||
{
|
||||
using StreamReader yamlReader = File.OpenText(Path.Combine("Workflows", workflowPath));
|
||||
Mock<ResponseAgentProvider> mockAgentProvider = CreateMockProvider($"{workflowInput}");
|
||||
DeclarativeWorkflowOptions workflowContext = new(mockAgentProvider.Object) { LoggerFactory = this.Output };
|
||||
DeclarativeWorkflowOptions workflowContext =
|
||||
new(mockAgentProvider.Object)
|
||||
{
|
||||
LoggerFactory = this.Output,
|
||||
HttpRequestHandler = CreateMockHttpRequestHandler().Object,
|
||||
};
|
||||
return DeclarativeWorkflowBuilder.Build<TInput>(yamlReader, workflowContext);
|
||||
}
|
||||
|
||||
@@ -385,4 +391,18 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow
|
||||
mockAgentProvider.Setup(provider => provider.CreateMessageAsync(It.IsAny<string>(), It.IsAny<ChatMessage>(), It.IsAny<CancellationToken>())).Returns(Task.FromResult(new ChatMessage(ChatRole.Assistant, input)));
|
||||
return mockAgentProvider;
|
||||
}
|
||||
|
||||
private static Mock<IHttpRequestHandler> CreateMockHttpRequestHandler()
|
||||
{
|
||||
Mock<IHttpRequestHandler> mockHandler = new(MockBehavior.Loose);
|
||||
mockHandler
|
||||
.Setup(handler => handler.SendAsync(It.IsAny<HttpRequestInfo>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(() => Task.FromResult(new HttpRequestResult
|
||||
{
|
||||
StatusCode = 200,
|
||||
IsSuccessStatusCode = true,
|
||||
Body = "{\"ok\":true}",
|
||||
}));
|
||||
return mockHandler;
|
||||
}
|
||||
}
|
||||
|
||||
+510
@@ -0,0 +1,510 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="DefaultHttpRequestHandler"/>.
|
||||
/// </summary>
|
||||
public sealed class DefaultHttpRequestHandlerTests
|
||||
{
|
||||
private static readonly string[] s_setCookieValues = ["a=1", "b=2"];
|
||||
|
||||
private const string TestUrl = "https://api.example.test/resource";
|
||||
|
||||
#region Constructor Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ConstructorWithNoParametersCreatesInstanceAsync()
|
||||
{
|
||||
// Act
|
||||
await using DefaultHttpRequestHandler handler = new();
|
||||
|
||||
// Assert
|
||||
handler.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConstructorWithNullProviderCreatesInstanceAsync()
|
||||
{
|
||||
// Act
|
||||
await using DefaultHttpRequestHandler handler = new(httpClientProvider: null);
|
||||
|
||||
// Assert
|
||||
handler.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConstructorWithNullHttpClientThrows()
|
||||
{
|
||||
// Act
|
||||
Action act = () => _ = new DefaultHttpRequestHandler((HttpClient)null!);
|
||||
|
||||
// Assert
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConstructorWithHttpClientUsesSuppliedClientForAllRequestsAsync()
|
||||
{
|
||||
// Arrange
|
||||
TestHttpMessageHandler messageHandler = new((req, _) =>
|
||||
Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent("ok", Encoding.UTF8, "text/plain"),
|
||||
}));
|
||||
using HttpClient suppliedClient = new(messageHandler);
|
||||
await using DefaultHttpRequestHandler handler = new(suppliedClient);
|
||||
HttpRequestInfo request = new() { Method = "GET", Url = TestUrl };
|
||||
|
||||
// Act
|
||||
HttpRequestResult result = await handler.SendAsync(request);
|
||||
|
||||
// Assert - the supplied HttpClient's underlying handler saw the request
|
||||
messageHandler.LastRequest.Should().NotBeNull();
|
||||
messageHandler.LastRequest!.RequestUri!.ToString().Should().Be(TestUrl);
|
||||
result.Body.Should().Be("ok");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisposeAsyncDoesNotDisposeCallerSuppliedHttpClientAsync()
|
||||
{
|
||||
// Arrange
|
||||
TestHttpMessageHandler messageHandler = new((req, _) =>
|
||||
Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)));
|
||||
using HttpClient suppliedClient = new(messageHandler);
|
||||
|
||||
// Act
|
||||
DefaultHttpRequestHandler handler = new(suppliedClient);
|
||||
await handler.DisposeAsync();
|
||||
|
||||
// Assert - supplied client remains usable (not disposed)
|
||||
Func<Task> act = async () => await suppliedClient.GetAsync(new Uri(TestUrl));
|
||||
await act.Should().NotThrowAsync<ObjectDisposedException>();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Argument Validation Tests
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsyncWithNullRequestThrowsAsync()
|
||||
{
|
||||
// Arrange
|
||||
await using DefaultHttpRequestHandler handler = new();
|
||||
|
||||
// Act
|
||||
Func<Task> act = async () => await handler.SendAsync(null!);
|
||||
|
||||
// Assert
|
||||
await act.Should().ThrowAsync<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsyncWithEmptyUrlThrowsAsync()
|
||||
{
|
||||
// Arrange
|
||||
await using DefaultHttpRequestHandler handler = new();
|
||||
HttpRequestInfo request = new() { Method = "GET", Url = "" };
|
||||
|
||||
// Act
|
||||
Func<Task> act = async () => await handler.SendAsync(request);
|
||||
|
||||
// Assert
|
||||
await act.Should().ThrowAsync<ArgumentException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsyncWithEmptyMethodThrowsAsync()
|
||||
{
|
||||
// Arrange
|
||||
await using DefaultHttpRequestHandler handler = new();
|
||||
HttpRequestInfo request = new() { Method = "", Url = TestUrl };
|
||||
|
||||
// Act
|
||||
Func<Task> act = async () => await handler.SendAsync(request);
|
||||
|
||||
// Assert
|
||||
await act.Should().ThrowAsync<ArgumentException>();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Send Behavior Tests
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsyncUsesProvidedHttpClientAsync()
|
||||
{
|
||||
// Arrange
|
||||
TestHttpMessageHandler messageHandler = new((req, _) =>
|
||||
Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent("hello", Encoding.UTF8, "text/plain"),
|
||||
}));
|
||||
|
||||
await using DefaultHttpRequestHandler handler = new((_, _) => Task.FromResult<HttpClient?>(new HttpClient(messageHandler)));
|
||||
|
||||
HttpRequestInfo request = new() { Method = "GET", Url = TestUrl };
|
||||
|
||||
// Act
|
||||
HttpRequestResult result = await handler.SendAsync(request);
|
||||
|
||||
// Assert
|
||||
messageHandler.LastRequest.Should().NotBeNull();
|
||||
messageHandler.LastRequest!.Method.Should().Be(HttpMethod.Get);
|
||||
messageHandler.LastRequest.RequestUri!.ToString().Should().Be(TestUrl);
|
||||
result.StatusCode.Should().Be(200);
|
||||
result.IsSuccessStatusCode.Should().BeTrue();
|
||||
result.Body.Should().Be("hello");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsyncMapsAllKnownMethodsAsync()
|
||||
{
|
||||
// Arrange
|
||||
TestHttpMessageHandler messageHandler = new((req, _) =>
|
||||
Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)));
|
||||
|
||||
await using DefaultHttpRequestHandler handler = new((_, _) => Task.FromResult<HttpClient?>(new HttpClient(messageHandler)));
|
||||
|
||||
foreach (string method in new[] { "GET", "POST", "PUT", "PATCH", "DELETE", "CUSTOM" })
|
||||
{
|
||||
HttpRequestInfo request = new() { Method = method, Url = TestUrl };
|
||||
|
||||
// Act
|
||||
await handler.SendAsync(request);
|
||||
|
||||
// Assert
|
||||
messageHandler.LastRequest!.Method.Method.Should().Be(method);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsyncNormalizesWhitespaceAroundCustomMethodAsync()
|
||||
{
|
||||
// Arrange
|
||||
TestHttpMessageHandler messageHandler = new((req, _) =>
|
||||
Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)));
|
||||
await using DefaultHttpRequestHandler handler = new((_, _) => Task.FromResult<HttpClient?>(new HttpClient(messageHandler)));
|
||||
HttpRequestInfo request = new() { Method = " custom ", Url = TestUrl };
|
||||
|
||||
// Act
|
||||
await handler.SendAsync(request);
|
||||
|
||||
// Assert - fallback path should apply the same Trim/ToUpperInvariant normalization.
|
||||
messageHandler.LastRequest!.Method.Method.Should().Be("CUSTOM");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsyncAppliesBodyAndContentTypeAsync()
|
||||
{
|
||||
// Arrange
|
||||
TestHttpMessageHandler messageHandler = new((req, _) =>
|
||||
Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)));
|
||||
|
||||
await using DefaultHttpRequestHandler handler = new((_, _) => Task.FromResult<HttpClient?>(new HttpClient(messageHandler)));
|
||||
|
||||
HttpRequestInfo request = new()
|
||||
{
|
||||
Method = "POST",
|
||||
Url = TestUrl,
|
||||
Body = "{\"hello\":\"world\"}",
|
||||
BodyContentType = "application/json",
|
||||
};
|
||||
|
||||
// Act
|
||||
await handler.SendAsync(request);
|
||||
|
||||
// Assert
|
||||
messageHandler.LastRequestBody.Should().Be("{\"hello\":\"world\"}");
|
||||
messageHandler.LastRequestContentType.Should().Be("application/json");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsyncAppliesRequestHeadersAsync()
|
||||
{
|
||||
// Arrange
|
||||
TestHttpMessageHandler messageHandler = new((req, _) =>
|
||||
Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)));
|
||||
|
||||
await using DefaultHttpRequestHandler handler = new((_, _) => Task.FromResult<HttpClient?>(new HttpClient(messageHandler)));
|
||||
|
||||
HttpRequestInfo request = new()
|
||||
{
|
||||
Method = "GET",
|
||||
Url = TestUrl,
|
||||
Headers = new Dictionary<string, string>
|
||||
{
|
||||
["Authorization"] = "Bearer secret",
|
||||
["Accept"] = "application/json",
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
await handler.SendAsync(request);
|
||||
|
||||
// Assert
|
||||
messageHandler.LastRequest!.Headers.Authorization!.ToString().Should().Be("Bearer secret");
|
||||
messageHandler.LastRequest.Headers.Accept.Should().Contain(mediaType => mediaType.MediaType == "application/json");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsyncRoutesContentHeadersToBodyAsync()
|
||||
{
|
||||
// Arrange
|
||||
TestHttpMessageHandler messageHandler = new((req, _) =>
|
||||
Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)));
|
||||
|
||||
await using DefaultHttpRequestHandler handler = new((_, _) => Task.FromResult<HttpClient?>(new HttpClient(messageHandler)));
|
||||
|
||||
HttpRequestInfo request = new()
|
||||
{
|
||||
Method = "POST",
|
||||
Url = TestUrl,
|
||||
Body = "raw",
|
||||
BodyContentType = "text/plain",
|
||||
Headers = new Dictionary<string, string>
|
||||
{
|
||||
["Content-Language"] = "en-US",
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
await handler.SendAsync(request);
|
||||
|
||||
// Assert
|
||||
messageHandler.LastRequest!.Content!.Headers.ContentLanguage.Should().Contain("en-US");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsyncCapturesResponseHeadersAsync()
|
||||
{
|
||||
// Arrange
|
||||
TestHttpMessageHandler messageHandler = new((req, _) =>
|
||||
{
|
||||
#pragma warning disable CA2025
|
||||
HttpResponseMessage response = new(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent("ok", Encoding.UTF8, "text/plain"),
|
||||
};
|
||||
response.Headers.Add("X-Request-Id", "request-1");
|
||||
response.Headers.Add("Set-Cookie", s_setCookieValues);
|
||||
return Task.FromResult(response);
|
||||
#pragma warning restore CA2025
|
||||
});
|
||||
|
||||
await using DefaultHttpRequestHandler handler = new((_, _) => Task.FromResult<HttpClient?>(new HttpClient(messageHandler)));
|
||||
|
||||
HttpRequestInfo request = new() { Method = "GET", Url = TestUrl };
|
||||
|
||||
// Act
|
||||
HttpRequestResult result = await handler.SendAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Headers.Should().NotBeNull();
|
||||
result.Headers!.Should().ContainKey("X-Request-Id");
|
||||
result.Headers!["Set-Cookie"].Should().BeEquivalentTo(s_setCookieValues);
|
||||
// Content headers also flattened in.
|
||||
result.Headers!.Should().ContainKey("Content-Type");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsyncReturnsFailureStatusWithoutThrowingAsync()
|
||||
{
|
||||
// Arrange
|
||||
TestHttpMessageHandler messageHandler = new((req, _) =>
|
||||
Task.FromResult(new HttpResponseMessage(HttpStatusCode.BadRequest)
|
||||
{
|
||||
Content = new StringContent("bad request", Encoding.UTF8, "text/plain"),
|
||||
}));
|
||||
|
||||
await using DefaultHttpRequestHandler handler = new((_, _) => Task.FromResult<HttpClient?>(new HttpClient(messageHandler)));
|
||||
|
||||
HttpRequestInfo request = new() { Method = "GET", Url = TestUrl };
|
||||
|
||||
// Act
|
||||
HttpRequestResult result = await handler.SendAsync(request);
|
||||
|
||||
// Assert
|
||||
result.IsSuccessStatusCode.Should().BeFalse();
|
||||
result.StatusCode.Should().Be(400);
|
||||
result.Body.Should().Be("bad request");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsyncTimeoutCancelsRequestAsync()
|
||||
{
|
||||
// Arrange
|
||||
TestHttpMessageHandler messageHandler = new(async (req, ct) =>
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(5), ct).ConfigureAwait(false);
|
||||
return new HttpResponseMessage(HttpStatusCode.OK);
|
||||
});
|
||||
|
||||
await using DefaultHttpRequestHandler handler = new((_, _) => Task.FromResult<HttpClient?>(new HttpClient(messageHandler)));
|
||||
|
||||
HttpRequestInfo request = new()
|
||||
{
|
||||
Method = "GET",
|
||||
Url = TestUrl,
|
||||
Timeout = TimeSpan.FromMilliseconds(50),
|
||||
};
|
||||
|
||||
// Act
|
||||
Func<Task> act = async () => await handler.SendAsync(request);
|
||||
|
||||
// Assert
|
||||
await act.Should().ThrowAsync<OperationCanceledException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsyncFallsBackToOwnedClientWhenProviderReturnsNullAsync()
|
||||
{
|
||||
// Arrange
|
||||
int providerCallCount = 0;
|
||||
await using DefaultHttpRequestHandler handler = new((_, _) =>
|
||||
{
|
||||
providerCallCount++;
|
||||
return Task.FromResult<HttpClient?>(null);
|
||||
});
|
||||
|
||||
HttpRequestInfo request = new() { Method = "GET", Url = "http://127.0.0.1:1/" };
|
||||
|
||||
// Act - owned client will attempt real network and fail, but provider path should have been consulted first.
|
||||
Func<Task> act = async () => await handler.SendAsync(request);
|
||||
|
||||
// Assert
|
||||
await act.Should().ThrowAsync<Exception>();
|
||||
providerCallCount.Should().Be(1);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region DisposeAsync
|
||||
|
||||
[Fact]
|
||||
public async Task DisposeAsyncCompletesAsync()
|
||||
{
|
||||
// Arrange
|
||||
DefaultHttpRequestHandler handler = new();
|
||||
|
||||
// Act
|
||||
Func<Task> act = async () => await handler.DisposeAsync();
|
||||
|
||||
// Assert
|
||||
await act.Should().NotThrowAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisposeAsyncCalledMultipleTimesSucceedsAsync()
|
||||
{
|
||||
// Arrange
|
||||
DefaultHttpRequestHandler handler = new();
|
||||
|
||||
// Act
|
||||
await handler.DisposeAsync();
|
||||
Func<Task> second = async () => await handler.DisposeAsync();
|
||||
|
||||
// Assert
|
||||
await second.Should().NotThrowAsync();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Query Parameters and Connection Tests
|
||||
|
||||
[Fact]
|
||||
public async Task QueryParametersAreAppendedToUrlAsync()
|
||||
{
|
||||
// Arrange
|
||||
TestHttpMessageHandler fake = new(static (req, _) =>
|
||||
Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(string.Empty) }));
|
||||
await using DefaultHttpRequestHandler handler = new((_, _) => Task.FromResult<HttpClient?>(new HttpClient(fake)));
|
||||
|
||||
HttpRequestInfo info = new()
|
||||
{
|
||||
Method = "GET",
|
||||
Url = TestUrl,
|
||||
QueryParameters = new Dictionary<string, string>
|
||||
{
|
||||
["filter"] = "active items",
|
||||
["ids"] = "1,2,3",
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
await handler.SendAsync(info);
|
||||
|
||||
// Assert
|
||||
fake.LastRequest.Should().NotBeNull();
|
||||
string? query = fake.LastRequest!.RequestUri!.Query;
|
||||
query.Should().Contain("filter=active%20items");
|
||||
query.Should().Contain("ids=1%2C2%2C3");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryParametersPreserveExistingQueryStringAsync()
|
||||
{
|
||||
// Arrange
|
||||
TestHttpMessageHandler fake = new(static (req, _) =>
|
||||
Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(string.Empty) }));
|
||||
await using DefaultHttpRequestHandler handler = new((_, _) => Task.FromResult<HttpClient?>(new HttpClient(fake)));
|
||||
|
||||
HttpRequestInfo info = new()
|
||||
{
|
||||
Method = "GET",
|
||||
Url = TestUrl + "?existing=yes",
|
||||
QueryParameters = new Dictionary<string, string>
|
||||
{
|
||||
["added"] = "true",
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
await handler.SendAsync(info);
|
||||
|
||||
// Assert
|
||||
fake.LastRequest!.RequestUri!.Query.Should().Be("?existing=yes&added=true");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private sealed class TestHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> _responseFactory;
|
||||
|
||||
public TestHttpMessageHandler(Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> responseFactory)
|
||||
{
|
||||
this._responseFactory = responseFactory;
|
||||
}
|
||||
|
||||
public HttpRequestMessage? LastRequest { get; private set; }
|
||||
|
||||
public string? LastRequestBody { get; private set; }
|
||||
|
||||
public string? LastRequestContentType { get; private set; }
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
this.LastRequest = request;
|
||||
if (request.Content is not null)
|
||||
{
|
||||
#if NET
|
||||
this.LastRequestBody = await request.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
#else
|
||||
this.LastRequestBody = await request.Content.ReadAsStringAsync().ConfigureAwait(false);
|
||||
#endif
|
||||
this.LastRequestContentType = request.Content.Headers.ContentType?.MediaType;
|
||||
}
|
||||
return await this._responseFactory(request, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
+759
@@ -0,0 +1,759 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
|
||||
using Microsoft.Agents.ObjectModel;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.PowerFx.Types;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="HttpRequestExecutor"/>.
|
||||
/// </summary>
|
||||
public sealed class HttpRequestExecutorTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output)
|
||||
{
|
||||
private const string TestUrl = "https://api.example.com/data";
|
||||
|
||||
private readonly Mock<ResponseAgentProvider> _agentProvider = new(MockBehavior.Loose);
|
||||
|
||||
[Fact]
|
||||
public void InvalidModel()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IHttpRequestHandler> mockHandler = new();
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<DeclarativeModelException>(() => new HttpRequestExecutor(
|
||||
new HttpRequestAction(),
|
||||
mockHandler.Object,
|
||||
this._agentProvider.Object,
|
||||
this.State));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HttpRequestIsDiscreteAction()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IHttpRequestHandler> mockHandler = new();
|
||||
HttpRequestAction model = this.CreateModel(
|
||||
displayName: nameof(HttpRequestIsDiscreteAction),
|
||||
url: TestUrl,
|
||||
method: HttpMethodType.Get);
|
||||
HttpRequestExecutor action = new(model, mockHandler.Object, this._agentProvider.Object, this.State);
|
||||
|
||||
// Act & Assert — IsDiscreteAction should be true for HttpRequest (single-step action).
|
||||
VerifyIsDiscrete(action, isDiscrete: true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HttpGetReturnsJsonObjectAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.State.InitializeSystem();
|
||||
const string ResponseVar = "Result";
|
||||
HttpRequestAction model = this.CreateModel(
|
||||
displayName: nameof(HttpGetReturnsJsonObjectAsync),
|
||||
url: TestUrl,
|
||||
method: HttpMethodType.Get,
|
||||
responseVariable: ResponseVar);
|
||||
|
||||
MockHttpRequestHandler handler = new(HttpRequestResult("{\"key\":\"value\",\"number\":42}"));
|
||||
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
|
||||
|
||||
// Act
|
||||
await this.ExecuteAsync(action);
|
||||
|
||||
// Assert
|
||||
VerifyModel(model, action);
|
||||
Assert.IsType<RecordValue>(this.State.Get(ResponseVar), exactMatch: false);
|
||||
handler.VerifySent(info => info.Method == "GET" && info.Url == TestUrl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HttpGetReturnsPlainStringAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.State.InitializeSystem();
|
||||
const string ResponseVar = "Result";
|
||||
HttpRequestAction model = this.CreateModel(
|
||||
displayName: nameof(HttpGetReturnsPlainStringAsync),
|
||||
url: TestUrl,
|
||||
method: HttpMethodType.Get,
|
||||
responseVariable: ResponseVar);
|
||||
|
||||
MockHttpRequestHandler handler = new(HttpRequestResult("not-json content"));
|
||||
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
|
||||
|
||||
// Act
|
||||
await this.ExecuteAsync(action);
|
||||
|
||||
// Assert
|
||||
VerifyModel(model, action);
|
||||
this.VerifyState(ResponseVar, FormulaValue.New("not-json content"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HttpGetWithEmptyBodyYieldsBlankAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.State.InitializeSystem();
|
||||
const string ResponseVar = "Result";
|
||||
HttpRequestAction model = this.CreateModel(
|
||||
displayName: nameof(HttpGetWithEmptyBodyYieldsBlankAsync),
|
||||
url: TestUrl,
|
||||
method: HttpMethodType.Get,
|
||||
responseVariable: ResponseVar);
|
||||
|
||||
MockHttpRequestHandler handler = new(HttpRequestResult(null));
|
||||
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
|
||||
|
||||
// Act
|
||||
await this.ExecuteAsync(action);
|
||||
|
||||
// Assert
|
||||
VerifyModel(model, action);
|
||||
this.VerifyUndefined(ResponseVar);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HttpGetForwardsHeadersAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.State.InitializeSystem();
|
||||
HttpRequestAction model = this.CreateModel(
|
||||
displayName: nameof(HttpGetForwardsHeadersAsync),
|
||||
url: TestUrl,
|
||||
method: HttpMethodType.Get,
|
||||
headers: new Dictionary<string, string>
|
||||
{
|
||||
["Authorization"] = "Bearer token",
|
||||
["Accept"] = "application/json",
|
||||
});
|
||||
|
||||
MockHttpRequestHandler handler = new(HttpRequestResult("{}"));
|
||||
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
|
||||
|
||||
// Act
|
||||
await this.ExecuteAsync(action);
|
||||
|
||||
// Assert
|
||||
VerifyModel(model, action);
|
||||
handler.VerifySent(info =>
|
||||
info.Headers?["Authorization"] == "Bearer token" &&
|
||||
info.Headers?["Accept"] == "application/json");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HttpPostWithJsonBodyAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.State.InitializeSystem();
|
||||
HttpRequestAction model = this.CreateModel(
|
||||
displayName: nameof(HttpPostWithJsonBodyAsync),
|
||||
url: TestUrl,
|
||||
method: HttpMethodType.Post,
|
||||
jsonBody: new StringDataValue("hello"));
|
||||
|
||||
MockHttpRequestHandler handler = new(HttpRequestResult("{}"));
|
||||
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
|
||||
|
||||
// Act
|
||||
await this.ExecuteAsync(action);
|
||||
|
||||
// Assert
|
||||
VerifyModel(model, action);
|
||||
handler.VerifySent(info =>
|
||||
info.Method == "POST" &&
|
||||
info.BodyContentType == "application/json" &&
|
||||
info.Body == "\"hello\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HttpPostWithRawBodyAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.State.InitializeSystem();
|
||||
HttpRequestAction model = this.CreateModel(
|
||||
displayName: nameof(HttpPostWithRawBodyAsync),
|
||||
url: TestUrl,
|
||||
method: HttpMethodType.Post,
|
||||
rawBody: "raw body content",
|
||||
rawContentType: "text/plain");
|
||||
|
||||
MockHttpRequestHandler handler = new(HttpRequestResult(""));
|
||||
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
|
||||
|
||||
// Act
|
||||
await this.ExecuteAsync(action);
|
||||
|
||||
// Assert
|
||||
VerifyModel(model, action);
|
||||
handler.VerifySent(info =>
|
||||
info.BodyContentType == "text/plain" &&
|
||||
info.Body == "raw body content");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HttpRequestRaisesOnErrorByDefaultAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.State.InitializeSystem();
|
||||
HttpRequestAction model = this.CreateModel(
|
||||
displayName: nameof(HttpRequestRaisesOnErrorByDefaultAsync),
|
||||
url: TestUrl,
|
||||
method: HttpMethodType.Get);
|
||||
|
||||
MockHttpRequestHandler handler = new(HttpRequestResult("server error", statusCode: 500, isSuccess: false));
|
||||
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<DeclarativeActionException>(() => this.ExecuteAsync(action));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HttpRequestFailureExceptionTruncatesLongBodyAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.State.InitializeSystem();
|
||||
HttpRequestAction model = this.CreateModel(
|
||||
displayName: nameof(HttpRequestFailureExceptionTruncatesLongBodyAsync),
|
||||
url: TestUrl,
|
||||
method: HttpMethodType.Get);
|
||||
|
||||
string longBody = new('x', 10_000);
|
||||
MockHttpRequestHandler handler = new(HttpRequestResult(longBody, statusCode: 500, isSuccess: false));
|
||||
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
|
||||
|
||||
// Act
|
||||
DeclarativeActionException exception =
|
||||
await Assert.ThrowsAsync<DeclarativeActionException>(() => this.ExecuteAsync(action));
|
||||
|
||||
// Assert - message contains status and truncation marker, bounded in length, never the full body.
|
||||
Assert.Contains("500", exception.Message);
|
||||
Assert.Contains("[truncated]", exception.Message);
|
||||
Assert.DoesNotContain(longBody, exception.Message);
|
||||
Assert.True(exception.Message.Length < 512, $"Exception message too long: {exception.Message.Length} chars.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HttpRequestFailureExceptionOmitsEmptyBodyAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.State.InitializeSystem();
|
||||
HttpRequestAction model = this.CreateModel(
|
||||
displayName: nameof(HttpRequestFailureExceptionOmitsEmptyBodyAsync),
|
||||
url: TestUrl,
|
||||
method: HttpMethodType.Get);
|
||||
|
||||
MockHttpRequestHandler handler = new(HttpRequestResult(body: null, statusCode: 404, isSuccess: false));
|
||||
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
|
||||
|
||||
// Act
|
||||
DeclarativeActionException exception =
|
||||
await Assert.ThrowsAsync<DeclarativeActionException>(() => this.ExecuteAsync(action));
|
||||
|
||||
// Assert - status present, no stray "Body: ''" noise.
|
||||
Assert.Contains("404", exception.Message);
|
||||
Assert.DoesNotContain("Body:", exception.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HttpRequestFailureExceptionSanitizesControlCharsAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.State.InitializeSystem();
|
||||
HttpRequestAction model = this.CreateModel(
|
||||
displayName: nameof(HttpRequestFailureExceptionSanitizesControlCharsAsync),
|
||||
url: TestUrl,
|
||||
method: HttpMethodType.Get);
|
||||
|
||||
MockHttpRequestHandler handler = new(HttpRequestResult("line1\r\nline2\tend", statusCode: 400, isSuccess: false));
|
||||
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
|
||||
|
||||
// Act
|
||||
DeclarativeActionException exception =
|
||||
await Assert.ThrowsAsync<DeclarativeActionException>(() => this.ExecuteAsync(action));
|
||||
|
||||
// Assert - CR/LF/TAB collapsed to spaces so the message stays on one line.
|
||||
Assert.DoesNotContain("\r", exception.Message);
|
||||
Assert.DoesNotContain("\n", exception.Message);
|
||||
Assert.DoesNotContain("\t", exception.Message);
|
||||
Assert.Contains("line1", exception.Message);
|
||||
Assert.Contains("line2", exception.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HttpRequestPassesTimeoutToHandlerAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.State.InitializeSystem();
|
||||
HttpRequestAction model = this.CreateModel(
|
||||
displayName: nameof(HttpRequestPassesTimeoutToHandlerAsync),
|
||||
url: TestUrl,
|
||||
method: HttpMethodType.Get,
|
||||
timeoutMilliseconds: 1500);
|
||||
|
||||
MockHttpRequestHandler handler = new(HttpRequestResult("{}"));
|
||||
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
|
||||
|
||||
// Act
|
||||
await this.ExecuteAsync(action);
|
||||
|
||||
// Assert
|
||||
VerifyModel(model, action);
|
||||
handler.VerifySent(info =>
|
||||
info.Timeout is not null &&
|
||||
info.Timeout.Value == TimeSpan.FromMilliseconds(1500));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HttpRequestTimeoutRaisesDeclarativeExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.State.InitializeSystem();
|
||||
HttpRequestAction model = this.CreateModel(
|
||||
displayName: nameof(HttpRequestTimeoutRaisesDeclarativeExceptionAsync),
|
||||
url: TestUrl,
|
||||
method: HttpMethodType.Get);
|
||||
|
||||
MockHttpRequestHandler handler = new(
|
||||
HttpRequestResult("{}"),
|
||||
throwOnSend: new OperationCanceledException());
|
||||
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<DeclarativeActionException>(() => this.ExecuteAsync(action));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HttpRequestTransportFailureRaisesDeclarativeExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.State.InitializeSystem();
|
||||
HttpRequestAction model = this.CreateModel(
|
||||
displayName: nameof(HttpRequestTransportFailureRaisesDeclarativeExceptionAsync),
|
||||
url: TestUrl,
|
||||
method: HttpMethodType.Get);
|
||||
|
||||
MockHttpRequestHandler handler = new(
|
||||
HttpRequestResult("{}"),
|
||||
throwOnSend: new InvalidOperationException("transport failure"));
|
||||
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<DeclarativeActionException>(() => this.ExecuteAsync(action));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HttpRequestStoresResponseHeadersAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.State.InitializeSystem();
|
||||
const string HeaderVar = "Headers";
|
||||
HttpRequestAction model = this.CreateModel(
|
||||
displayName: nameof(HttpRequestStoresResponseHeadersAsync),
|
||||
url: TestUrl,
|
||||
method: HttpMethodType.Get,
|
||||
responseHeadersVariable: HeaderVar);
|
||||
|
||||
Dictionary<string, IReadOnlyList<string>> responseHeaders = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["X-Request-Id"] = ["abc-123"],
|
||||
["Set-Cookie"] = ["a=1", "b=2"],
|
||||
};
|
||||
MockHttpRequestHandler handler = new(HttpRequestResult("{}", headers: responseHeaders));
|
||||
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
|
||||
|
||||
// Act
|
||||
await this.ExecuteAsync(action);
|
||||
|
||||
// Assert
|
||||
VerifyModel(model, action);
|
||||
FormulaValue storedHeaders = this.State.Get(HeaderVar);
|
||||
Assert.IsType<RecordValue>(storedHeaders, exactMatch: false);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HttpRequestForwardsQueryParametersAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.State.InitializeSystem();
|
||||
HttpRequestAction model = this.CreateModel(
|
||||
displayName: nameof(HttpRequestForwardsQueryParametersAsync),
|
||||
url: TestUrl,
|
||||
method: HttpMethodType.Get,
|
||||
queryParameters: new Dictionary<string, DataValue>
|
||||
{
|
||||
["filter"] = StringDataValue.Create("active"),
|
||||
["limit"] = NumberDataValue.Create(10),
|
||||
["includeDeleted"] = BooleanDataValue.Create(false),
|
||||
});
|
||||
|
||||
MockHttpRequestHandler handler = new(HttpRequestResult("{}"));
|
||||
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
|
||||
|
||||
// Act
|
||||
await this.ExecuteAsync(action);
|
||||
|
||||
// Assert
|
||||
VerifyModel(model, action);
|
||||
handler.VerifySent(info =>
|
||||
info.QueryParameters?.Count == 3 &&
|
||||
info.QueryParameters["filter"] == "active" &&
|
||||
info.QueryParameters["limit"] == "10" &&
|
||||
info.QueryParameters["includeDeleted"] == "false");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HttpRequestAddsResponseToConversationAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.State.InitializeSystem();
|
||||
const string ConversationId = "conv-12345";
|
||||
const string ResponseBody = "response-text";
|
||||
|
||||
this._agentProvider
|
||||
.Setup(p => p.CreateMessageAsync(It.IsAny<string>(), It.IsAny<ChatMessage>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<string, ChatMessage, CancellationToken>((_, message, _) => Task.FromResult(message));
|
||||
|
||||
HttpRequestAction model = this.CreateModel(
|
||||
displayName: nameof(HttpRequestAddsResponseToConversationAsync),
|
||||
url: TestUrl,
|
||||
method: HttpMethodType.Get,
|
||||
conversationId: ConversationId);
|
||||
|
||||
MockHttpRequestHandler handler = new(HttpRequestResult(ResponseBody));
|
||||
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
|
||||
|
||||
// Act
|
||||
await this.ExecuteAsync(action);
|
||||
|
||||
// Assert
|
||||
VerifyModel(model, action);
|
||||
this._agentProvider.Verify(
|
||||
p => p.CreateMessageAsync(
|
||||
ConversationId,
|
||||
It.Is<ChatMessage>(m => m.Role == ChatRole.Assistant && m.Text == ResponseBody),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HttpRequestWithoutConversationIdSkipsConversationAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.State.InitializeSystem();
|
||||
HttpRequestAction model = this.CreateModel(
|
||||
displayName: nameof(HttpRequestWithoutConversationIdSkipsConversationAsync),
|
||||
url: TestUrl,
|
||||
method: HttpMethodType.Get);
|
||||
|
||||
MockHttpRequestHandler handler = new(HttpRequestResult("response"));
|
||||
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
|
||||
|
||||
// Act
|
||||
await this.ExecuteAsync(action);
|
||||
|
||||
// Assert
|
||||
VerifyModel(model, action);
|
||||
this._agentProvider.Verify(
|
||||
p => p.CreateMessageAsync(It.IsAny<string>(), It.IsAny<ChatMessage>(), It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HttpRequestForwardsConnectionNameAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.State.InitializeSystem();
|
||||
const string ConnectionName = "my-connection";
|
||||
HttpRequestAction model = this.CreateModel(
|
||||
displayName: nameof(HttpRequestForwardsConnectionNameAsync),
|
||||
url: TestUrl,
|
||||
method: HttpMethodType.Get,
|
||||
connectionName: ConnectionName);
|
||||
|
||||
MockHttpRequestHandler handler = new(HttpRequestResult("{}"));
|
||||
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
|
||||
|
||||
// Act
|
||||
await this.ExecuteAsync(action);
|
||||
|
||||
// Assert
|
||||
VerifyModel(model, action);
|
||||
handler.VerifySent(info => info.ConnectionName == ConnectionName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HttpRequestEmptyConversationIdSkipsConversationAsync()
|
||||
{
|
||||
// Arrange - empty-string conversationId should be treated as unset.
|
||||
this.State.InitializeSystem();
|
||||
HttpRequestAction model = this.CreateModel(
|
||||
displayName: nameof(HttpRequestEmptyConversationIdSkipsConversationAsync),
|
||||
url: TestUrl,
|
||||
method: HttpMethodType.Get,
|
||||
conversationId: "");
|
||||
|
||||
MockHttpRequestHandler handler = new(HttpRequestResult("response"));
|
||||
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
|
||||
|
||||
// Act
|
||||
await this.ExecuteAsync(action);
|
||||
|
||||
// Assert
|
||||
VerifyModel(model, action);
|
||||
this._agentProvider.Verify(
|
||||
p => p.CreateMessageAsync(It.IsAny<string>(), It.IsAny<ChatMessage>(), It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HttpRequestEmptyResponseBodySkipsConversationAsync()
|
||||
{
|
||||
// Arrange - conversationId set, but empty body should not produce a conversation message.
|
||||
this.State.InitializeSystem();
|
||||
HttpRequestAction model = this.CreateModel(
|
||||
displayName: nameof(HttpRequestEmptyResponseBodySkipsConversationAsync),
|
||||
url: TestUrl,
|
||||
method: HttpMethodType.Get,
|
||||
conversationId: "conv-1");
|
||||
|
||||
MockHttpRequestHandler handler = new(HttpRequestResult(""));
|
||||
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
|
||||
|
||||
// Act
|
||||
await this.ExecuteAsync(action);
|
||||
|
||||
// Assert
|
||||
VerifyModel(model, action);
|
||||
this._agentProvider.Verify(
|
||||
p => p.CreateMessageAsync(It.IsAny<string>(), It.IsAny<ChatMessage>(), It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HttpGetReturnsJsonArrayAsync()
|
||||
{
|
||||
// Arrange - exercises JsonValueKind.Array branch of ParseResponseBody.
|
||||
this.State.InitializeSystem();
|
||||
const string ResponseVar = "Result";
|
||||
HttpRequestAction model = this.CreateModel(
|
||||
displayName: nameof(HttpGetReturnsJsonArrayAsync),
|
||||
url: TestUrl,
|
||||
method: HttpMethodType.Get,
|
||||
responseVariable: ResponseVar);
|
||||
|
||||
MockHttpRequestHandler handler = new(HttpRequestResult("[1, 2, 3]"));
|
||||
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
|
||||
|
||||
// Act
|
||||
await this.ExecuteAsync(action);
|
||||
|
||||
// Assert
|
||||
VerifyModel(model, action);
|
||||
FormulaValue stored = this.State.Get(ResponseVar);
|
||||
Assert.IsType<TableValue>(stored, exactMatch: false);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HttpGetWithEmptyHeaderValueDropsHeaderAsync()
|
||||
{
|
||||
// Arrange - empty header values should be filtered out (matches GetHeaders guard).
|
||||
this.State.InitializeSystem();
|
||||
HttpRequestAction model = this.CreateModel(
|
||||
displayName: nameof(HttpGetWithEmptyHeaderValueDropsHeaderAsync),
|
||||
url: TestUrl,
|
||||
method: HttpMethodType.Get,
|
||||
headers: new Dictionary<string, string>
|
||||
{
|
||||
["X-Trace"] = "trace-1",
|
||||
["X-Empty"] = "",
|
||||
});
|
||||
|
||||
MockHttpRequestHandler handler = new(HttpRequestResult("{}"));
|
||||
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
|
||||
|
||||
// Act
|
||||
await this.ExecuteAsync(action);
|
||||
|
||||
// Assert
|
||||
VerifyModel(model, action);
|
||||
handler.VerifySent(info =>
|
||||
info.Headers?.ContainsKey("X-Trace") == true &&
|
||||
info.Headers?.ContainsKey("X-Empty") == false);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HttpRequestZeroTimeoutNotForwardedAsync()
|
||||
{
|
||||
// Arrange - non-positive timeouts should not be forwarded (handler default applies).
|
||||
this.State.InitializeSystem();
|
||||
HttpRequestAction model = this.CreateModel(
|
||||
displayName: nameof(HttpRequestZeroTimeoutNotForwardedAsync),
|
||||
url: TestUrl,
|
||||
method: HttpMethodType.Get,
|
||||
timeoutMilliseconds: 0);
|
||||
|
||||
MockHttpRequestHandler handler = new(HttpRequestResult("{}"));
|
||||
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
|
||||
|
||||
// Act
|
||||
await this.ExecuteAsync(action);
|
||||
|
||||
// Assert
|
||||
VerifyModel(model, action);
|
||||
handler.VerifySent(info => info.Timeout is null);
|
||||
}
|
||||
|
||||
private static HttpRequestResult HttpRequestResult(
|
||||
string? body,
|
||||
int statusCode = 200,
|
||||
bool isSuccess = true,
|
||||
IReadOnlyDictionary<string, IReadOnlyList<string>>? headers = null) =>
|
||||
new()
|
||||
{
|
||||
StatusCode = statusCode,
|
||||
IsSuccessStatusCode = isSuccess,
|
||||
Body = body,
|
||||
Headers = headers,
|
||||
};
|
||||
|
||||
private HttpRequestAction CreateModel(
|
||||
string displayName,
|
||||
string url,
|
||||
HttpMethodType method,
|
||||
string? responseVariable = null,
|
||||
string? responseHeadersVariable = null,
|
||||
IReadOnlyDictionary<string, string>? headers = null,
|
||||
IReadOnlyDictionary<string, DataValue>? queryParameters = null,
|
||||
string? conversationId = null,
|
||||
string? connectionName = null,
|
||||
DataValue? jsonBody = null,
|
||||
string? rawBody = null,
|
||||
string? rawContentType = null,
|
||||
long? timeoutMilliseconds = null,
|
||||
string? continueOnErrorStatusVariable = null,
|
||||
string? continueOnErrorBodyVariable = null)
|
||||
{
|
||||
HttpRequestAction.Builder builder = new()
|
||||
{
|
||||
Id = this.CreateActionId(),
|
||||
DisplayName = this.FormatDisplayName(displayName),
|
||||
Url = new StringExpression.Builder(StringExpression.Literal(url)),
|
||||
Method = new EnumExpression<HttpMethodTypeWrapper>.Builder(
|
||||
EnumExpression<HttpMethodTypeWrapper>.Literal(HttpMethodTypeWrapper.Get(method))),
|
||||
};
|
||||
|
||||
if (responseVariable is not null)
|
||||
{
|
||||
builder.Response = PropertyPath.Create(FormatVariablePath(responseVariable));
|
||||
}
|
||||
|
||||
if (responseHeadersVariable is not null)
|
||||
{
|
||||
builder.ResponseHeaders = PropertyPath.Create(FormatVariablePath(responseHeadersVariable));
|
||||
}
|
||||
|
||||
if (headers is not null)
|
||||
{
|
||||
foreach (KeyValuePair<string, string> header in headers)
|
||||
{
|
||||
builder.Headers.Add(header.Key, new StringExpression.Builder(StringExpression.Literal(header.Value)));
|
||||
}
|
||||
}
|
||||
|
||||
if (queryParameters is not null)
|
||||
{
|
||||
foreach (KeyValuePair<string, DataValue> parameter in queryParameters)
|
||||
{
|
||||
builder.QueryParameters.Add(parameter.Key, new ValueExpression.Builder(ValueExpression.Literal(parameter.Value)));
|
||||
}
|
||||
}
|
||||
|
||||
if (conversationId is not null)
|
||||
{
|
||||
builder.ConversationId = new StringExpression.Builder(StringExpression.Literal(conversationId));
|
||||
}
|
||||
|
||||
if (connectionName is not null)
|
||||
{
|
||||
builder.Connection = new RemoteConnection.Builder
|
||||
{
|
||||
Name = new StringExpression.Builder(StringExpression.Literal(connectionName)),
|
||||
};
|
||||
}
|
||||
|
||||
if (jsonBody is not null)
|
||||
{
|
||||
builder.Body = new JsonRequestContent.Builder()
|
||||
{
|
||||
Content = new ValueExpression.Builder(ValueExpression.Literal(jsonBody)),
|
||||
};
|
||||
}
|
||||
else if (rawBody is not null)
|
||||
{
|
||||
RawRequestContent.Builder rawBuilder = new()
|
||||
{
|
||||
Content = new StringExpression.Builder(StringExpression.Literal(rawBody)),
|
||||
};
|
||||
if (rawContentType is not null)
|
||||
{
|
||||
rawBuilder.ContentType = new StringExpression.Builder(StringExpression.Literal(rawContentType));
|
||||
}
|
||||
builder.Body = rawBuilder;
|
||||
}
|
||||
|
||||
if (timeoutMilliseconds is not null)
|
||||
{
|
||||
builder.RequestTimeoutInMilliseconds = new IntExpression.Builder(IntExpression.Literal(timeoutMilliseconds.Value));
|
||||
}
|
||||
|
||||
if (continueOnErrorStatusVariable is not null || continueOnErrorBodyVariable is not null)
|
||||
{
|
||||
ContinueOnErrorBehavior.Builder continueBuilder = new();
|
||||
if (continueOnErrorStatusVariable is not null)
|
||||
{
|
||||
continueBuilder.StatusCode = PropertyPath.Create(FormatVariablePath(continueOnErrorStatusVariable));
|
||||
}
|
||||
if (continueOnErrorBodyVariable is not null)
|
||||
{
|
||||
continueBuilder.ErrorResponseBody = PropertyPath.Create(FormatVariablePath(continueOnErrorBodyVariable));
|
||||
}
|
||||
builder.ErrorHandling = continueBuilder;
|
||||
}
|
||||
|
||||
return AssignParent<HttpRequestAction>(builder);
|
||||
}
|
||||
|
||||
private sealed class MockHttpRequestHandler : Mock<IHttpRequestHandler>
|
||||
{
|
||||
private HttpRequestInfo? _lastRequest;
|
||||
|
||||
public MockHttpRequestHandler(HttpRequestResult result, Exception? throwOnSend = null)
|
||||
{
|
||||
this.Setup(handler => handler.SendAsync(It.IsAny<HttpRequestInfo>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<HttpRequestInfo, CancellationToken>((info, _) =>
|
||||
{
|
||||
this._lastRequest = info;
|
||||
if (throwOnSend is not null)
|
||||
{
|
||||
throw throwOnSend;
|
||||
}
|
||||
return Task.FromResult(result);
|
||||
});
|
||||
}
|
||||
|
||||
public void VerifySent(Func<HttpRequestInfo, bool> predicate)
|
||||
{
|
||||
Assert.NotNull(this._lastRequest);
|
||||
Assert.True(predicate(this._lastRequest!), "Sent HTTP request did not match expected predicate.");
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
kind: Workflow
|
||||
trigger:
|
||||
|
||||
kind: OnConversationStart
|
||||
id: my_workflow
|
||||
actions:
|
||||
|
||||
- kind: HttpRequestAction
|
||||
id: http_request
|
||||
method: GET
|
||||
url: =Concatenate("https://api.example.test/items/", System.LastMessageText)
|
||||
headers:
|
||||
Accept: application/json
|
||||
response: Local.HttpResult
|
||||
responseHeaders: Local.HttpHeaders
|
||||
@@ -61,7 +61,7 @@ repos:
|
||||
additional_dependencies: ["bandit[toml]"]
|
||||
- repo: https://github.com/astral-sh/uv-pre-commit
|
||||
# uv version.
|
||||
rev: 0.10.10
|
||||
rev: 0.11.6
|
||||
hooks:
|
||||
# Update the uv lockfile
|
||||
- id: uv-lock
|
||||
|
||||
@@ -69,6 +69,7 @@ python/
|
||||
|
||||
### Azure Integrations
|
||||
- [foundry](packages/foundry/README.md) - Microsoft Foundry chat, agent, memory, and embedding integrations
|
||||
- [azure-contentunderstanding](packages/azure-contentunderstanding/AGENTS.md) - Azure Content Understanding context provider
|
||||
- [azure-ai-search](packages/azure-ai-search/AGENTS.md) - Azure AI Search RAG
|
||||
- [azure-cosmos](packages/azure-cosmos/AGENTS.md) - Azure Cosmos DB-backed history provider
|
||||
- [azurefunctions](packages/azurefunctions/AGENTS.md) - Azure Functions hosting
|
||||
|
||||
+64
-3
@@ -7,6 +7,64 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.2.2] - 2026-04-29
|
||||
|
||||
### Added
|
||||
- **agent-framework-azure-contentunderstanding**: New alpha package — Azure AI Content Understanding context provider that auto-analyzes file attachments (documents, images, audio, video) and injects structured results into the LLM context, with multi-document session state, configurable timeout, output filtering via `AnalysisSection`, and auto-registered `list_documents` / `get_analyzed_document` tools ([#4829](https://github.com/microsoft/agent-framework/pull/4829))
|
||||
- **agent-framework-foundry-hosting**: Add hosted Durable Workflow support — propagate full conversation history to workflow agents and wire `Workflow.as_agent()` end-to-end via the foundry hosting layer ([#5531](https://github.com/microsoft/agent-framework/pull/5531))
|
||||
|
||||
### Changed
|
||||
- **agent-framework-orchestrations**: [BREAKING] Standardize orchestration terminal outputs as `AgentResponse` so `Workflow.as_agent()` returns the final answer only; aligns sequential-approval (`with_request_info`) and concurrent (`intermediate_outputs=True`) flows on the same output contract ([#5301](https://github.com/microsoft/agent-framework/pull/5301))
|
||||
- **agent-framework-core**, **agent-framework-declarative**: Preserve `Workflow.run()` shared state across calls so multi-turn `WorkflowAgent` invocations retain context, accept `list[Message]` input in the declarative start executor, and coerce `Enum` values when serializing PowerFx symbols ([#5531](https://github.com/microsoft/agent-framework/pull/5531))
|
||||
- **dependencies**: Update workspace package dependencies and preserve `mcp[ws]` / `uvicorn[standard]` extras through override-dependencies in `/python` ([#5555](https://github.com/microsoft/agent-framework/pull/5555))
|
||||
|
||||
### Fixed
|
||||
- **agent-framework-core**: Fix observability spans not being correctly nested when using streaming ([#5552](https://github.com/microsoft/agent-framework/pull/5552))
|
||||
- **agent-framework-openai**: Fix `file_search` citations breaking the assistant-message history roundtrip — skip `hosted_file` content in the assistant role so the Responses API no longer rejects `input_file` ([#5557](https://github.com/microsoft/agent-framework/pull/5557))
|
||||
|
||||
## [1.2.1] - 2026-04-28
|
||||
|
||||
### Added
|
||||
- **agent-framework-foundry-hosting**: Add file data type support to hosted-agent Responses, refresh `foundry-hosted-agents` samples, and add response test coverage ([#5485](https://github.com/microsoft/agent-framework/pull/5485))
|
||||
- **samples**: Add `requirements.txt` and `.env.example` to the `a2a/` hosting sample for pip-based setup ([#5510](https://github.com/microsoft/agent-framework/pull/5510))
|
||||
|
||||
### Changed
|
||||
- **dependencies**: Update `rich` requirement from `<15.0.0,>=13.7.1` to `>=13.7.1,<16.0.0` in `/python` ([#5227](https://github.com/microsoft/agent-framework/pull/5227))
|
||||
- **dependencies**: Bump `prek` from `0.3.8` to `0.3.9` in `/python` ([#5228](https://github.com/microsoft/agent-framework/pull/5228))
|
||||
- **dependencies**: Bump `python-multipart` from `0.0.22` to `0.0.26` in `/python` ([#5286](https://github.com/microsoft/agent-framework/pull/5286))
|
||||
- **dependencies**: Bump `pyasn1` from `0.6.2` to `0.6.3` in `/python` ([#4748](https://github.com/microsoft/agent-framework/pull/4748))
|
||||
- **dependencies**: Bump `pytest` from `9.0.2` to `9.0.3` in `/python/packages/ag-ui` ([#5461](https://github.com/microsoft/agent-framework/pull/5461))
|
||||
- **dependencies**: Bump `pytest` from `9.0.2` to `9.0.3` in `/python/packages/devui` ([#5492](https://github.com/microsoft/agent-framework/pull/5492))
|
||||
- **dependencies**: Bump `pytest` from `9.0.2` to `9.0.3` in `/python/packages/lab` ([#5470](https://github.com/microsoft/agent-framework/pull/5470))
|
||||
- **dependencies**: Bump `uv` from `0.11.3` to `0.11.6` in `/python/packages/lab` ([#5469](https://github.com/microsoft/agent-framework/pull/5469))
|
||||
- **dependencies**: Bump `vite` from `7.1.12` to `7.3.2` in `/python/packages/devui/frontend` ([#5127](https://github.com/microsoft/agent-framework/pull/5127))
|
||||
- **dependencies**: Bump `vite` from `7.1.12` to `7.3.2` in `/python/samples/05-end-to-end/chatkit-integration/frontend` ([#5126](https://github.com/microsoft/agent-framework/pull/5126))
|
||||
- **dependencies**: Bump `postcss` from `8.5.6` to `8.5.10` in `/python/packages/devui/frontend` ([#5484](https://github.com/microsoft/agent-framework/pull/5484))
|
||||
- **dependencies**: Bump `postcss` from `8.5.6` to `8.5.10` in `/python/samples/05-end-to-end/chatkit-integration/frontend` ([#5491](https://github.com/microsoft/agent-framework/pull/5491))
|
||||
- **dependencies**: Bump `postcss` from `8.5.6` to `8.5.12` in `/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend` ([#5527](https://github.com/microsoft/agent-framework/pull/5527))
|
||||
- **dependencies**: Bump `picomatch` from `4.0.3` to `4.0.4` in `/python/packages/devui/frontend` ([#4921](https://github.com/microsoft/agent-framework/pull/4921))
|
||||
- **dependencies**: Bump `picomatch` from `4.0.3` to `4.0.4` in `/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend` ([#4936](https://github.com/microsoft/agent-framework/pull/4936))
|
||||
|
||||
### Fixed
|
||||
- **agent-framework-core**: Prevent `inner_exception` from being lost in `AgentFrameworkException` ([#5167](https://github.com/microsoft/agent-framework/pull/5167))
|
||||
|
||||
## [1.2.0] - 2026-04-24
|
||||
|
||||
### Added
|
||||
- **agent-framework-core**: Add functional workflow API ([#4238](https://github.com/microsoft/agent-framework/pull/4238))
|
||||
- **agent-framework-core**, **agent-framework-github-copilot**: Add OpenTelemetry integration for `GitHubCopilotAgent` ([#5142](https://github.com/microsoft/agent-framework/pull/5142))
|
||||
- **agent-framework-a2a**: Add Agent Framework to A2A bridge support ([#2403](https://github.com/microsoft/agent-framework/pull/2403))
|
||||
- **agent-framework-foundry**: Surface `oauth_consent_request` events from Responses API in Foundry clients ([#5070](https://github.com/microsoft/agent-framework/pull/5070))
|
||||
|
||||
### Changed
|
||||
- **agent-framework-core**, **agent-framework-foundry**: Update `FoundryAgent` for hosted agent sessions ([#5447](https://github.com/microsoft/agent-framework/pull/5447))
|
||||
- **agent-framework-foundry-hosting**: Upgrade hosting server dependency and add more type support ([#5459](https://github.com/microsoft/agent-framework/pull/5459))
|
||||
|
||||
### Fixed
|
||||
- **agent-framework-ag-ui**: Fix reasoning role and multimodal media parsing to follow specification ([#5389](https://github.com/microsoft/agent-framework/pull/5389))
|
||||
- **agent-framework-foundry**: Stop emitting `[TOOLBOXES]` warning for every `FoundryChatClient` call ([#5440](https://github.com/microsoft/agent-framework/pull/5440))
|
||||
- **agent-framework-anthropic**, **agent-framework-azure-ai-search**, **agent-framework-azure-cosmos**: Fix user agent prefix ([#5455](https://github.com/microsoft/agent-framework/pull/5455))
|
||||
|
||||
## [1.1.1] - 2026-04-23
|
||||
|
||||
### Added
|
||||
@@ -26,8 +84,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- **agent-framework-openai**: Exclude null `file_id` from `input_image` payload to prevent schema 400 errors ([#5125](https://github.com/microsoft/agent-framework/pull/5125))
|
||||
- **agent-framework-foundry**: Reconcile Toolbox hosted-tool payloads with the Responses API ([#5414](https://github.com/microsoft/agent-framework/pull/5414))
|
||||
- **agent-framework-ag-ui**: Pass client `thread_id` as `session_id` when constructing `AgentSession` ([#5384](https://github.com/microsoft/agent-framework/pull/5384))
|
||||
- **agent-framework-hyperlight**: Thread-confine `WasmSandbox` interactions via per-entry `ThreadPoolExecutor` to eliminate the PyO3 `unsendable` panic when touched from asyncio worker threads
|
||||
([#5424](https://github.com/microsoft/agent-framework/pull/5424))
|
||||
- **agent-framework-hyperlight**: Thread-confine `WasmSandbox` interactions via per-entry `ThreadPoolExecutor` to eliminate the PyO3 `unsendable` panic when touched from asyncio worker threads ([#5424](https://github.com/microsoft/agent-framework/pull/5424))
|
||||
|
||||
## [1.1.0] - 2026-04-21
|
||||
|
||||
@@ -961,7 +1018,11 @@ 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.1.0...HEAD
|
||||
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.2.2...HEAD
|
||||
[1.2.2]: https://github.com/microsoft/agent-framework/compare/python-1.2.1...python-1.2.2
|
||||
[1.2.1]: https://github.com/microsoft/agent-framework/compare/python-1.2.0...python-1.2.1
|
||||
[1.2.0]: https://github.com/microsoft/agent-framework/compare/python-1.1.1...python-1.2.0
|
||||
[1.1.1]: https://github.com/microsoft/agent-framework/compare/python-1.1.0...python-1.1.1
|
||||
[1.1.0]: https://github.com/microsoft/agent-framework/compare/python-1.0.1...python-1.1.0
|
||||
[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
|
||||
|
||||
@@ -18,6 +18,7 @@ Status is grouped into these buckets:
|
||||
| `agent-framework-a2a` | `python/packages/a2a` | `beta` |
|
||||
| `agent-framework-ag-ui` | `python/packages/ag-ui` | `beta` |
|
||||
| `agent-framework-anthropic` | `python/packages/anthropic` | `beta` |
|
||||
| `agent-framework-azure-contentunderstanding` | `python/packages/azure-contentunderstanding` | `alpha` |
|
||||
| `agent-framework-azure-ai-search` | `python/packages/azure-ai-search` | `beta` |
|
||||
| `agent-framework-azure-cosmos` | `python/packages/azure-cosmos` | `beta` |
|
||||
| `agent-framework-azurefunctions` | `python/packages/azurefunctions` | `beta` |
|
||||
|
||||
@@ -4,20 +4,48 @@ Agent-to-Agent (A2A) protocol support for inter-agent communication.
|
||||
|
||||
## Main Classes
|
||||
|
||||
- **`A2AAgent`** - Agent wrapper that exposes an agent via the A2A protocol
|
||||
- **`A2AAgent`** - Client to connect to remote A2A-compliant agents.
|
||||
- **`A2AExecutor`** - Bridge to expose Agent Framework agents via the A2A protocol.
|
||||
|
||||
## Usage
|
||||
|
||||
### A2AAgent (Client)
|
||||
|
||||
```python
|
||||
from agent_framework.a2a import A2AAgent
|
||||
|
||||
a2a_agent = A2AAgent(agent=my_agent)
|
||||
# Connect to a remote A2A agent
|
||||
a2a_agent = A2AAgent(url="http://remote-agent/a2a")
|
||||
response = await a2a_agent.run("Hello!")
|
||||
```
|
||||
|
||||
### A2AExecutor (Server/Bridge)
|
||||
|
||||
```python
|
||||
from agent_framework.a2a import A2AExecutor
|
||||
from a2a.server.apps import A2AStarletteApplication
|
||||
from a2a.server.request_handlers import DefaultRequestHandler
|
||||
from a2a.server.tasks import InMemoryTaskStore
|
||||
|
||||
# Create an A2A executor for your agent
|
||||
executor = A2AExecutor(agent=my_agent)
|
||||
|
||||
# Set up the request handler and server application
|
||||
request_handler = DefaultRequestHandler(
|
||||
agent_executor=executor,
|
||||
task_store=InMemoryTaskStore(),
|
||||
)
|
||||
|
||||
app = A2AStarletteApplication(
|
||||
agent_card=my_agent_card,
|
||||
http_handler=request_handler,
|
||||
).build()
|
||||
```
|
||||
|
||||
## Import Path
|
||||
|
||||
```python
|
||||
from agent_framework.a2a import A2AAgent
|
||||
from agent_framework.a2a import A2AAgent, A2AExecutor
|
||||
# or directly:
|
||||
from agent_framework_a2a import A2AAgent
|
||||
from agent_framework_a2a import A2AAgent, A2AExecutor
|
||||
```
|
||||
|
||||
@@ -10,11 +10,49 @@ pip install agent-framework-a2a --pre
|
||||
|
||||
The A2A agent integration enables communication with remote A2A-compliant agents using the standardized A2A protocol. This allows your Agent Framework applications to connect to agents running on different platforms, languages, or services.
|
||||
|
||||
### A2AAgent (Client)
|
||||
|
||||
The `A2AAgent` class is a client that wraps an A2A Client to connect the Agent Framework with external A2A-compliant agents.
|
||||
|
||||
```python
|
||||
from agent_framework.a2a import A2AAgent
|
||||
|
||||
# Connect to a remote A2A agent
|
||||
a2a_agent = A2AAgent(url="http://remote-agent/a2a")
|
||||
response = await a2a_agent.run("Hello!")
|
||||
```
|
||||
|
||||
### A2AExecutor (Hosting)
|
||||
|
||||
The `A2AExecutor` class bridges local AI agents built with the `agent_framework` library to the A2A protocol, allowing them to be hosted and accessed by other A2A-compliant clients.
|
||||
|
||||
```python
|
||||
from agent_framework.a2a import A2AExecutor
|
||||
from a2a.server.apps import A2AStarletteApplication
|
||||
from a2a.server.request_handlers import DefaultRequestHandler
|
||||
from a2a.server.tasks import InMemoryTaskStore
|
||||
|
||||
# Create an A2A executor for your agent
|
||||
executor = A2AExecutor(agent=my_agent)
|
||||
|
||||
# Set up the request handler and server application
|
||||
request_handler = DefaultRequestHandler(
|
||||
agent_executor=executor,
|
||||
task_store=InMemoryTaskStore(),
|
||||
)
|
||||
|
||||
app = A2AStarletteApplication(
|
||||
agent_card=my_agent_card,
|
||||
http_handler=request_handler,
|
||||
).build()
|
||||
```
|
||||
|
||||
### Basic Usage Example
|
||||
|
||||
See the [A2A agent examples](../../samples/04-hosting/a2a/) which demonstrate:
|
||||
|
||||
- Connecting to remote A2A agents
|
||||
- Hosting local agents via A2A protocol
|
||||
- Sending messages and receiving responses
|
||||
- Handling different content types (text, files, data)
|
||||
- Streaming responses and real-time interaction
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import importlib.metadata
|
||||
|
||||
from ._a2a_executor import A2AExecutor
|
||||
from ._agent import A2AAgent, A2AContinuationToken
|
||||
|
||||
try:
|
||||
@@ -12,5 +13,6 @@ except importlib.metadata.PackageNotFoundError:
|
||||
__all__ = [
|
||||
"A2AAgent",
|
||||
"A2AContinuationToken",
|
||||
"A2AExecutor",
|
||||
"__version__",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import logging
|
||||
from asyncio import CancelledError
|
||||
from collections.abc import Mapping
|
||||
from functools import partial
|
||||
from typing import Any
|
||||
|
||||
from a2a.server.agent_execution import AgentExecutor, RequestContext
|
||||
from a2a.server.events import EventQueue
|
||||
from a2a.server.tasks import TaskUpdater
|
||||
from a2a.types import FilePart, FileWithBytes, FileWithUri, Part, TaskState, TextPart
|
||||
from a2a.utils import new_task
|
||||
from agent_framework import (
|
||||
AgentResponseUpdate,
|
||||
AgentSession,
|
||||
Message,
|
||||
SupportsAgentRun,
|
||||
)
|
||||
from typing_extensions import override
|
||||
|
||||
from agent_framework_a2a._utils import get_uri_data
|
||||
|
||||
logger = logging.getLogger("agent_framework.a2a")
|
||||
|
||||
|
||||
class A2AExecutor(AgentExecutor):
|
||||
"""Execute AI agents using the A2A (Agent-to-Agent) protocol.
|
||||
|
||||
The A2AExecutor bridges AI agents built with the agent_framework library and the A2A protocol,
|
||||
enabling structured agent execution with event-driven communication. It handles execution
|
||||
contexts, delegates history management to the agent's session, and converts agent
|
||||
responses into A2A protocol events.
|
||||
|
||||
The executor supports executing an Agent or WorkflowAgent. It provides comprehensive
|
||||
error handling with task status updates and supports various content types including text,
|
||||
binary data, and URI-based content.
|
||||
|
||||
Example:
|
||||
.. code-block:: python
|
||||
|
||||
from a2a.server.apps import A2AStarletteApplication
|
||||
from a2a.server.request_handlers import DefaultRequestHandler
|
||||
from a2a.server.tasks import InMemoryTaskStore
|
||||
from a2a.types import AgentCapabilities, AgentCard
|
||||
from agent_framework.a2a import A2AExecutor
|
||||
from agent_framework.openai import OpenAIResponsesClient
|
||||
|
||||
public_agent_card = AgentCard(
|
||||
name="Food Agent",
|
||||
description="A simple agent that provides food-related information.",
|
||||
url="http://localhost:9999/",
|
||||
version="1.0.0",
|
||||
defaultInputModes=["text"],
|
||||
defaultOutputModes=["text"],
|
||||
capabilities=AgentCapabilities(streaming=True),
|
||||
skills=[],
|
||||
)
|
||||
|
||||
# Create an agent
|
||||
agent = OpenAIResponsesClient().as_agent(
|
||||
name="Food Agent",
|
||||
instructions="A simple agent that provides food-related information.",
|
||||
)
|
||||
|
||||
# Set up the A2A server with the A2AExecutor enabled for streaming
|
||||
# and passing custom keyword arguments to the agent's run method.
|
||||
request_handler = DefaultRequestHandler(
|
||||
agent_executor=A2AExecutor(agent, stream=True, run_kwargs={"client_kwargs": {"max_tokens": 500}}),
|
||||
task_store=InMemoryTaskStore(),
|
||||
)
|
||||
|
||||
server = A2AStarletteApplication(
|
||||
agent_card=public_agent_card,
|
||||
http_handler=request_handler,
|
||||
).build()
|
||||
|
||||
Args:
|
||||
agent: The AI agent to execute.
|
||||
stream: Whether to stream the agent response. Defaults to False.
|
||||
run_kwargs: Additional keyword arguments to pass to the agent's run method.
|
||||
"""
|
||||
|
||||
def __init__(self, agent: SupportsAgentRun, stream: bool = False, run_kwargs: Mapping[str, Any] | None = None):
|
||||
"""Initialize the A2AExecutor with the specified agent.
|
||||
|
||||
Args:
|
||||
agent: The AI agent or workflow to execute.
|
||||
stream: Whether to stream the agent response. Defaults to False.
|
||||
run_kwargs: Additional keyword arguments to pass to the agent's run method.
|
||||
Cannot contain 'session' or 'stream' as these are managed by the executor.
|
||||
|
||||
Raises:
|
||||
ValueError: If run_kwargs contains 'session' or 'stream'.
|
||||
"""
|
||||
super().__init__()
|
||||
self._agent: SupportsAgentRun = agent
|
||||
self._stream: bool = stream
|
||||
if run_kwargs:
|
||||
if "session" in run_kwargs:
|
||||
raise ValueError("run_kwargs cannot contain 'session' as it is managed by the executor.")
|
||||
if "stream" in run_kwargs:
|
||||
raise ValueError("run_kwargs cannot contain 'stream' as it is managed by the executor.")
|
||||
self._run_kwargs: Mapping[str, Any] = run_kwargs or {}
|
||||
|
||||
@override
|
||||
async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None:
|
||||
"""Cancel agent execution for the given request context.
|
||||
|
||||
Uses a TaskUpdater to send a cancellation event through the provided event queue.
|
||||
|
||||
Args:
|
||||
context: The request context identifying the task to cancel.
|
||||
event_queue: The event queue to publish the cancellation event to.
|
||||
|
||||
Raises:
|
||||
ValueError: If context_id is not provided in the RequestContext.
|
||||
"""
|
||||
if context.context_id is None:
|
||||
raise ValueError("Context ID must be provided in the RequestContext")
|
||||
|
||||
updater = TaskUpdater(
|
||||
event_queue=event_queue,
|
||||
task_id=context.task_id or "",
|
||||
context_id=context.context_id,
|
||||
)
|
||||
|
||||
await updater.cancel()
|
||||
|
||||
@override
|
||||
async def execute(self, context: RequestContext, event_queue: EventQueue) -> None:
|
||||
"""Execute the agent with the given context and event queue.
|
||||
|
||||
Orchestrates the agent execution process: sets up the agent session,
|
||||
executes the agent, processes response messages, and handles errors with appropriate task status updates.
|
||||
"""
|
||||
if context.context_id is None:
|
||||
raise ValueError("Context ID must be provided in the RequestContext")
|
||||
if context.message is None:
|
||||
raise ValueError("Message must be provided in the RequestContext")
|
||||
|
||||
query = context.get_user_input()
|
||||
task = context.current_task
|
||||
|
||||
if not task:
|
||||
task = new_task(context.message)
|
||||
await event_queue.enqueue_event(task)
|
||||
|
||||
updater = TaskUpdater(event_queue, task.id, context.context_id)
|
||||
await updater.submit()
|
||||
|
||||
try:
|
||||
await updater.start_work()
|
||||
|
||||
session = self._agent.create_session(session_id=task.context_id)
|
||||
|
||||
if self._stream:
|
||||
await self._run_stream(query, session, updater)
|
||||
else:
|
||||
await self._run(query, session, updater)
|
||||
|
||||
# Mark as complete
|
||||
await updater.complete()
|
||||
except CancelledError:
|
||||
await updater.update_status(state=TaskState.canceled, final=True)
|
||||
except Exception as e:
|
||||
logger.exception("A2AExecutor encountered an error during execution.", exc_info=e)
|
||||
await updater.update_status(
|
||||
state=TaskState.failed,
|
||||
final=True,
|
||||
message=updater.new_agent_message([Part(root=TextPart(text=str(e)))]),
|
||||
)
|
||||
|
||||
async def _run_stream(self, query: Any, session: AgentSession, updater: TaskUpdater) -> None:
|
||||
"""Run the agent in streaming mode and publish updates to the task updater."""
|
||||
response_stream = self._agent.run(query, session=session, stream=True, **self._run_kwargs)
|
||||
streamed_artifact_ids: set[str] = set()
|
||||
await (
|
||||
response_stream.with_transform_hook(
|
||||
partial(self.handle_events, updater=updater, streamed_artifact_ids=streamed_artifact_ids)
|
||||
)
|
||||
).get_final_response()
|
||||
|
||||
async def _run(self, query: Any, session: AgentSession, updater: TaskUpdater) -> None:
|
||||
"""Run the agent in non-streaming mode and publish messages to the task updater."""
|
||||
response = await self._agent.run(query, session=session, stream=False, **self._run_kwargs)
|
||||
response_messages = response.messages
|
||||
|
||||
if not isinstance(response_messages, list):
|
||||
response_messages = [response_messages]
|
||||
|
||||
for message in response_messages:
|
||||
await self.handle_events(message, updater)
|
||||
|
||||
async def handle_events(
|
||||
self, item: Message | AgentResponseUpdate, updater: TaskUpdater, streamed_artifact_ids: set[str] | None = None
|
||||
) -> None:
|
||||
"""Convert agent response items (Messages or Updates) to A2A protocol events.
|
||||
|
||||
Processes Message or AgentResponseUpdate objects and converts them into A2A protocol format.
|
||||
Handles text, data, and URI content. USER role messages are skipped.
|
||||
|
||||
Users can override this method in a subclass to implement custom transformations
|
||||
from their agent's output format to A2A protocol events.
|
||||
|
||||
Args:
|
||||
item: The agent response item (Message or AgentResponseUpdate) to process.
|
||||
updater: The task updater to publish events to.
|
||||
streamed_artifact_ids: A set of artifact IDs that have already been streamed.
|
||||
Used to prevent duplicate updates for the same artifact.
|
||||
|
||||
Example:
|
||||
.. code-block:: python
|
||||
|
||||
class CustomA2AExecutor(A2AExecutor):
|
||||
async def handle_events(
|
||||
self,
|
||||
item: Message | AgentResponseUpdate,
|
||||
updater: TaskUpdater,
|
||||
streamed_artifact_ids: set[str] | None = None,
|
||||
) -> None:
|
||||
# Custom logic to transform item contents
|
||||
if item.role == "assistant" and item.contents:
|
||||
parts = [Part(root=TextPart(text=f"Custom: {item.contents[0].text}"))]
|
||||
await updater.update_status(
|
||||
state=TaskState.working,
|
||||
message=updater.new_agent_message(parts=parts),
|
||||
)
|
||||
else:
|
||||
await super().handle_events(item, updater)
|
||||
"""
|
||||
role = getattr(item, "role", None)
|
||||
if role == "user":
|
||||
# This is a user message, we can ignore it in the context of task updates
|
||||
return
|
||||
|
||||
parts: list[Part] = []
|
||||
metadata = getattr(item, "additional_properties", None)
|
||||
|
||||
# AgentResponseUpdate uses 'contents', Message uses 'contents'
|
||||
contents = getattr(item, "contents", [])
|
||||
|
||||
for content in contents:
|
||||
if content.type == "text" and content.text:
|
||||
parts.append(Part(root=TextPart(text=content.text)))
|
||||
elif content.type == "data" and content.uri:
|
||||
base64_str = get_uri_data(content.uri)
|
||||
parts.append(Part(root=FilePart(file=FileWithBytes(bytes=base64_str, mime_type=content.media_type))))
|
||||
elif content.type == "uri" and content.uri:
|
||||
parts.append(Part(root=FilePart(file=FileWithUri(uri=content.uri, mime_type=content.media_type))))
|
||||
else:
|
||||
# Silently skip unsupported content types
|
||||
logger.warning("A2AExecutor does not yet support content type: %s. Omitted.", content.type)
|
||||
|
||||
if parts:
|
||||
if isinstance(item, AgentResponseUpdate):
|
||||
# For streaming updates, we send TaskArtifactUpdateEvent via add_artifact
|
||||
await updater.add_artifact(
|
||||
parts=parts,
|
||||
artifact_id=item.message_id,
|
||||
metadata=metadata,
|
||||
append=(
|
||||
True
|
||||
if streamed_artifact_ids is not None and item.message_id in (streamed_artifact_ids or set())
|
||||
else None
|
||||
),
|
||||
)
|
||||
if item.message_id and streamed_artifact_ids is not None:
|
||||
streamed_artifact_ids.add(item.message_id)
|
||||
else:
|
||||
# For final messages, we send TaskStatusUpdateEvent with 'working' state
|
||||
await updater.update_status(
|
||||
state=TaskState.working,
|
||||
message=updater.new_agent_message(parts=parts, metadata=metadata),
|
||||
)
|
||||
@@ -4,7 +4,6 @@ from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
import uuid
|
||||
from collections.abc import AsyncIterable, Awaitable, Mapping, Sequence
|
||||
from typing import Any, Final, Literal, TypeAlias, overload
|
||||
@@ -49,7 +48,7 @@ from agent_framework.observability import AgentTelemetryLayer
|
||||
|
||||
__all__ = ["A2AAgent", "A2AContinuationToken"]
|
||||
|
||||
URI_PATTERN = re.compile(r"^data:(?P<media_type>[^;]+);base64,(?P<base64_data>[A-Za-z0-9+/=]+)$")
|
||||
from agent_framework_a2a._utils import get_uri_data
|
||||
|
||||
|
||||
class A2AContinuationToken(ContinuationToken):
|
||||
@@ -78,14 +77,6 @@ A2AClientEvent: TypeAlias = tuple[Task, TaskStatusUpdateEvent | TaskArtifactUpda
|
||||
A2AStreamItem: TypeAlias = A2AMessage | A2AClientEvent
|
||||
|
||||
|
||||
def _get_uri_data(uri: str) -> str:
|
||||
match = URI_PATTERN.match(uri)
|
||||
if not match:
|
||||
raise ValueError(f"Invalid data URI format: {uri}")
|
||||
|
||||
return match.group("base64_data")
|
||||
|
||||
|
||||
class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
"""Agent2Agent (A2A) protocol implementation.
|
||||
|
||||
@@ -652,7 +643,7 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
A2APart(
|
||||
root=FilePart(
|
||||
file=FileWithBytes(
|
||||
bytes=_get_uri_data(content.uri),
|
||||
bytes=get_uri_data(content.uri),
|
||||
mime_type=content.media_type,
|
||||
),
|
||||
metadata=content.additional_properties,
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import re
|
||||
|
||||
URI_PATTERN = re.compile(r"^data:(?P<media_type>[^;]+);base64,(?P<base64_data>[A-Za-z0-9+/=]+)$")
|
||||
|
||||
|
||||
def get_uri_data(uri: str) -> str:
|
||||
"""Extracts the base64-encoded data from a data URI.
|
||||
|
||||
Args:
|
||||
uri: The data URI to parse.
|
||||
|
||||
Returns:
|
||||
The base64-encoded data part of the URI.
|
||||
|
||||
Raises:
|
||||
ValueError: If the URI format is invalid.
|
||||
"""
|
||||
match = URI_PATTERN.match(uri)
|
||||
if not match:
|
||||
raise ValueError(f"Invalid data URI format: {uri}")
|
||||
|
||||
return match.group("base64_data")
|
||||
@@ -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.0b260423"
|
||||
version = "1.0.0b260429"
|
||||
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.1.1,<2",
|
||||
"agent-framework-core>=1.2.2,<2",
|
||||
"a2a-sdk>=0.3.5,<0.3.24",
|
||||
]
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ from agent_framework.a2a import A2AAgent
|
||||
from pytest import fixture, mark, raises
|
||||
|
||||
from agent_framework_a2a import A2AContinuationToken
|
||||
from agent_framework_a2a._agent import _get_uri_data # type: ignore
|
||||
from agent_framework_a2a._utils import get_uri_data
|
||||
|
||||
|
||||
class MockA2AClient:
|
||||
@@ -353,18 +353,18 @@ def test_parse_message_from_artifact(a2a_agent: A2AAgent) -> None:
|
||||
|
||||
|
||||
def test_get_uri_data_valid_uri() -> None:
|
||||
"""Test _get_uri_data with valid data URI."""
|
||||
"""Test get_uri_data with valid data URI."""
|
||||
|
||||
uri = "data:application/json;base64,eyJ0ZXN0IjoidmFsdWUifQ=="
|
||||
result = _get_uri_data(uri)
|
||||
result = get_uri_data(uri)
|
||||
assert result == "eyJ0ZXN0IjoidmFsdWUifQ=="
|
||||
|
||||
|
||||
def test_get_uri_data_invalid_uri() -> None:
|
||||
"""Test _get_uri_data with invalid URI format."""
|
||||
"""Test get_uri_data with invalid URI format."""
|
||||
|
||||
with raises(ValueError, match="Invalid data URI format"):
|
||||
_get_uri_data("not-a-valid-data-uri")
|
||||
get_uri_data("not-a-valid-data-uri")
|
||||
|
||||
|
||||
def test_parse_contents_from_a2a_conversion(a2a_agent: A2AAgent) -> None:
|
||||
|
||||
@@ -0,0 +1,910 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
from asyncio import CancelledError
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
from a2a.types import Task, TaskState, TextPart
|
||||
from agent_framework import (
|
||||
AgentResponseUpdate,
|
||||
Content,
|
||||
Message,
|
||||
SupportsAgentRun,
|
||||
)
|
||||
from agent_framework._types import AgentResponse
|
||||
from agent_framework.a2a import A2AExecutor
|
||||
from pytest import fixture, raises
|
||||
|
||||
|
||||
@fixture
|
||||
def mock_agent() -> MagicMock:
|
||||
"""Fixture that provides a mock SupportsAgentRun."""
|
||||
agent = MagicMock(spec=SupportsAgentRun)
|
||||
agent.run = AsyncMock()
|
||||
return agent
|
||||
|
||||
|
||||
@fixture
|
||||
def mock_request_context() -> MagicMock:
|
||||
"""Fixture that provides a mock RequestContext."""
|
||||
request_context = MagicMock()
|
||||
request_context.context_id = str(uuid4())
|
||||
request_context.get_user_input = MagicMock(return_value="Test query")
|
||||
request_context.current_task = None
|
||||
request_context.message = None
|
||||
return request_context
|
||||
|
||||
|
||||
@fixture
|
||||
def mock_event_queue() -> MagicMock:
|
||||
"""Fixture that provides a mock EventQueue."""
|
||||
queue = AsyncMock()
|
||||
queue.enqueue_event = AsyncMock()
|
||||
return queue
|
||||
|
||||
|
||||
@fixture
|
||||
def mock_task() -> Task:
|
||||
"""Fixture that provides a mock Task."""
|
||||
task = MagicMock(spec=Task)
|
||||
task.id = str(uuid4())
|
||||
task.context_id = str(uuid4())
|
||||
task.state = TaskState.completed
|
||||
return task
|
||||
|
||||
|
||||
@fixture
|
||||
def mock_task_updater() -> MagicMock:
|
||||
"""Fixture that provides a mock TaskUpdater."""
|
||||
updater = MagicMock()
|
||||
updater.submit = AsyncMock()
|
||||
updater.start_work = AsyncMock()
|
||||
updater.complete = AsyncMock()
|
||||
updater.update_status = AsyncMock()
|
||||
updater.new_agent_message = MagicMock()
|
||||
return updater
|
||||
|
||||
|
||||
@fixture
|
||||
def executor(mock_agent: MagicMock) -> A2AExecutor:
|
||||
"""Fixture that provides an A2AExecutor."""
|
||||
return A2AExecutor(agent=mock_agent)
|
||||
|
||||
|
||||
class TestA2AExecutorInitialization:
|
||||
"""Tests for A2AExecutor initialization."""
|
||||
|
||||
def test_initialization_with_agent_only(self, mock_agent: MagicMock) -> None:
|
||||
"""Arrange: Create mock agent
|
||||
Act: Initialize A2AExecutor with only agent
|
||||
Assert: Executor is created with default values
|
||||
"""
|
||||
# Act
|
||||
executor = A2AExecutor(agent=mock_agent)
|
||||
|
||||
# Assert
|
||||
assert executor._agent is mock_agent
|
||||
assert executor._stream is False
|
||||
assert executor._run_kwargs == {}
|
||||
|
||||
def test_initialization_with_stream_and_kwargs(self, mock_agent: MagicMock) -> None:
|
||||
"""Arrange: Create mock agent
|
||||
Act: Initialize A2AExecutor with stream and run_kwargs
|
||||
Assert: Executor is created with specified values
|
||||
"""
|
||||
# Arrange
|
||||
run_kwargs = {"temperature": 0.5}
|
||||
|
||||
# Act
|
||||
executor = A2AExecutor(agent=mock_agent, stream=True, run_kwargs=run_kwargs)
|
||||
|
||||
# Assert
|
||||
assert executor._agent is mock_agent
|
||||
assert executor._stream is True
|
||||
assert executor._run_kwargs == run_kwargs
|
||||
|
||||
def test_initialization_with_invalid_run_kwargs(self, mock_agent: MagicMock) -> None:
|
||||
"""Arrange: Create mock agent
|
||||
Act: Initialize A2AExecutor with reserved keys in run_kwargs
|
||||
Assert: ValueError is raised
|
||||
"""
|
||||
# Act & Assert
|
||||
with raises(ValueError, match="run_kwargs cannot contain 'session'"):
|
||||
A2AExecutor(agent=mock_agent, run_kwargs={"session": "something"})
|
||||
|
||||
with raises(ValueError, match="run_kwargs cannot contain 'stream'"):
|
||||
A2AExecutor(agent=mock_agent, run_kwargs={"stream": True})
|
||||
|
||||
|
||||
class TestA2AExecutorCancel:
|
||||
"""Tests for the cancel method."""
|
||||
|
||||
async def test_cancel_method_completes(
|
||||
self,
|
||||
executor: A2AExecutor,
|
||||
mock_request_context: MagicMock,
|
||||
mock_event_queue: MagicMock,
|
||||
) -> None:
|
||||
"""Arrange: Create executor with dependencies
|
||||
Act: Call cancel method
|
||||
Assert: Method completes without raising error
|
||||
"""
|
||||
# Arrange
|
||||
mock_request_context.task_id = "task-123"
|
||||
|
||||
# Act & Assert (should not raise)
|
||||
await executor.cancel(mock_request_context, mock_event_queue) # type: ignore
|
||||
|
||||
async def test_cancel_handles_different_contexts(
|
||||
self,
|
||||
executor: A2AExecutor,
|
||||
mock_event_queue: MagicMock,
|
||||
) -> None:
|
||||
"""Arrange: Create executor with multiple request contexts
|
||||
Act: Call cancel with different contexts
|
||||
Assert: Each cancel completes successfully
|
||||
"""
|
||||
# Arrange
|
||||
context1 = MagicMock()
|
||||
context1.context_id = "ctx-1"
|
||||
context1.task_id = "task-1"
|
||||
context2 = MagicMock()
|
||||
context2.context_id = "ctx-2"
|
||||
context2.task_id = "task-2"
|
||||
|
||||
# Act & Assert
|
||||
await executor.cancel(context1, mock_event_queue) # type: ignore
|
||||
await executor.cancel(context2, mock_event_queue) # type: ignore
|
||||
|
||||
async def test_cancel_raises_error_when_context_id_missing(
|
||||
self,
|
||||
executor: A2AExecutor,
|
||||
mock_event_queue: MagicMock,
|
||||
) -> None:
|
||||
"""Arrange: Create context without context_id
|
||||
Act: Call cancel method
|
||||
Assert: ValueError is raised
|
||||
"""
|
||||
# Arrange
|
||||
mock_context = MagicMock()
|
||||
mock_context.context_id = None
|
||||
|
||||
# Act & Assert
|
||||
with raises(ValueError) as excinfo:
|
||||
await executor.cancel(mock_context, mock_event_queue) # type: ignore
|
||||
|
||||
# Assert
|
||||
assert "Context ID" in str(excinfo.value)
|
||||
|
||||
|
||||
class TestA2AExecutorExecute:
|
||||
"""Tests for the execute method."""
|
||||
|
||||
async def test_execute_with_existing_task_succeeds(
|
||||
self,
|
||||
executor: A2AExecutor,
|
||||
mock_request_context: MagicMock,
|
||||
mock_event_queue: MagicMock,
|
||||
mock_task: Task,
|
||||
) -> None:
|
||||
"""Arrange: Create executor with mocked dependencies and existing task
|
||||
Act: Call execute method
|
||||
Assert: Execution completes successfully
|
||||
"""
|
||||
# Arrange
|
||||
mock_request_context.get_user_input = MagicMock(return_value="Hello")
|
||||
mock_request_context.current_task = mock_task
|
||||
mock_request_context.context_id = "ctx-123"
|
||||
mock_request_context.message = MagicMock()
|
||||
|
||||
response_message = Message(role="assistant", contents=[Content.from_text(text="Hello back")])
|
||||
response = MagicMock(spec=AgentResponse)
|
||||
response.messages = [response_message]
|
||||
executor._agent.run = AsyncMock(return_value=response)
|
||||
executor._agent.create_session = MagicMock()
|
||||
|
||||
with patch("agent_framework_a2a._a2a_executor.TaskUpdater") as mock_updater_class:
|
||||
mock_updater = MagicMock()
|
||||
mock_updater.submit = AsyncMock()
|
||||
mock_updater.start_work = AsyncMock()
|
||||
mock_updater.complete = AsyncMock()
|
||||
mock_updater.update_status = AsyncMock()
|
||||
mock_updater.new_agent_message = MagicMock(return_value="message_obj")
|
||||
mock_updater_class.return_value = mock_updater
|
||||
|
||||
# Act
|
||||
await executor.execute(mock_request_context, mock_event_queue)
|
||||
|
||||
# Assert
|
||||
mock_updater.submit.assert_called_once()
|
||||
mock_updater.start_work.assert_called_once()
|
||||
mock_updater.complete.assert_called_once()
|
||||
executor._agent.create_session.assert_called_once()
|
||||
executor._agent.run.assert_called_once()
|
||||
|
||||
async def test_execute_creates_task_when_not_exists(
|
||||
self,
|
||||
executor: A2AExecutor,
|
||||
mock_request_context: MagicMock,
|
||||
mock_event_queue: MagicMock,
|
||||
) -> None:
|
||||
"""Arrange: Create executor with request context without task
|
||||
Act: Call execute method
|
||||
Assert: New task is created and enqueued
|
||||
"""
|
||||
# Arrange
|
||||
mock_message = MagicMock()
|
||||
mock_request_context.get_user_input = MagicMock(return_value="Hello")
|
||||
mock_request_context.current_task = None
|
||||
mock_request_context.message = mock_message
|
||||
mock_request_context.context_id = "ctx-123"
|
||||
|
||||
response_message = Message(role="assistant", contents=[Content.from_text(text="Response")])
|
||||
response = MagicMock(spec=AgentResponse)
|
||||
response.messages = [response_message]
|
||||
executor._agent.run = AsyncMock(return_value=response)
|
||||
executor._agent.create_session = MagicMock()
|
||||
|
||||
with patch("agent_framework_a2a._a2a_executor.new_task") as mock_new_task:
|
||||
mock_task = MagicMock(spec=Task)
|
||||
mock_task.id = "task-new"
|
||||
mock_task.context_id = "ctx-123"
|
||||
mock_new_task.return_value = mock_task
|
||||
|
||||
with patch("agent_framework_a2a._a2a_executor.TaskUpdater") as mock_updater_class:
|
||||
mock_updater = MagicMock()
|
||||
mock_updater.submit = AsyncMock()
|
||||
mock_updater.start_work = AsyncMock()
|
||||
mock_updater.complete = AsyncMock()
|
||||
mock_updater.update_status = AsyncMock()
|
||||
mock_updater.new_agent_message = MagicMock(return_value="message_obj")
|
||||
mock_updater_class.return_value = mock_updater
|
||||
|
||||
# Act
|
||||
await executor.execute(mock_request_context, mock_event_queue)
|
||||
|
||||
# Assert
|
||||
mock_new_task.assert_called_once()
|
||||
mock_event_queue.enqueue_event.assert_called_once()
|
||||
|
||||
async def test_execute_raises_error_when_context_id_missing(
|
||||
self,
|
||||
executor: A2AExecutor,
|
||||
mock_request_context: MagicMock,
|
||||
mock_event_queue: MagicMock,
|
||||
) -> None:
|
||||
"""Arrange: Create context without context_id
|
||||
Act: Call execute method
|
||||
Assert: ValueError is raised
|
||||
"""
|
||||
# Arrange
|
||||
mock_request_context.context_id = None
|
||||
mock_request_context.message = MagicMock()
|
||||
|
||||
# Act & Assert
|
||||
with raises(ValueError) as excinfo:
|
||||
await executor.execute(mock_request_context, mock_event_queue)
|
||||
|
||||
# Assert
|
||||
assert "Context ID" in str(excinfo.value)
|
||||
|
||||
async def test_execute_raises_error_when_message_missing(
|
||||
self,
|
||||
executor: A2AExecutor,
|
||||
mock_request_context: MagicMock,
|
||||
mock_event_queue: MagicMock,
|
||||
) -> None:
|
||||
"""Arrange: Create context without message
|
||||
Act: Call execute method
|
||||
Assert: ValueError is raised
|
||||
"""
|
||||
# Arrange
|
||||
mock_request_context.context_id = "ctx-123"
|
||||
mock_request_context.message = None
|
||||
|
||||
# Act & Assert
|
||||
with raises(ValueError) as excinfo:
|
||||
await executor.execute(mock_request_context, mock_event_queue)
|
||||
|
||||
# Assert
|
||||
assert "Message" in str(excinfo.value)
|
||||
|
||||
async def test_execute_handles_cancelled_error(
|
||||
self,
|
||||
executor: A2AExecutor,
|
||||
mock_request_context: MagicMock,
|
||||
mock_event_queue: MagicMock,
|
||||
mock_task: Task,
|
||||
) -> None:
|
||||
"""Arrange: Create executor that raises CancelledError
|
||||
Act: Call execute method
|
||||
Assert: Error is caught and task is marked as canceled
|
||||
"""
|
||||
# Arrange
|
||||
mock_request_context.get_user_input = MagicMock(return_value="Hello")
|
||||
mock_request_context.current_task = mock_task
|
||||
mock_request_context.context_id = "ctx-123"
|
||||
mock_request_context.message = MagicMock()
|
||||
|
||||
executor._agent.run = AsyncMock(side_effect=CancelledError())
|
||||
executor._agent.create_session = MagicMock()
|
||||
|
||||
with patch("agent_framework_a2a._a2a_executor.TaskUpdater") as mock_updater_class:
|
||||
mock_updater = MagicMock()
|
||||
mock_updater.submit = AsyncMock()
|
||||
mock_updater.start_work = AsyncMock()
|
||||
mock_updater.update_status = AsyncMock()
|
||||
mock_updater_class.return_value = mock_updater
|
||||
|
||||
# Act
|
||||
await executor.execute(mock_request_context, mock_event_queue) # type: ignore
|
||||
|
||||
# Assert
|
||||
mock_updater.update_status.assert_called()
|
||||
call_args_list = mock_updater.update_status.call_args_list
|
||||
assert any(
|
||||
call[1].get("state") == TaskState.canceled and call[1].get("final") is True for call in call_args_list
|
||||
)
|
||||
|
||||
async def test_execute_handles_generic_exception(
|
||||
self,
|
||||
executor: A2AExecutor,
|
||||
mock_request_context: MagicMock,
|
||||
mock_event_queue: MagicMock,
|
||||
mock_task: Task,
|
||||
) -> None:
|
||||
"""Arrange: Create executor that raises generic exception
|
||||
Act: Call execute method
|
||||
Assert: Error is caught and task is marked as failed
|
||||
"""
|
||||
# Arrange
|
||||
mock_request_context.get_user_input = MagicMock(return_value="Hello")
|
||||
mock_request_context.current_task = mock_task
|
||||
mock_request_context.context_id = "ctx-123"
|
||||
mock_request_context.message = MagicMock()
|
||||
|
||||
error_message = "Test error"
|
||||
executor._agent.run = AsyncMock(side_effect=ValueError(error_message))
|
||||
executor._agent.create_session = MagicMock()
|
||||
|
||||
with patch("agent_framework_a2a._a2a_executor.TaskUpdater") as mock_updater_class:
|
||||
mock_updater = MagicMock()
|
||||
mock_updater.submit = AsyncMock()
|
||||
mock_updater.start_work = AsyncMock()
|
||||
mock_updater.update_status = AsyncMock()
|
||||
mock_updater.new_agent_message = MagicMock(return_value="error_message_obj")
|
||||
mock_updater_class.return_value = mock_updater
|
||||
|
||||
# Act
|
||||
await executor.execute(mock_request_context, mock_event_queue)
|
||||
|
||||
# Assert
|
||||
mock_updater.new_agent_message.assert_called_once()
|
||||
args, _ = mock_updater.new_agent_message.call_args
|
||||
parts = args[0]
|
||||
assert len(parts) == 1
|
||||
assert isinstance(parts[0].root, TextPart)
|
||||
assert parts[0].root.text == error_message
|
||||
|
||||
call_args_list = mock_updater.update_status.call_args_list
|
||||
assert any(
|
||||
call[1].get("state") == TaskState.failed
|
||||
and call[1].get("final") is True
|
||||
and call[1].get("message") == "error_message_obj"
|
||||
for call in call_args_list
|
||||
)
|
||||
|
||||
async def test_execute_processes_multiple_response_messages(
|
||||
self,
|
||||
executor: A2AExecutor,
|
||||
mock_request_context: MagicMock,
|
||||
mock_event_queue: MagicMock,
|
||||
mock_task: Task,
|
||||
) -> None:
|
||||
"""Arrange: Create executor that returns multiple response messages
|
||||
Act: Call execute method
|
||||
Assert: All messages are processed through handle_events
|
||||
"""
|
||||
# Arrange
|
||||
mock_request_context.get_user_input = MagicMock(return_value="Hello")
|
||||
mock_request_context.current_task = mock_task
|
||||
mock_request_context.context_id = "ctx-123"
|
||||
mock_request_context.message = MagicMock()
|
||||
|
||||
response_message1 = Message(role="assistant", contents=[Content.from_text(text="First")])
|
||||
response_message2 = Message(role="assistant", contents=[Content.from_text(text="Second")])
|
||||
response = MagicMock(spec=AgentResponse)
|
||||
response.messages = [response_message1, response_message2]
|
||||
executor._agent.run = AsyncMock(return_value=response)
|
||||
executor._agent.create_session = MagicMock()
|
||||
|
||||
# Mock handle_events
|
||||
executor.handle_events = AsyncMock()
|
||||
|
||||
with patch("agent_framework_a2a._a2a_executor.TaskUpdater") as mock_updater_class:
|
||||
mock_updater = MagicMock()
|
||||
mock_updater.submit = AsyncMock()
|
||||
mock_updater.start_work = AsyncMock()
|
||||
mock_updater.complete = AsyncMock()
|
||||
mock_updater.update_status = AsyncMock()
|
||||
mock_updater_class.return_value = mock_updater
|
||||
|
||||
# Act
|
||||
await executor.execute(mock_request_context, mock_event_queue)
|
||||
|
||||
# Assert
|
||||
assert executor.handle_events.call_count == 2
|
||||
|
||||
async def test_execute_passes_query_to_run(
|
||||
self,
|
||||
executor: A2AExecutor,
|
||||
mock_request_context: MagicMock,
|
||||
mock_event_queue: MagicMock,
|
||||
mock_task: Task,
|
||||
) -> None:
|
||||
"""Arrange: Create executor with request
|
||||
Act: Call execute method
|
||||
Assert: Query text is passed to run method with default stream and kwargs
|
||||
"""
|
||||
# Arrange
|
||||
query_text = "Hello agent"
|
||||
mock_request_context.get_user_input = MagicMock(return_value=query_text)
|
||||
mock_request_context.current_task = mock_task
|
||||
mock_request_context.context_id = "ctx-123"
|
||||
mock_request_context.message = MagicMock()
|
||||
|
||||
response_message = Message(role="assistant", contents=[Content.from_text(text="Response")])
|
||||
response = MagicMock(spec=AgentResponse)
|
||||
response.messages = [response_message]
|
||||
executor._agent.run = AsyncMock(return_value=response)
|
||||
executor._agent.create_session = MagicMock()
|
||||
|
||||
with patch("agent_framework_a2a._a2a_executor.TaskUpdater") as mock_updater_class:
|
||||
mock_updater = MagicMock()
|
||||
mock_updater.submit = AsyncMock()
|
||||
mock_updater.start_work = AsyncMock()
|
||||
mock_updater.complete = AsyncMock()
|
||||
mock_updater.update_status = AsyncMock()
|
||||
mock_updater.new_agent_message = MagicMock(return_value="message_obj")
|
||||
mock_updater_class.return_value = mock_updater
|
||||
|
||||
# Act
|
||||
await executor.execute(mock_request_context, mock_event_queue)
|
||||
|
||||
# Assert
|
||||
executor._agent.run.assert_called_once_with(
|
||||
query_text, session=executor._agent.create_session(), stream=False
|
||||
)
|
||||
|
||||
async def test_execute_with_stream_enabled(
|
||||
self,
|
||||
mock_agent: MagicMock,
|
||||
mock_request_context: MagicMock,
|
||||
mock_event_queue: MagicMock,
|
||||
mock_task: Task,
|
||||
) -> None:
|
||||
"""Arrange: Create executor with stream=True
|
||||
Act: Call execute method
|
||||
Assert: _run_stream is called and passes stream=True to run
|
||||
"""
|
||||
# Arrange
|
||||
executor = A2AExecutor(agent=mock_agent, stream=True)
|
||||
query_text = "Hello agent"
|
||||
mock_request_context.get_user_input = MagicMock(return_value=query_text)
|
||||
mock_request_context.current_task = mock_task
|
||||
mock_request_context.context_id = "ctx-123"
|
||||
mock_request_context.message = MagicMock()
|
||||
|
||||
mock_response_stream = MagicMock()
|
||||
mock_response_stream.with_transform_hook = MagicMock(return_value=mock_response_stream)
|
||||
mock_response_stream.get_final_response = AsyncMock()
|
||||
mock_agent.run = MagicMock(return_value=mock_response_stream)
|
||||
mock_agent.create_session = MagicMock()
|
||||
|
||||
with patch("agent_framework_a2a._a2a_executor.TaskUpdater") as mock_updater_class:
|
||||
mock_updater = MagicMock()
|
||||
mock_updater.submit = AsyncMock()
|
||||
mock_updater.start_work = AsyncMock()
|
||||
mock_updater.complete = AsyncMock()
|
||||
mock_updater.update_status = AsyncMock()
|
||||
mock_updater_class.return_value = mock_updater
|
||||
|
||||
# Act
|
||||
await executor.execute(mock_request_context, mock_event_queue)
|
||||
|
||||
# Assert
|
||||
mock_agent.run.assert_called_once_with(query_text, session=mock_agent.create_session(), stream=True)
|
||||
mock_response_stream.with_transform_hook.assert_called_once()
|
||||
mock_response_stream.get_final_response.assert_called_once()
|
||||
|
||||
async def test_execute_with_run_kwargs(
|
||||
self,
|
||||
mock_agent: MagicMock,
|
||||
mock_request_context: MagicMock,
|
||||
mock_event_queue: MagicMock,
|
||||
mock_task: Task,
|
||||
) -> None:
|
||||
"""Arrange: Create executor with run_kwargs
|
||||
Act: Call execute method
|
||||
Assert: run_kwargs are passed to run method
|
||||
"""
|
||||
# Arrange
|
||||
run_kwargs = {"temperature": 0.5, "max_tokens": 100}
|
||||
executor = A2AExecutor(agent=mock_agent, run_kwargs=run_kwargs)
|
||||
query_text = "Hello agent"
|
||||
mock_request_context.get_user_input = MagicMock(return_value=query_text)
|
||||
mock_request_context.current_task = mock_task
|
||||
mock_request_context.context_id = "ctx-123"
|
||||
mock_request_context.message = MagicMock()
|
||||
|
||||
response_message = Message(role="assistant", contents=[Content.from_text(text="Response")])
|
||||
response = MagicMock(spec=AgentResponse)
|
||||
response.messages = [response_message]
|
||||
mock_agent.run = AsyncMock(return_value=response)
|
||||
mock_agent.create_session = MagicMock()
|
||||
|
||||
with patch("agent_framework_a2a._a2a_executor.TaskUpdater") as mock_updater_class:
|
||||
mock_updater = MagicMock()
|
||||
mock_updater.submit = AsyncMock()
|
||||
mock_updater.start_work = AsyncMock()
|
||||
mock_updater.complete = AsyncMock()
|
||||
mock_updater.update_status = AsyncMock()
|
||||
mock_updater_class.return_value = mock_updater
|
||||
|
||||
# Act
|
||||
await executor.execute(mock_request_context, mock_event_queue)
|
||||
|
||||
# Assert
|
||||
mock_agent.run.assert_called_once_with(
|
||||
query_text, session=mock_agent.create_session(), stream=False, **run_kwargs
|
||||
)
|
||||
|
||||
|
||||
class TestA2AExecutorHandleEvents:
|
||||
"""Tests for A2AExecutor.handle_events method."""
|
||||
|
||||
async def test_run_method_with_single_message(self, executor: A2AExecutor, mock_updater: MagicMock) -> None:
|
||||
"""Test the private _run method with a single message (not a list)."""
|
||||
# Arrange
|
||||
query = "test query"
|
||||
session = MagicMock()
|
||||
response_message = Message(role="assistant", contents=[Content.from_text(text="Response")])
|
||||
response = MagicMock(spec=AgentResponse)
|
||||
response.messages = response_message # Not a list
|
||||
executor._agent.run = AsyncMock(return_value=response)
|
||||
executor.handle_events = AsyncMock()
|
||||
|
||||
# Act
|
||||
await executor._run(query, session, mock_updater)
|
||||
|
||||
# Assert
|
||||
executor.handle_events.assert_called_once_with(response_message, mock_updater)
|
||||
|
||||
@fixture
|
||||
def mock_updater(self) -> MagicMock:
|
||||
"""Create a mock execution context."""
|
||||
updater = MagicMock()
|
||||
updater.update_status = AsyncMock()
|
||||
updater.new_agent_message = MagicMock(return_value="mock_message")
|
||||
return updater
|
||||
|
||||
async def test_ignore_user_messages(self, executor: A2AExecutor, mock_updater: MagicMock) -> None:
|
||||
"""Test that messages from USER role are ignored."""
|
||||
# Arrange
|
||||
message = Message(
|
||||
contents=[Content.from_text(text="User input")],
|
||||
role="user",
|
||||
)
|
||||
|
||||
# Act
|
||||
await executor.handle_events(message, mock_updater)
|
||||
|
||||
# Assert
|
||||
mock_updater.update_status.assert_not_called()
|
||||
|
||||
async def test_ignore_messages_with_no_contents(self, executor: A2AExecutor, mock_updater: MagicMock) -> None:
|
||||
"""Test that messages with no contents are ignored."""
|
||||
# Arrange
|
||||
message = Message(
|
||||
contents=[],
|
||||
role="assistant",
|
||||
)
|
||||
|
||||
# Act
|
||||
await executor.handle_events(message, mock_updater)
|
||||
|
||||
# Assert
|
||||
mock_updater.update_status.assert_not_called()
|
||||
|
||||
async def test_handle_text_content(self, executor: A2AExecutor, mock_updater: MagicMock) -> None:
|
||||
"""Test handling messages with text content."""
|
||||
# Arrange
|
||||
text = "Hello, this is a test message"
|
||||
message = Message(
|
||||
contents=[Content.from_text(text=text)],
|
||||
role="assistant",
|
||||
)
|
||||
|
||||
# Act
|
||||
await executor.handle_events(message, mock_updater)
|
||||
|
||||
# Assert
|
||||
mock_updater.update_status.assert_called_once()
|
||||
call_args = mock_updater.update_status.call_args
|
||||
assert call_args.kwargs["state"] == TaskState.working
|
||||
assert mock_updater.new_agent_message.called
|
||||
|
||||
async def test_handle_multiple_text_contents(self, executor: A2AExecutor, mock_updater: MagicMock) -> None:
|
||||
"""Test handling messages with multiple text contents."""
|
||||
# Arrange
|
||||
message = Message(
|
||||
contents=[
|
||||
Content.from_text(text="First message"),
|
||||
Content.from_text(text="Second message"),
|
||||
],
|
||||
role="assistant",
|
||||
)
|
||||
|
||||
# Act
|
||||
await executor.handle_events(message, mock_updater)
|
||||
|
||||
# Assert
|
||||
mock_updater.update_status.assert_called_once()
|
||||
assert mock_updater.new_agent_message.called
|
||||
|
||||
async def test_handle_data_content(self, executor: A2AExecutor, mock_updater: MagicMock) -> None:
|
||||
"""Test handling messages with data content."""
|
||||
# Arrange
|
||||
data = b"test file data"
|
||||
message = Message(
|
||||
contents=[Content.from_data(data=data, media_type="application/octet-stream")],
|
||||
role="assistant",
|
||||
)
|
||||
|
||||
# Act
|
||||
await executor.handle_events(message, mock_updater)
|
||||
|
||||
# Assert
|
||||
mock_updater.update_status.assert_called_once()
|
||||
call_args = mock_updater.update_status.call_args
|
||||
assert call_args.kwargs["state"] == TaskState.working
|
||||
|
||||
async def test_handle_uri_content(self, executor: A2AExecutor, mock_updater: MagicMock) -> None:
|
||||
"""Test handling messages with URI content."""
|
||||
# Arrange
|
||||
uri = "https://example.com/file.pdf"
|
||||
message = Message(
|
||||
contents=[Content.from_uri(uri=uri, media_type="application/pdf")],
|
||||
role="assistant",
|
||||
)
|
||||
|
||||
# Act
|
||||
await executor.handle_events(message, mock_updater)
|
||||
|
||||
# Assert
|
||||
mock_updater.update_status.assert_called_once()
|
||||
call_args = mock_updater.update_status.call_args
|
||||
assert call_args.kwargs["state"] == TaskState.working
|
||||
|
||||
async def test_handle_mixed_content_types(self, executor: A2AExecutor, mock_updater: MagicMock) -> None:
|
||||
"""Test handling messages with mixed content types."""
|
||||
# Arrange
|
||||
data = b"file data"
|
||||
|
||||
message = Message(
|
||||
contents=[
|
||||
Content.from_text(text="Processing file..."),
|
||||
Content.from_data(data=data, media_type="application/octet-stream"),
|
||||
Content.from_uri(uri="https://example.com/reference.pdf", media_type="application/pdf"),
|
||||
],
|
||||
role="assistant",
|
||||
)
|
||||
|
||||
# Act
|
||||
await executor.handle_events(message, mock_updater)
|
||||
|
||||
# Assert
|
||||
mock_updater.update_status.assert_called_once()
|
||||
call_args = mock_updater.update_status.call_args
|
||||
assert call_args.kwargs["state"] == TaskState.working
|
||||
|
||||
async def test_handle_with_additional_properties(self, executor: A2AExecutor, mock_updater: MagicMock) -> None:
|
||||
"""Test handling messages with additional properties metadata."""
|
||||
# Arrange
|
||||
additional_props = {"custom_field": "custom_value", "priority": "high"}
|
||||
message = Message(
|
||||
contents=[Content.from_text(text="Test message")],
|
||||
role="assistant",
|
||||
additional_properties=additional_props,
|
||||
)
|
||||
|
||||
# Act
|
||||
await executor.handle_events(message, mock_updater)
|
||||
|
||||
# Assert
|
||||
mock_updater.update_status.assert_called_once()
|
||||
mock_updater.new_agent_message.assert_called_once()
|
||||
call_args = mock_updater.new_agent_message.call_args
|
||||
assert call_args.kwargs["metadata"] == additional_props
|
||||
|
||||
async def test_handle_with_no_additional_properties(self, executor: A2AExecutor, mock_updater: MagicMock) -> None:
|
||||
"""Test handling messages without additional properties."""
|
||||
# Arrange
|
||||
message = Message(
|
||||
contents=[Content.from_text(text="Test message")],
|
||||
role="assistant",
|
||||
additional_properties=None,
|
||||
)
|
||||
|
||||
# Act
|
||||
await executor.handle_events(message, mock_updater)
|
||||
|
||||
# Assert
|
||||
mock_updater.update_status.assert_called_once()
|
||||
mock_updater.new_agent_message.assert_called_once()
|
||||
call_args = mock_updater.new_agent_message.call_args
|
||||
assert call_args.kwargs["metadata"] == {}
|
||||
|
||||
async def test_parts_list_passed_to_new_agent_message(self, executor: A2AExecutor, mock_updater: MagicMock) -> None:
|
||||
"""Test that parts list is correctly passed to new_agent_message."""
|
||||
# Arrange
|
||||
message = Message(
|
||||
contents=[
|
||||
Content.from_text(text="Message 1"),
|
||||
Content.from_text(text="Message 2"),
|
||||
],
|
||||
role="assistant",
|
||||
)
|
||||
|
||||
# Act
|
||||
await executor.handle_events(message, mock_updater)
|
||||
|
||||
# Assert
|
||||
mock_updater.new_agent_message.assert_called_once()
|
||||
call_kwargs = mock_updater.new_agent_message.call_args.kwargs
|
||||
assert "parts" in call_kwargs
|
||||
parts_list = call_kwargs["parts"]
|
||||
assert len(parts_list) == 2
|
||||
|
||||
async def test_task_state_always_working(self, executor: A2AExecutor, mock_updater: MagicMock) -> None:
|
||||
"""Test that task state is always set to working."""
|
||||
# Arrange
|
||||
message = Message(
|
||||
contents=[Content.from_text(text="Any message")],
|
||||
role="assistant",
|
||||
)
|
||||
|
||||
# Act
|
||||
await executor.handle_events(message, mock_updater)
|
||||
|
||||
# Assert
|
||||
call_kwargs = mock_updater.update_status.call_args.kwargs
|
||||
assert call_kwargs["state"] == TaskState.working
|
||||
|
||||
async def test_handle_agent_response_update_no_streamed_set(
|
||||
self, executor: A2AExecutor, mock_updater: MagicMock
|
||||
) -> None:
|
||||
"""Test handling AgentResponseUpdate (streaming) without a tracking set."""
|
||||
# Arrange
|
||||
update = AgentResponseUpdate(
|
||||
contents=[Content.from_text(text="Streaming chunk")],
|
||||
role="assistant",
|
||||
message_id="msg-1",
|
||||
)
|
||||
mock_updater.add_artifact = AsyncMock()
|
||||
|
||||
# Act
|
||||
await executor.handle_events(update, mock_updater)
|
||||
|
||||
# Assert
|
||||
mock_updater.add_artifact.assert_called_once()
|
||||
call_kwargs = mock_updater.add_artifact.call_args.kwargs
|
||||
assert call_kwargs["artifact_id"] == "msg-1"
|
||||
assert call_kwargs["append"] is None
|
||||
|
||||
async def test_handle_agent_response_update_first_time(
|
||||
self, executor: A2AExecutor, mock_updater: MagicMock
|
||||
) -> None:
|
||||
"""Test handling AgentResponseUpdate (streaming) for the first time with a tracking set."""
|
||||
# Arrange
|
||||
update = AgentResponseUpdate(
|
||||
contents=[Content.from_text(text="Streaming chunk")],
|
||||
role="assistant",
|
||||
message_id="msg-1",
|
||||
)
|
||||
mock_updater.add_artifact = AsyncMock()
|
||||
streamed_artifact_ids = set()
|
||||
|
||||
# Act
|
||||
await executor.handle_events(update, mock_updater, streamed_artifact_ids=streamed_artifact_ids)
|
||||
|
||||
# Assert
|
||||
mock_updater.add_artifact.assert_called_once()
|
||||
call_kwargs = mock_updater.add_artifact.call_args.kwargs
|
||||
assert call_kwargs["append"] is None
|
||||
assert "msg-1" in streamed_artifact_ids
|
||||
|
||||
async def test_handle_agent_response_update_subsequent_time(
|
||||
self, executor: A2AExecutor, mock_updater: MagicMock
|
||||
) -> None:
|
||||
"""Test handling AgentResponseUpdate (streaming) for subsequent times with a tracking set."""
|
||||
# Arrange
|
||||
update = AgentResponseUpdate(
|
||||
contents=[Content.from_text(text="Next chunk")],
|
||||
role="assistant",
|
||||
message_id="msg-1",
|
||||
)
|
||||
mock_updater.add_artifact = AsyncMock()
|
||||
streamed_artifact_ids = {"msg-1"}
|
||||
|
||||
# Act
|
||||
await executor.handle_events(update, mock_updater, streamed_artifact_ids=streamed_artifact_ids)
|
||||
|
||||
# Assert
|
||||
mock_updater.add_artifact.assert_called_once()
|
||||
call_kwargs = mock_updater.add_artifact.call_args.kwargs
|
||||
assert call_kwargs["append"] is True
|
||||
|
||||
async def test_handle_unsupported_content_type(self, executor: A2AExecutor, mock_updater: MagicMock) -> None:
|
||||
"""Test handling messages with unsupported content types."""
|
||||
# Arrange
|
||||
message = Message(
|
||||
contents=[Content(type="unknown", text="Some text")],
|
||||
role="assistant",
|
||||
)
|
||||
|
||||
# Act
|
||||
with patch("agent_framework_a2a._a2a_executor.logger") as mock_logger:
|
||||
await executor.handle_events(message, mock_updater)
|
||||
|
||||
# Assert
|
||||
mock_logger.warning.assert_called_once()
|
||||
mock_updater.update_status.assert_not_called()
|
||||
|
||||
|
||||
class TestA2AExecutorIntegration:
|
||||
"""Integration tests for A2AExecutor."""
|
||||
|
||||
async def test_full_execution_flow_with_responses(
|
||||
self,
|
||||
executor: A2AExecutor,
|
||||
mock_request_context: MagicMock,
|
||||
mock_event_queue: MagicMock,
|
||||
mock_task: Task,
|
||||
) -> None:
|
||||
"""Arrange: Create executor with all mocked dependencies
|
||||
Act: Execute full flow from request to completion
|
||||
Assert: All components interact correctly
|
||||
"""
|
||||
# Arrange
|
||||
mock_request_context.get_user_input = MagicMock(return_value="Hello agent")
|
||||
mock_request_context.current_task = mock_task
|
||||
mock_request_context.context_id = "ctx-123"
|
||||
mock_request_context.message = MagicMock()
|
||||
|
||||
response = MagicMock(spec=AgentResponse)
|
||||
response_message = MagicMock(spec=Message)
|
||||
response.messages = [response_message]
|
||||
response_message.contents = [Content.from_text(text="Hello user")]
|
||||
response_message.role = "assistant"
|
||||
response_message.additional_properties = None
|
||||
|
||||
executor._agent.run = AsyncMock(return_value=response)
|
||||
executor._agent.create_session = MagicMock()
|
||||
executor.handle_events = AsyncMock()
|
||||
|
||||
with patch("agent_framework_a2a._a2a_executor.TaskUpdater") as mock_updater_class:
|
||||
mock_updater = MagicMock()
|
||||
mock_updater.submit = AsyncMock()
|
||||
mock_updater.start_work = AsyncMock()
|
||||
mock_updater.complete = AsyncMock()
|
||||
mock_updater.update_status = AsyncMock()
|
||||
mock_updater_class.return_value = mock_updater
|
||||
|
||||
# Act
|
||||
await executor.execute(mock_request_context, mock_event_queue)
|
||||
|
||||
# Assert
|
||||
mock_updater.submit.assert_called_once()
|
||||
mock_updater.start_work.assert_called_once()
|
||||
executor.handle_events.assert_called_once()
|
||||
mock_updater.complete.assert_called_once()
|
||||
@@ -0,0 +1,41 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_framework_a2a._utils import get_uri_data
|
||||
|
||||
|
||||
def test_get_uri_data_valid() -> None:
|
||||
"""Test get_uri_data with valid data URIs."""
|
||||
# Simple text/plain
|
||||
uri = "data:text/plain;base64,SGVsbG8sIFdvcmxkIQ=="
|
||||
assert get_uri_data(uri) == "SGVsbG8sIFdvcmxkIQ=="
|
||||
|
||||
# Image png
|
||||
uri = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=="
|
||||
assert get_uri_data(uri) == "iVBORw0KGgoAAAANSUhEUgfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=="
|
||||
|
||||
# Application octet-stream
|
||||
uri = "data:application/octet-stream;base64,AQIDBA=="
|
||||
assert get_uri_data(uri) == "AQIDBA=="
|
||||
|
||||
|
||||
def test_get_uri_data_invalid_format() -> None:
|
||||
"""Test get_uri_data with invalid URI formats."""
|
||||
invalid_uris = [
|
||||
"not-a-uri",
|
||||
"http://example.com",
|
||||
"data:text/plain;SGVsbG8sIFdvcmxkIQ==", # Missing base64 marker
|
||||
"data:base64,SGVsbG8sIFdvcmxkIQ==", # Missing media type
|
||||
"data:text/plain;charset=utf-8;base64,SGVsbG8sIFdvcmxkIQ==", # Extra parameters (current regex doesn't support)
|
||||
"data:text/plain;base64,SGVsbG8sIFdvcmxkIQ== extra",
|
||||
]
|
||||
for uri in invalid_uris:
|
||||
with pytest.raises(ValueError, match="Invalid data URI format"):
|
||||
get_uri_data(uri)
|
||||
|
||||
|
||||
def test_get_uri_data_empty() -> None:
|
||||
"""Test get_uri_data with empty string."""
|
||||
with pytest.raises(ValueError, match="Invalid data URI format"):
|
||||
get_uri_data("")
|
||||
@@ -263,27 +263,21 @@ def _deduplicate_messages(messages: list[Message]) -> list[Message]:
|
||||
return unique_messages
|
||||
|
||||
|
||||
def _parse_multimodal_media_part(part: dict[str, Any]) -> Content | None:
|
||||
"""Convert a multimodal media part into Agent Framework content."""
|
||||
part_type = str(part.get("type", "")).lower()
|
||||
source = part.get("source")
|
||||
def _extract_multimodal_source_fields(
|
||||
part: dict[str, Any],
|
||||
) -> tuple[str | None, str | None, str | None, str | None]:
|
||||
"""Extract ``(url, data, binary_id, mime_type)`` from an AG-UI multimodal part.
|
||||
|
||||
mime_type = cast(
|
||||
str | None,
|
||||
part.get("mimeType")
|
||||
or part.get("mime_type")
|
||||
or {
|
||||
"image": "image/*",
|
||||
"audio": "audio/*",
|
||||
"video": "video/*",
|
||||
"document": "application/octet-stream",
|
||||
"binary": "application/octet-stream",
|
||||
}.get(part_type, "application/octet-stream"),
|
||||
)
|
||||
Handles both the current AG-UI spec (``source.value`` for base64 payloads) and the
|
||||
legacy ``source.data`` field for backward compatibility. Returned values are the
|
||||
raw extracted strings (or ``None`` when absent); callers apply their own defaults.
|
||||
"""
|
||||
mime_type = cast(str | None, part.get("mimeType") or part.get("mime_type"))
|
||||
url = cast(str | None, part.get("url") or part.get("uri"))
|
||||
data = cast(str | None, part.get("data"))
|
||||
binary_id = cast(str | None, part.get("id"))
|
||||
|
||||
source = part.get("source")
|
||||
if isinstance(source, dict):
|
||||
source_dict = cast(dict[str, Any], source)
|
||||
source_type = str(source_dict.get("type", "")).lower()
|
||||
@@ -294,14 +288,31 @@ def _parse_multimodal_media_part(part: dict[str, Any]) -> Content | None:
|
||||
if source_type in {"url", "uri"}:
|
||||
url = cast(str | None, source_dict.get("url") or source_dict.get("uri"))
|
||||
elif source_type in {"base64", "data", "binary"}:
|
||||
data = cast(str | None, source_dict.get("data"))
|
||||
data = cast(str | None, source_dict.get("value") or source_dict.get("data"))
|
||||
elif source_type in {"id", "file"}:
|
||||
binary_id = cast(str | None, source_dict.get("id"))
|
||||
else:
|
||||
url = cast(str | None, source_dict.get("url") or source_dict.get("uri") or url)
|
||||
data = cast(str | None, source_dict.get("data") or data)
|
||||
data = cast(str | None, source_dict.get("value") or source_dict.get("data") or data)
|
||||
binary_id = cast(str | None, source_dict.get("id") or binary_id)
|
||||
|
||||
return url, data, binary_id, mime_type
|
||||
|
||||
|
||||
def _parse_multimodal_media_part(part: dict[str, Any]) -> Content | None:
|
||||
"""Convert a multimodal media part into Agent Framework content."""
|
||||
part_type = str(part.get("type", "")).lower()
|
||||
url, data, binary_id, mime_type = _extract_multimodal_source_fields(part)
|
||||
|
||||
if not mime_type:
|
||||
mime_type = {
|
||||
"image": "image/*",
|
||||
"audio": "audio/*",
|
||||
"video": "video/*",
|
||||
"document": "application/octet-stream",
|
||||
"binary": "application/octet-stream",
|
||||
}.get(part_type, "application/octet-stream")
|
||||
|
||||
if isinstance(url, str) and url:
|
||||
return Content.from_uri(uri=url, media_type=mime_type)
|
||||
|
||||
@@ -389,30 +400,7 @@ def _normalize_snapshot_content(content: Any) -> Any:
|
||||
def _legacy_binary_part(part: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Convert draft/legacy multimodal parts to AG-UI snapshot binary shape."""
|
||||
normalized: dict[str, Any] = {"type": "binary"}
|
||||
|
||||
mime_type = cast(str | None, part.get("mimeType") or part.get("mime_type"))
|
||||
url = cast(str | None, part.get("url") or part.get("uri"))
|
||||
data = cast(str | None, part.get("data"))
|
||||
binary_id = cast(str | None, part.get("id"))
|
||||
|
||||
source = part.get("source")
|
||||
if isinstance(source, dict):
|
||||
source_part = cast(dict[str, Any], source)
|
||||
source_mime = source_part.get("mimeType") or source_part.get("mime_type")
|
||||
if isinstance(source_mime, str) and source_mime:
|
||||
mime_type = source_mime
|
||||
|
||||
source_type = str(source_part.get("type", "")).lower()
|
||||
if source_type in {"url", "uri"}:
|
||||
url = cast(str | None, source_part.get("url") or source_part.get("uri"))
|
||||
elif source_type in {"base64", "data", "binary"}:
|
||||
data = cast(str | None, source_part.get("data"))
|
||||
elif source_type in {"id", "file"}:
|
||||
binary_id = cast(str | None, source_part.get("id"))
|
||||
else:
|
||||
url = cast(str | None, source_part.get("url") or source_part.get("uri") or url)
|
||||
data = cast(str | None, source_part.get("data") or data)
|
||||
binary_id = cast(str | None, source_part.get("id") or binary_id)
|
||||
url, data, binary_id, mime_type = _extract_multimodal_source_fields(part)
|
||||
|
||||
if isinstance(mime_type, str) and mime_type:
|
||||
normalized["mimeType"] = mime_type
|
||||
|
||||
@@ -596,7 +596,7 @@ def _emit_text_reasoning(content: Content, flow: FlowState | None = None) -> lis
|
||||
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"))
|
||||
events.append(ReasoningMessageStartEvent(message_id=message_id, role="reasoning"))
|
||||
flow.reasoning_message_id = message_id
|
||||
|
||||
if text:
|
||||
@@ -613,7 +613,7 @@ def _emit_text_reasoning(content: Content, flow: FlowState | None = None) -> lis
|
||||
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(ReasoningMessageStartEvent(message_id=message_id, role="reasoning"))
|
||||
|
||||
if text:
|
||||
events.append(ReasoningMessageContentEvent(message_id=message_id, delta=text))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "agent-framework-ag-ui"
|
||||
version = "1.0.0b260423"
|
||||
version = "1.0.0b260429"
|
||||
description = "AG-UI protocol integration for Agent Framework"
|
||||
readme = "README.md"
|
||||
license-files = ["LICENSE"]
|
||||
@@ -22,15 +22,15 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.1.1,<2",
|
||||
"ag-ui-protocol==0.1.13",
|
||||
"agent-framework-core>=1.2.2,<2",
|
||||
"ag-ui-protocol>=0.1.16,<0.2",
|
||||
"fastapi>=0.115.0,<0.133.1",
|
||||
"uvicorn[standard]>=0.30.0,<0.42.0"
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest==9.0.2",
|
||||
"pytest==9.0.3",
|
||||
"httpx==0.28.1",
|
||||
]
|
||||
|
||||
|
||||
@@ -536,6 +536,77 @@ def test_agui_snapshot_format_preserves_multimodal_content():
|
||||
assert content_parts[1]["url"] == "https://example.com/image.png"
|
||||
|
||||
|
||||
def test_agui_snapshot_format_reads_base64_value_field():
|
||||
"""Snapshot normalization reads the spec 'value' field for base64 sources."""
|
||||
payload = base64.b64encode(b"abc").decode("utf-8")
|
||||
normalized = agui_messages_to_snapshot_format(
|
||||
[
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "image",
|
||||
"source": {"type": "base64", "value": payload, "mimeType": "image/png"},
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
binary_part = normalized[0]["content"][0]
|
||||
assert binary_part["type"] == "binary"
|
||||
assert binary_part["mimeType"] == "image/png"
|
||||
assert binary_part["data"] == payload
|
||||
|
||||
|
||||
def test_agui_snapshot_format_base64_value_preferred_over_data():
|
||||
"""Snapshot normalization prefers 'value' when both 'value' and 'data' are set."""
|
||||
value_payload = base64.b64encode(b"new-spec").decode("utf-8")
|
||||
data_payload = base64.b64encode(b"legacy").decode("utf-8")
|
||||
normalized = agui_messages_to_snapshot_format(
|
||||
[
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"value": value_payload,
|
||||
"data": data_payload,
|
||||
"mimeType": "image/png",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
binary_part = normalized[0]["content"][0]
|
||||
assert binary_part["data"] == value_payload
|
||||
|
||||
|
||||
def test_agui_snapshot_format_base64_data_field_backward_compat():
|
||||
"""Snapshot normalization still reads the legacy 'data' field when 'value' is absent."""
|
||||
payload = base64.b64encode(b"legacy").decode("utf-8")
|
||||
normalized = agui_messages_to_snapshot_format(
|
||||
[
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "image",
|
||||
"source": {"type": "base64", "data": payload, "mimeType": "image/png"},
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
binary_part = normalized[0]["content"][0]
|
||||
assert binary_part["data"] == payload
|
||||
|
||||
|
||||
def test_agui_with_tool_calls_to_agent_framework():
|
||||
"""Assistant message with tool_calls is converted to FunctionCallContent."""
|
||||
agui_msg = {
|
||||
@@ -1760,3 +1831,67 @@ class TestReasoningRoundTrip:
|
||||
assert "First answer" in texts
|
||||
assert "Follow-up question" in texts
|
||||
assert "Prior reasoning" not in texts
|
||||
|
||||
|
||||
def test_parse_multimodal_media_part_base64_value_field():
|
||||
"""Source with type='base64' reads data from the 'value' field per AG-UI spec."""
|
||||
from agent_framework_ag_ui._message_adapters import _parse_multimodal_media_part
|
||||
|
||||
result = _parse_multimodal_media_part(
|
||||
{"type": "image", "source": {"type": "base64", "value": "aGVsbG8=", "mimeType": "image/png"}}
|
||||
)
|
||||
assert result is not None
|
||||
assert "aGVsbG8=" in result.uri
|
||||
|
||||
|
||||
def test_parse_multimodal_media_part_data_source_value_field():
|
||||
"""Source with type='data' reads data from the 'value' field per AG-UI spec."""
|
||||
from agent_framework_ag_ui._message_adapters import _parse_multimodal_media_part
|
||||
|
||||
result = _parse_multimodal_media_part(
|
||||
{"type": "image", "source": {"type": "data", "value": "aGVsbG8=", "mimeType": "image/png"}}
|
||||
)
|
||||
assert result is not None
|
||||
assert "aGVsbG8=" in result.uri
|
||||
|
||||
|
||||
def test_parse_multimodal_media_part_base64_data_field_backward_compat():
|
||||
"""Source with type='base64' still supports deprecated 'data' field."""
|
||||
from agent_framework_ag_ui._message_adapters import _parse_multimodal_media_part
|
||||
|
||||
result = _parse_multimodal_media_part(
|
||||
{"type": "image", "source": {"type": "base64", "data": "aGVsbG8=", "mimeType": "image/png"}}
|
||||
)
|
||||
assert result is not None
|
||||
assert "aGVsbG8=" in result.uri
|
||||
|
||||
|
||||
def test_parse_multimodal_media_part_value_preferred_over_data():
|
||||
"""When both 'value' and 'data' are present, 'value' takes precedence."""
|
||||
from agent_framework_ag_ui._message_adapters import _parse_multimodal_media_part
|
||||
|
||||
result = _parse_multimodal_media_part(
|
||||
{
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"value": "dmFsdWU=",
|
||||
"data": "ZGF0YQ==",
|
||||
"mimeType": "image/png",
|
||||
},
|
||||
}
|
||||
)
|
||||
assert result is not None
|
||||
# 'value' field content should be used (base64 of "value")
|
||||
assert "dmFsdWU=" in result.uri
|
||||
|
||||
|
||||
def test_parse_multimodal_media_part_unknown_source_value_fallback():
|
||||
"""Unknown source type falls back to 'value' field before 'data' field."""
|
||||
from agent_framework_ag_ui._message_adapters import _parse_multimodal_media_part
|
||||
|
||||
result = _parse_multimodal_media_part(
|
||||
{"type": "image", "source": {"type": "custom", "value": "aGVsbG8=", "mimeType": "image/png"}}
|
||||
)
|
||||
assert result is not None
|
||||
assert "aGVsbG8=" in result.uri
|
||||
|
||||
@@ -1244,7 +1244,7 @@ class TestEmitTextReasoning:
|
||||
assert events[0].message_id == "reason_1"
|
||||
assert isinstance(events[1], ReasoningMessageStartEvent)
|
||||
assert events[1].message_id == "reason_1"
|
||||
assert events[1].role == "assistant"
|
||||
assert events[1].role == "reasoning"
|
||||
assert isinstance(events[2], ReasoningMessageContentEvent)
|
||||
assert events[2].message_id == "reason_1"
|
||||
assert events[2].delta == "The user is asking about weather, so I should call the weather tool."
|
||||
@@ -1642,6 +1642,37 @@ class TestReasoningInSnapshot:
|
||||
assert close[0].message_id == "block2"
|
||||
|
||||
|
||||
class TestReasoningEventRole:
|
||||
"""Tests that reasoning events use role='reasoning' per AG-UI spec."""
|
||||
|
||||
def test_reasoning_role_without_flow(self):
|
||||
"""ReasoningMessageStartEvent uses role='reasoning' in non-flow mode."""
|
||||
content = Content.from_text_reasoning(
|
||||
id="reason_role_1",
|
||||
text="Thinking about the question.",
|
||||
)
|
||||
|
||||
events = _emit_text_reasoning(content)
|
||||
|
||||
msg_starts = [e for e in events if isinstance(e, ReasoningMessageStartEvent)]
|
||||
assert len(msg_starts) == 1
|
||||
assert msg_starts[0].role == "reasoning"
|
||||
|
||||
def test_reasoning_role_with_flow(self):
|
||||
"""ReasoningMessageStartEvent uses role='reasoning' in streaming flow mode."""
|
||||
flow = FlowState()
|
||||
content = Content.from_text_reasoning(
|
||||
id="reason_role_2",
|
||||
text="Reasoning in streaming mode.",
|
||||
)
|
||||
|
||||
events = _emit_text_reasoning(content, flow)
|
||||
|
||||
msg_starts = [e for e in events if isinstance(e, ReasoningMessageStartEvent)]
|
||||
assert len(msg_starts) == 1
|
||||
assert msg_starts[0].role == "reasoning"
|
||||
|
||||
|
||||
async def test_session_id_matches_thread_id():
|
||||
"""Session created by run_agent_stream uses the client thread_id as session_id."""
|
||||
from conftest import StubAgent
|
||||
|
||||
@@ -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.0b260423"
|
||||
version = "1.0.0b260429"
|
||||
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.1.1,<2",
|
||||
"agent-framework-core>=1.2.2,<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.0b260423"
|
||||
version = "1.0.0b260429"
|
||||
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.1.1,<2",
|
||||
"agent-framework-core>=1.2.2,<2",
|
||||
"azure-search-documents>=11.7.0b2,<11.7.0b3",
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# Local-only files (not committed)
|
||||
_local_only/
|
||||
*_local_only*
|
||||
@@ -0,0 +1,71 @@
|
||||
# AGENTS.md — azure-contentunderstanding
|
||||
|
||||
## Package Overview
|
||||
|
||||
`agent-framework-azure-contentunderstanding` integrates Azure Content Understanding (CU)
|
||||
into the Agent Framework as a context provider. It automatically analyzes file attachments
|
||||
(documents, images, audio, video) and injects structured results into the LLM context.
|
||||
|
||||
## Public API
|
||||
|
||||
| Symbol | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `ContentUnderstandingContextProvider` | class | Main context provider — extends `ContextProvider` |
|
||||
| `AnalysisSection` | enum | Output section selector (MARKDOWN, FIELDS, etc.) |
|
||||
| `DocumentStatus` | enum | Document lifecycle state (ANALYZING, UPLOADING, READY, FAILED) |
|
||||
| `FileSearchBackend` | ABC | Abstract vector store file operations interface |
|
||||
| `FileSearchConfig` | dataclass | Configuration for CU + vector store RAG mode |
|
||||
|
||||
## Architecture
|
||||
|
||||
- **`_context_provider.py`** — Main provider implementation. Overrides `before_run()` to detect
|
||||
file attachments, call the CU API, manage session state with multi-document tracking,
|
||||
and auto-register retrieval tools for follow-up turns.
|
||||
- **Analyzer auto-detection** — When `analyzer_id=None` (default), `_resolve_analyzer_id()`
|
||||
selects the CU analyzer based on media type prefix: `audio/` → `prebuilt-audioSearch`,
|
||||
`video/` → `prebuilt-videoSearch`, everything else → `prebuilt-documentSearch`.
|
||||
- **Multi-segment output** — CU splits long video/audio into multiple scene segments
|
||||
(each a separate `contents[]` entry with its own `startTimeMs`, `endTimeMs`, `markdown`,
|
||||
and `fields`). `_extract_sections()` produces:
|
||||
- `segments`: list of per-segment dicts, each with `markdown`, `fields`, `start_time_s`, `end_time_s`
|
||||
- `markdown`: concatenated at top level with `---` separators (for file_search uploads)
|
||||
- `duration_seconds`: computed from global `min(startTimeMs)` → `max(endTimeMs)`
|
||||
- Metadata (`kind`, `resolution`): taken from the first segment
|
||||
- **Speaker diarization (not identification)** — CU transcripts label speakers as
|
||||
`<Speaker 1>`, `<Speaker 2>`, etc. CU does **not** identify speakers by name.
|
||||
- **file_search RAG** — When `FileSearchConfig` is provided, CU-extracted markdown is
|
||||
uploaded to an OpenAI vector store and a `file_search` tool is registered on the context
|
||||
instead of injecting the full document content. This enables token-efficient retrieval
|
||||
for large documents.
|
||||
- **`_models.py`** — `AnalysisSection` enum, `DocumentStatus` enum, `DocumentEntry` TypedDict,
|
||||
`FileSearchConfig` dataclass.
|
||||
- **`_file_search.py`** — `FileSearchBackend` ABC, `OpenAIFileSearchBackend`,
|
||||
`FoundryFileSearchBackend`.
|
||||
|
||||
## Key Patterns
|
||||
|
||||
- Follows the Azure AI Search context provider pattern (same lifecycle, config style).
|
||||
- Uses provider-scoped `state` dict for multi-document tracking across turns.
|
||||
- Auto-registers `list_documents()` tool via `context.extend_tools()`.
|
||||
- Configurable timeout (`max_wait`) with `asyncio.create_task()` background fallback.
|
||||
- Strips supported binary attachments from `input_messages` to prevent LLM API errors.
|
||||
- Explicit `analyzer_id` always overrides auto-detection (user preference wins).
|
||||
- Vector store resources are cleaned up in `close()` / `__aexit__`.
|
||||
|
||||
## Samples
|
||||
|
||||
| Sample | Description |
|
||||
|--------|-------------|
|
||||
| `01_document_qa.py` | Upload a PDF via URL, ask questions about it |
|
||||
| `02_multi_turn_session.py` | AgentSession persistence across turns |
|
||||
| `03_multimodal_chat.py` | PDF + audio + video parallel analysis |
|
||||
| `04_invoice_processing.py` | Structured field extraction with `prebuilt-invoice` analyzer |
|
||||
| `05_large_doc_file_search.py` | CU extraction + OpenAI vector store RAG |
|
||||
| `02-devui/01-multimodal_agent/` | DevUI web UI for CU-powered chat |
|
||||
| `02-devui/02-file_search_agent/` | DevUI web UI combining CU + file_search RAG |
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
uv run poe test -P azure-contentunderstanding
|
||||
```
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) Microsoft Corporation.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE
|
||||
@@ -0,0 +1,127 @@
|
||||
# Get Started with Azure Content Understanding in Microsoft Agent Framework
|
||||
|
||||
Please install this package via pip:
|
||||
|
||||
```bash
|
||||
pip install agent-framework-azure-contentunderstanding --pre
|
||||
```
|
||||
|
||||
## Azure Content Understanding Integration
|
||||
|
||||
### Prerequisites
|
||||
|
||||
Before using this package, you need an Azure Content Understanding resource:
|
||||
|
||||
1. An active **Azure subscription** ([create one for free](https://azure.microsoft.com/pricing/purchase-options/azure-account))
|
||||
2. A **Microsoft Foundry resource** created in a [supported region](https://learn.microsoft.com/azure/ai-services/content-understanding/language-region-support)
|
||||
3. **Default model deployments** configured for your resource (GPT-4.1, GPT-4.1-mini, text-embedding-3-large)
|
||||
|
||||
Follow the [prerequisites section](https://learn.microsoft.com/azure/ai-services/content-understanding/quickstart/use-rest-api?tabs=portal%2Cdocument&pivots=programming-language-rest#prerequisites) in the Azure Content Understanding quickstart for setup instructions.
|
||||
|
||||
### Introduction
|
||||
|
||||
The Azure Content Understanding integration provides a context provider that automatically analyzes file attachments (documents, images, audio, video) using [Azure Content Understanding](https://learn.microsoft.com/azure/ai-services/content-understanding/) and injects structured results into the LLM context.
|
||||
|
||||
- **Document & image analysis**: State-of-the-art OCR with markdown extraction, table preservation, and structured field extraction — handles scanned PDFs, handwritten content, and complex layouts
|
||||
- **Audio & video analysis**: Transcription, speaker diarization, and per-segment summaries
|
||||
- **Background processing**: Configurable timeout with async background fallback for large files
|
||||
- **file_search integration**: Optional vector store upload for token-efficient RAG on large documents
|
||||
|
||||
> Learn more about Azure Content Understanding capabilities at [https://learn.microsoft.com/azure/ai-services/content-understanding/](https://learn.microsoft.com/azure/ai-services/content-understanding/)
|
||||
|
||||
### Basic Usage Example
|
||||
|
||||
See the [samples directory](samples/) which demonstrates:
|
||||
|
||||
- Single PDF upload and Q&A ([01_document_qa](samples/01-get-started/01_document_qa.py))
|
||||
- Multi-turn sessions with cached results ([02_multi_turn_session](samples/01-get-started/02_multi_turn_session.py))
|
||||
- PDF + audio + video parallel analysis ([03_multimodal_chat](samples/01-get-started/03_multimodal_chat.py))
|
||||
- Structured field extraction with prebuilt-invoice ([04_invoice_processing](samples/01-get-started/04_invoice_processing.py))
|
||||
- CU extraction + OpenAI vector store RAG ([05_large_doc_file_search](samples/01-get-started/05_large_doc_file_search.py))
|
||||
- Interactive web UI with DevUI ([02-devui](samples/02-devui/))
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from agent_framework import Agent, AgentSession, Message, Content
|
||||
from agent_framework.foundry import FoundryChatClient
|
||||
from agent_framework.foundry import ContentUnderstandingContextProvider
|
||||
from azure.identity import AzureCliCredential
|
||||
|
||||
credential = AzureCliCredential()
|
||||
|
||||
cu = ContentUnderstandingContextProvider(
|
||||
endpoint="https://my-resource.cognitiveservices.azure.com/",
|
||||
credential=credential,
|
||||
max_wait=None, # block until CU extraction completes before sending to LLM
|
||||
)
|
||||
|
||||
client = FoundryChatClient(
|
||||
project_endpoint="https://your-project.services.ai.azure.com",
|
||||
model="gpt-4.1",
|
||||
credential=credential,
|
||||
)
|
||||
|
||||
async def main():
|
||||
async with cu:
|
||||
agent = Agent(
|
||||
client=client,
|
||||
name="DocumentQA",
|
||||
instructions="You are a helpful document analyst.",
|
||||
context_providers=[cu],
|
||||
)
|
||||
session = AgentSession()
|
||||
|
||||
response = await agent.run(
|
||||
Message(role="user", contents=[
|
||||
Content.from_text("What's on this invoice?"),
|
||||
Content.from_uri(
|
||||
"https://raw.githubusercontent.com/Azure-Samples/"
|
||||
"azure-ai-content-understanding-assets/main/document/invoice.pdf",
|
||||
media_type="application/pdf",
|
||||
additional_properties={"filename": "invoice.pdf"},
|
||||
),
|
||||
]),
|
||||
session=session,
|
||||
)
|
||||
print(response.text)
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
### Supported File Types
|
||||
|
||||
| Category | Types |
|
||||
|----------|-------|
|
||||
| Documents | PDF, DOCX, XLSX, PPTX, HTML, TXT, Markdown |
|
||||
| Images | JPEG, PNG, TIFF, BMP |
|
||||
| Audio | WAV, MP3, M4A, FLAC, OGG |
|
||||
| Video | MP4, MOV, AVI, WebM |
|
||||
|
||||
For the complete list of supported file types and size limits, see [Azure Content Understanding service limits](https://learn.microsoft.com/azure/ai-services/content-understanding/service-limits#input-file-limits).
|
||||
|
||||
### Environment Variables
|
||||
|
||||
The provider supports automatic endpoint resolution from environment variables.
|
||||
When ``endpoint`` is not passed to the constructor, it is loaded from
|
||||
``AZURE_CONTENTUNDERSTANDING_ENDPOINT``:
|
||||
|
||||
```python
|
||||
# Endpoint auto-loaded from AZURE_CONTENTUNDERSTANDING_ENDPOINT env var
|
||||
cu = ContentUnderstandingContextProvider(credential=credential)
|
||||
```
|
||||
|
||||
Set these in your shell or in a `.env` file:
|
||||
|
||||
```bash
|
||||
AZURE_CONTENTUNDERSTANDING_ENDPOINT=https://your-cu-resource.cognitiveservices.azure.com/
|
||||
AZURE_AI_PROJECT_ENDPOINT=https://your-project.services.ai.azure.com
|
||||
AZURE_OPENAI_DEPLOYMENT_NAME=gpt-4.1
|
||||
```
|
||||
|
||||
You also need to be logged in with `az login` (for `AzureCliCredential`).
|
||||
|
||||
### Next steps
|
||||
|
||||
- Explore the [samples directory](samples/) for complete code examples
|
||||
- Read the [Azure Content Understanding documentation](https://learn.microsoft.com/azure/ai-services/content-understanding/) for detailed service information
|
||||
- Learn more about the [Microsoft Agent Framework](https://aka.ms/agent-framework)
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Azure Content Understanding integration for Microsoft Agent Framework.
|
||||
|
||||
Provides a context provider that analyzes file attachments (documents, images,
|
||||
audio, video) using Azure Content Understanding and injects structured results
|
||||
into the LLM context.
|
||||
"""
|
||||
|
||||
import importlib.metadata
|
||||
|
||||
from ._context_provider import ContentUnderstandingContextProvider
|
||||
from ._file_search import FileSearchBackend
|
||||
from ._models import AnalysisSection, DocumentStatus, FileSearchConfig
|
||||
|
||||
try:
|
||||
__version__ = importlib.metadata.version(__name__)
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
__version__ = "0.0.0"
|
||||
|
||||
__all__ = [
|
||||
"AnalysisSection",
|
||||
"ContentUnderstandingContextProvider",
|
||||
"DocumentStatus",
|
||||
"FileSearchBackend",
|
||||
"FileSearchConfig",
|
||||
"__version__",
|
||||
]
|
||||
+858
@@ -0,0 +1,858 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Azure Content Understanding context provider using ContextProvider.
|
||||
|
||||
This module provides ``ContentUnderstandingContextProvider``, built on the
|
||||
:class:`ContextProvider` hooks pattern. It automatically detects file
|
||||
attachments, analyzes them via the Azure Content Understanding API, and
|
||||
injects structured results into the LLM context.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, TypedDict
|
||||
|
||||
from agent_framework import (
|
||||
AGENT_FRAMEWORK_USER_AGENT,
|
||||
Content,
|
||||
ContextProvider,
|
||||
FunctionTool,
|
||||
Message,
|
||||
SessionContext,
|
||||
)
|
||||
from agent_framework._sessions import AgentSession
|
||||
from agent_framework._settings import load_settings
|
||||
from azure.ai.contentunderstanding.aio import ContentUnderstandingClient
|
||||
from azure.ai.contentunderstanding.models import AnalysisInput, AnalysisResult
|
||||
from azure.core.credentials import AzureKeyCredential
|
||||
from azure.core.credentials_async import AsyncTokenCredential
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agent_framework._agents import SupportsAgentRun
|
||||
|
||||
from ._detection import (
|
||||
detect_and_strip_files,
|
||||
)
|
||||
from ._extraction import extract_sections, format_result
|
||||
from ._models import AnalysisSection, DocumentEntry, DocumentStatus, FileSearchConfig
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
from typing import Self # pragma: no cover
|
||||
else:
|
||||
from typing_extensions import Self # pragma: no cover
|
||||
|
||||
logger = logging.getLogger("agent_framework.azure_contentunderstanding")
|
||||
|
||||
AzureCredentialTypes = AzureKeyCredential | AsyncTokenCredential
|
||||
|
||||
# Mapping from media type prefix to the appropriate prebuilt CU analyzer.
|
||||
# Used when analyzer_id is None (auto-detect mode).
|
||||
MEDIA_TYPE_ANALYZER_MAP: dict[str, str] = {
|
||||
"audio/": "prebuilt-audioSearch",
|
||||
"video/": "prebuilt-videoSearch",
|
||||
}
|
||||
DEFAULT_ANALYZER: str = "prebuilt-documentSearch"
|
||||
|
||||
|
||||
class ContentUnderstandingSettings(TypedDict, total=False):
|
||||
"""Settings for ContentUnderstandingContextProvider with auto-loading from environment.
|
||||
|
||||
Settings are resolved in this order: explicit keyword arguments, values from an
|
||||
explicitly provided .env file, then environment variables with the prefix
|
||||
``AZURE_CONTENTUNDERSTANDING_``.
|
||||
|
||||
Keys:
|
||||
endpoint: Azure AI Foundry endpoint URL.
|
||||
Can be set via environment variable ``AZURE_CONTENTUNDERSTANDING_ENDPOINT``.
|
||||
"""
|
||||
|
||||
endpoint: str | None
|
||||
|
||||
|
||||
class ContentUnderstandingContextProvider(ContextProvider):
|
||||
"""Context provider that analyzes file attachments using Azure Content Understanding.
|
||||
|
||||
Automatically detects supported file attachments in the agent's input,
|
||||
analyzes them via CU, and injects the structured results (markdown, fields)
|
||||
into the LLM context. Supports multiple documents per session with background
|
||||
processing for long-running analyses. Optionally integrates with a vector
|
||||
store backend for ``file_search``-based RAG retrieval on LLM clients that
|
||||
support it.
|
||||
|
||||
Args:
|
||||
endpoint: Azure AI Foundry endpoint URL
|
||||
(e.g., ``"https://<your-foundry-resource>.services.ai.azure.com/"``).
|
||||
Can also be set via environment variable
|
||||
``AZURE_CONTENTUNDERSTANDING_ENDPOINT``.
|
||||
credential: An ``AzureKeyCredential`` for API key auth or an
|
||||
``AsyncTokenCredential`` (e.g., ``DefaultAzureCredential``) for
|
||||
Microsoft Entra ID auth.
|
||||
analyzer_id: A prebuilt or custom CU analyzer ID. When ``None``
|
||||
(default), a prebuilt analyzer is chosen automatically based on
|
||||
the file's media type: ``prebuilt-documentSearch`` for documents
|
||||
and images, ``prebuilt-audioSearch`` for audio, and
|
||||
``prebuilt-videoSearch`` for video.
|
||||
Analyzer reference: https://learn.microsoft.com/azure/ai-services/content-understanding/concepts/analyzer-reference
|
||||
Prebuilt analyzers: https://learn.microsoft.com/azure/ai-services/content-understanding/concepts/prebuilt-analyzers
|
||||
max_wait: Max seconds to wait for analysis before deferring to background.
|
||||
``None`` waits until complete.
|
||||
output_sections: Which CU output sections to pass to LLM.
|
||||
Defaults to ``["markdown", "fields"]``.
|
||||
file_search: Optional configuration for uploading CU-extracted markdown to
|
||||
a vector store for token-efficient RAG retrieval. When provided, full
|
||||
content injection is replaced by ``file_search`` tool registration.
|
||||
The ``FileSearchConfig`` abstraction is backend-agnostic — use
|
||||
``FileSearchConfig.from_openai()`` or ``FileSearchConfig.from_foundry()``
|
||||
for supported providers, or supply a custom ``FileSearchBackend``
|
||||
implementation for other vector store services.
|
||||
source_id: Unique identifier for this provider instance, used for message
|
||||
attribution and tool registration. Defaults to ``"azure_contentunderstanding"``.
|
||||
env_file_path: Path to a ``.env`` file for loading settings.
|
||||
env_file_encoding: Encoding of the ``.env`` file.
|
||||
|
||||
Per-file ``additional_properties`` on ``Content`` objects:
|
||||
The provider reads the following keys from
|
||||
``Content.additional_properties`` (passed via ``Content.from_data()``
|
||||
or ``Content.from_uri()``):
|
||||
|
||||
``filename`` (str):
|
||||
The document key used for tracking, status, and LLM references.
|
||||
Without a filename, a UUID-based key is generated.
|
||||
Must be unique within a session — uploading a file with a
|
||||
duplicate filename will be rejected and the file will not be
|
||||
analyzed.
|
||||
|
||||
``analyzer_id`` (str):
|
||||
Per-file analyzer override. Takes priority over the provider-level
|
||||
``analyzer_id``. Useful for mixing analyzers in the same turn
|
||||
(e.g., ``prebuilt-invoice`` for invoices alongside
|
||||
``prebuilt-documentSearch`` for general documents).
|
||||
|
||||
``content_range`` (str):
|
||||
Subset of the input to analyze. For documents, use 1-based page
|
||||
numbers (e.g., ``"1-3"`` for pages 1-3, ``"1,3,5-"`` for pages
|
||||
1, 3, and 5 onward). For audio/video, use milliseconds
|
||||
(e.g., ``"0-60000"`` for the first 60 seconds).
|
||||
|
||||
Example::
|
||||
|
||||
Content.from_data(
|
||||
pdf_bytes,
|
||||
"application/pdf",
|
||||
additional_properties={
|
||||
"filename": "invoice.pdf",
|
||||
"analyzer_id": "prebuilt-invoice",
|
||||
"content_range": "1-3",
|
||||
},
|
||||
)
|
||||
"""
|
||||
|
||||
DEFAULT_SOURCE_ID: ClassVar[str] = "azure_contentunderstanding"
|
||||
DEFAULT_MAX_WAIT_SECONDS: ClassVar[float] = 5.0
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
endpoint: str | None = None,
|
||||
credential: AzureCredentialTypes | None = None,
|
||||
client: ContentUnderstandingClient | None = None,
|
||||
analyzer_id: str | None = None,
|
||||
max_wait: float | None = DEFAULT_MAX_WAIT_SECONDS,
|
||||
output_sections: list[AnalysisSection] | None = None,
|
||||
file_search: FileSearchConfig | None = None,
|
||||
source_id: str = DEFAULT_SOURCE_ID,
|
||||
env_file_path: str | None = None,
|
||||
env_file_encoding: str | None = None,
|
||||
) -> None:
|
||||
super().__init__(source_id)
|
||||
|
||||
if client is not None:
|
||||
# Use the pre-built client directly — endpoint/credential are ignored.
|
||||
self._client = client
|
||||
self._owns_client = False
|
||||
self._endpoint = ""
|
||||
self._credential = None
|
||||
else:
|
||||
# Build a new client from endpoint + credential.
|
||||
settings = load_settings(
|
||||
ContentUnderstandingSettings,
|
||||
env_prefix="AZURE_CONTENTUNDERSTANDING_",
|
||||
required_fields=["endpoint"],
|
||||
endpoint=endpoint,
|
||||
env_file_path=env_file_path,
|
||||
env_file_encoding=env_file_encoding,
|
||||
)
|
||||
resolved_endpoint: str = settings["endpoint"] # type: ignore[assignment] # validated by load_settings
|
||||
|
||||
if credential is None:
|
||||
raise ValueError(
|
||||
"Azure credential is required. Provide a 'credential' keyword argument "
|
||||
"(e.g., AzureKeyCredential or AzureCliCredential), or pass a pre-built "
|
||||
"'client' (ContentUnderstandingClient) instead."
|
||||
)
|
||||
|
||||
self._endpoint = resolved_endpoint
|
||||
self._credential = credential
|
||||
self._client = ContentUnderstandingClient(
|
||||
self._endpoint, self._credential, user_agent=AGENT_FRAMEWORK_USER_AGENT
|
||||
)
|
||||
self._owns_client = True
|
||||
self.analyzer_id = analyzer_id
|
||||
self.max_wait = max_wait
|
||||
self.output_sections: list[AnalysisSection] = output_sections or ["markdown", "fields"]
|
||||
self.file_search = file_search
|
||||
# Global list of uploaded file IDs — used only by close() for
|
||||
# best-effort cleanup. The authoritative per-session copy lives in
|
||||
# state["_uploaded_file_ids"] (populated in before_run). This global
|
||||
# list may contain entries from multiple sessions; that is intentional
|
||||
# for cleanup.
|
||||
self._all_uploaded_file_ids: list[str] = []
|
||||
|
||||
async def __aenter__(self) -> Self:
|
||||
"""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 — cleanup clients."""
|
||||
await self.close()
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close the underlying CU client and clean up resources.
|
||||
|
||||
Uses global tracking lists for best-effort cleanup across all
|
||||
sessions that used this provider instance.
|
||||
"""
|
||||
# Clean up uploaded files; the vector store itself is caller-managed.
|
||||
if self.file_search and self._all_uploaded_file_ids:
|
||||
await self._cleanup_uploaded_files()
|
||||
# Only close the client if we created it internally.
|
||||
# When a pre-built client was passed in, the caller owns its lifecycle.
|
||||
if self._owns_client:
|
||||
await self._client.close()
|
||||
|
||||
async def before_run(
|
||||
self,
|
||||
*,
|
||||
agent: SupportsAgentRun,
|
||||
session: AgentSession,
|
||||
context: SessionContext,
|
||||
state: dict[str, Any],
|
||||
) -> None:
|
||||
"""Analyze file attachments and inject results into the LLM context.
|
||||
|
||||
This method is called automatically by the framework before each LLM invocation.
|
||||
"""
|
||||
documents: dict[str, DocumentEntry] = state.setdefault("documents", {})
|
||||
|
||||
# Per-session mutable state — isolated per session to prevent cross-session leakage.
|
||||
# _pending_tokens stores serializable continuation tokens (not asyncio.Task objects)
|
||||
# so that state can be persisted to disk/storage by the framework.
|
||||
# Structure: {doc_key: {"continuation_token": <opaque Azure SDK string>,
|
||||
# "analyzer_id": <CU analyzer used for this file>}}
|
||||
pending_tokens: dict[str, dict[str, str]] = state.setdefault("_pending_tokens", {})
|
||||
pending_uploads: list[tuple[str, DocumentEntry]] = state.setdefault("_pending_uploads", [])
|
||||
|
||||
# 1. Resolve pending background analyses via continuation tokens
|
||||
await self._resolve_pending_tokens(pending_tokens, pending_uploads, documents, context)
|
||||
|
||||
# 1b. Upload any documents that completed in the background (file_search mode)
|
||||
if pending_uploads:
|
||||
# Use a bounded timeout so before_run() stays responsive and does not block
|
||||
# indefinitely on slow vector store indexing.
|
||||
upload_timeout = getattr(self, "max_wait", None)
|
||||
remaining_uploads: list[tuple[str, DocumentEntry]] = []
|
||||
for upload_key, upload_entry in pending_uploads:
|
||||
try:
|
||||
if upload_timeout is not None:
|
||||
await asyncio.wait_for(
|
||||
self._upload_to_vector_store(upload_key, upload_entry, state=state),
|
||||
timeout=upload_timeout,
|
||||
)
|
||||
else:
|
||||
await self._upload_to_vector_store(upload_key, upload_entry, state=state)
|
||||
except asyncio.TimeoutError:
|
||||
# Leave timed-out uploads pending so they can be retried on a later turn.
|
||||
logger.warning(
|
||||
"Timed out while uploading document '%s' to vector store; will retry later.",
|
||||
upload_key,
|
||||
)
|
||||
remaining_uploads.append((upload_key, upload_entry))
|
||||
except Exception:
|
||||
# Log unexpected failures and drop the upload entry; this matches prior
|
||||
# behavior where all pending uploads were cleared regardless of outcome.
|
||||
logger.exception(
|
||||
"Error while uploading document '%s' to vector store; dropping from pending list.",
|
||||
upload_key,
|
||||
)
|
||||
context.extend_messages(
|
||||
self.source_id,
|
||||
[
|
||||
Message(
|
||||
role="user",
|
||||
contents=[
|
||||
(
|
||||
f"Document '{upload_key}' was analyzed but failed to upload "
|
||||
"to the vector store. The document content is not available for search."
|
||||
)
|
||||
],
|
||||
)
|
||||
],
|
||||
)
|
||||
state["_pending_uploads"] = remaining_uploads
|
||||
pending_uploads = remaining_uploads
|
||||
|
||||
# 2. Detect CU-supported file attachments, strip them from input, and return for analysis
|
||||
new_files = detect_and_strip_files(context)
|
||||
|
||||
# 3. Analyze new files using CU (track elapsed time for combined timeout)
|
||||
file_start_times: dict[str, float] = {}
|
||||
accepted_keys: set[str] = set() # doc_keys successfully accepted for analysis this turn
|
||||
for doc_key, content_item, binary_data in new_files:
|
||||
# Reject duplicate filenames — re-analyzing would orphan vector store entries
|
||||
if doc_key in documents:
|
||||
logger.warning("Duplicate document key '%s' — skipping (already exists in session).", doc_key)
|
||||
context.extend_messages(
|
||||
self.source_id,
|
||||
[
|
||||
Message(
|
||||
role="user",
|
||||
contents=[
|
||||
(
|
||||
f"The user tried to upload '{doc_key}', but a file with that name "
|
||||
"was already uploaded earlier in this session. The new upload was rejected "
|
||||
"and was not analyzed. Tell the user that a file with the same name "
|
||||
"already exists and they need to rename the file before uploading again."
|
||||
)
|
||||
],
|
||||
)
|
||||
],
|
||||
)
|
||||
continue
|
||||
file_start_times[doc_key] = time.monotonic()
|
||||
doc_entry = await self._analyze_file(doc_key, content_item, binary_data, context, pending_tokens)
|
||||
if doc_entry:
|
||||
documents[doc_key] = doc_entry
|
||||
accepted_keys.add(doc_key)
|
||||
|
||||
# 4. Inject content for ready documents and register tools
|
||||
if documents:
|
||||
self._register_tools(documents, context)
|
||||
|
||||
# 5. On upload turns, inject content for docs accepted this turn
|
||||
for doc_key in accepted_keys:
|
||||
entry = documents.get(doc_key)
|
||||
if entry and entry["status"] == DocumentStatus.READY and entry["result"]:
|
||||
# Upload to vector store if file_search is configured
|
||||
if self.file_search:
|
||||
# Combined timeout: subtract CU analysis time from max_wait
|
||||
remaining: float | None = None
|
||||
if self.max_wait is not None:
|
||||
elapsed = time.monotonic() - file_start_times.get(doc_key, time.monotonic())
|
||||
remaining = max(0.0, self.max_wait - elapsed)
|
||||
uploaded = await self._upload_to_vector_store(doc_key, entry, timeout=remaining, state=state)
|
||||
if uploaded:
|
||||
context.extend_messages(
|
||||
self.source_id,
|
||||
[
|
||||
Message(
|
||||
role="user",
|
||||
contents=[
|
||||
(
|
||||
f"The user just uploaded '{entry['filename']}'. It has been analyzed "
|
||||
"using Azure Content Understanding and indexed in a vector store. "
|
||||
f"When using file_search, include '{entry['filename']}' in your query "
|
||||
"to retrieve content from this specific document."
|
||||
)
|
||||
],
|
||||
)
|
||||
],
|
||||
)
|
||||
elif entry.get("error"):
|
||||
# Upload failed (not timeout — actual error)
|
||||
context.extend_messages(
|
||||
self.source_id,
|
||||
[
|
||||
Message(
|
||||
role="user",
|
||||
contents=[
|
||||
(
|
||||
f"Document '{entry['filename']}' was analyzed but failed to upload "
|
||||
"to the vector store. The document content is not available for search."
|
||||
)
|
||||
],
|
||||
)
|
||||
],
|
||||
)
|
||||
else:
|
||||
# Upload deferred to background (timeout)
|
||||
context.extend_messages(
|
||||
self.source_id,
|
||||
[
|
||||
Message(
|
||||
role="user",
|
||||
contents=[
|
||||
(
|
||||
f"Document '{entry['filename']}' has been analyzed and is being indexed. "
|
||||
"Ask about it again in a moment."
|
||||
)
|
||||
],
|
||||
)
|
||||
],
|
||||
)
|
||||
else:
|
||||
# Without file_search, inject full content into context
|
||||
context.extend_messages(
|
||||
self,
|
||||
[
|
||||
Message(role="user", contents=[format_result(entry["filename"], entry["result"])]),
|
||||
],
|
||||
)
|
||||
context.extend_messages(
|
||||
self.source_id,
|
||||
[
|
||||
Message(
|
||||
role="user",
|
||||
contents=[
|
||||
(
|
||||
f"The user just uploaded '{entry['filename']}'."
|
||||
" It has been analyzed using Azure Content Understanding."
|
||||
" The document content (markdown) and extracted fields"
|
||||
" (JSON) are provided above."
|
||||
" If the user's question is ambiguous,"
|
||||
" prioritize this most recently uploaded document."
|
||||
" Use specific field values and cite page numbers"
|
||||
" when answering."
|
||||
)
|
||||
],
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
# 6. Register file_search tool (for LLM clients that support it)
|
||||
if self.file_search:
|
||||
context.extend_tools(
|
||||
self.source_id,
|
||||
[self.file_search.file_search_tool],
|
||||
)
|
||||
context.extend_instructions(
|
||||
self.source_id,
|
||||
"Tool usage guidelines:\n"
|
||||
"- Use file_search ONLY when answering questions about document content.\n"
|
||||
"- Use list_documents() for status queries (e.g. 'list docs', 'what's uploaded?').\n"
|
||||
"- Do NOT call file_search for status queries — it wastes tokens.",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Analyzer Resolution
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _resolve_analyzer_id(self, media_type: str) -> str:
|
||||
"""Return the analyzer ID to use for the given media type.
|
||||
|
||||
When ``self.analyzer_id`` is set, it is always returned (explicit
|
||||
override). Otherwise the media type prefix is matched against the
|
||||
known mapping, falling back to ``prebuilt-documentSearch``.
|
||||
"""
|
||||
if self.analyzer_id is not None:
|
||||
return self.analyzer_id
|
||||
for prefix, analyzer in MEDIA_TYPE_ANALYZER_MAP.items():
|
||||
if media_type.startswith(prefix):
|
||||
return analyzer
|
||||
return DEFAULT_ANALYZER
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Analysis
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _analyze_file(
|
||||
self,
|
||||
doc_key: str,
|
||||
content: Content,
|
||||
binary_data: bytes | None,
|
||||
context: SessionContext,
|
||||
pending_tokens: dict[str, dict[str, str]] | None = None,
|
||||
) -> DocumentEntry | None:
|
||||
"""Analyze a single file via CU with timeout handling.
|
||||
|
||||
The analyzer is resolved in priority order:
|
||||
1. Per-file override via ``content.additional_properties["analyzer_id"]``
|
||||
2. Provider-level default via ``self.analyzer_id``
|
||||
3. Auto-detect by media type (document/audio/video)
|
||||
|
||||
Returns:
|
||||
A ``DocumentEntry`` (ready, analyzing, or failed), or ``None`` if
|
||||
file data could not be extracted.
|
||||
"""
|
||||
media_type = content.media_type or "application/octet-stream"
|
||||
filename = doc_key
|
||||
|
||||
# Per-file analyzer override from additional_properties
|
||||
props = content.additional_properties or {}
|
||||
per_file_analyzer = props.get("analyzer_id")
|
||||
content_range = props.get("content_range")
|
||||
resolved_analyzer = per_file_analyzer or self._resolve_analyzer_id(media_type)
|
||||
t0 = time.monotonic()
|
||||
|
||||
try:
|
||||
# Start CU analysis
|
||||
if content.type == "uri" and content.uri and not content.uri.startswith("data:"):
|
||||
poller = await self._client.begin_analyze(
|
||||
resolved_analyzer,
|
||||
inputs=[AnalysisInput(url=content.uri, content_range=content_range)],
|
||||
)
|
||||
elif binary_data:
|
||||
poller = await self._client.begin_analyze_binary(
|
||||
resolved_analyzer,
|
||||
binary_input=binary_data,
|
||||
content_type=media_type,
|
||||
)
|
||||
else:
|
||||
context.extend_messages(
|
||||
self.source_id,
|
||||
[Message(role="user", contents=[f"Could not extract file data from '{filename}'."])],
|
||||
)
|
||||
return None
|
||||
|
||||
# Wait with timeout; defer to background polling on timeout.
|
||||
try:
|
||||
result = await asyncio.wait_for(poller.result(), timeout=self.max_wait)
|
||||
except asyncio.TimeoutError:
|
||||
# Save continuation token for resuming on next before_run().
|
||||
# Continuation tokens are serializable strings, so state can
|
||||
# be persisted to disk/storage without issues.
|
||||
token = poller.continuation_token()
|
||||
logger.info("Analysis of '%s' timed out; deferring to background via continuation token.", filename)
|
||||
if pending_tokens is not None:
|
||||
pending_tokens[doc_key] = {
|
||||
"continuation_token": token,
|
||||
"analyzer_id": resolved_analyzer,
|
||||
}
|
||||
context.extend_messages(
|
||||
self.source_id,
|
||||
[
|
||||
Message(
|
||||
role="user",
|
||||
contents=[f"Document '{filename}' is being analyzed. Ask about it again in a moment."],
|
||||
)
|
||||
],
|
||||
)
|
||||
return DocumentEntry(
|
||||
status=DocumentStatus.ANALYZING,
|
||||
filename=filename,
|
||||
media_type=media_type,
|
||||
analyzer_id=resolved_analyzer,
|
||||
analyzed_at=None,
|
||||
analysis_duration_s=None,
|
||||
upload_duration_s=None,
|
||||
result=None,
|
||||
error=None,
|
||||
)
|
||||
|
||||
# Analysis completed within timeout
|
||||
analysis_duration = round(time.monotonic() - t0, 2)
|
||||
extracted = self._extract_sections(result)
|
||||
logger.info("Analyzed '%s' with analyzer '%s' in %.1fs.", filename, resolved_analyzer, analysis_duration)
|
||||
return DocumentEntry(
|
||||
status=DocumentStatus.READY,
|
||||
filename=filename,
|
||||
media_type=media_type,
|
||||
analyzer_id=resolved_analyzer,
|
||||
analyzed_at=datetime.now(tz=timezone.utc).isoformat(),
|
||||
analysis_duration_s=analysis_duration,
|
||||
upload_duration_s=None,
|
||||
result=extracted,
|
||||
error=None,
|
||||
)
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning("CU analysis error for '%s': %s", filename, e)
|
||||
context.extend_messages(
|
||||
self.source_id,
|
||||
[Message(role="user", contents=[f"Could not analyze '{filename}': {e}"])],
|
||||
)
|
||||
return DocumentEntry(
|
||||
status=DocumentStatus.FAILED,
|
||||
filename=filename,
|
||||
media_type=media_type,
|
||||
analyzer_id=resolved_analyzer,
|
||||
analyzed_at=datetime.now(tz=timezone.utc).isoformat(),
|
||||
analysis_duration_s=round(time.monotonic() - t0, 2),
|
||||
upload_duration_s=None,
|
||||
result=None,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Pending Token Resolution
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _resolve_pending_tokens(
|
||||
self,
|
||||
pending_tokens: dict[str, dict[str, str]],
|
||||
pending_uploads: list[tuple[str, DocumentEntry]],
|
||||
documents: dict[str, DocumentEntry],
|
||||
context: SessionContext,
|
||||
) -> None:
|
||||
"""Resume pending CU analyses using serializable continuation tokens.
|
||||
|
||||
When a file's CU analysis exceeds ``max_wait``, a continuation token
|
||||
(an opaque string from the Azure SDK) is saved in ``state`` instead of
|
||||
an ``asyncio.Task``. This keeps state fully serializable — it can be
|
||||
persisted to disk/storage by the framework.
|
||||
|
||||
On the next ``before_run()`` call, this method resumes each pending
|
||||
operation by passing the token back to ``begin_analyze()``. If the
|
||||
server-side operation has completed, the result is available
|
||||
immediately; otherwise the token is kept for the next turn.
|
||||
"""
|
||||
if not pending_tokens:
|
||||
return
|
||||
logger.info("Resolving %d pending analysis token(s).", len(pending_tokens))
|
||||
completed_keys: list[str] = []
|
||||
|
||||
for doc_key, token_info in pending_tokens.items():
|
||||
entry = documents.get(doc_key)
|
||||
if not entry:
|
||||
completed_keys.append(doc_key)
|
||||
continue
|
||||
|
||||
try:
|
||||
poller = await self._client.begin_analyze( # type: ignore[call-overload, reportUnknownVariableType]
|
||||
token_info["analyzer_id"],
|
||||
continuation_token=token_info["continuation_token"], # pyright: ignore[reportCallIssue]
|
||||
)
|
||||
# Use wait_for to avoid blocking before_run indefinitely.
|
||||
# poller.done() always returns False for resumed pollers (stale
|
||||
# cached status), so we call poller.result() which polls the server.
|
||||
#
|
||||
# Timeout: at least 10s regardless of max_wait. The upload-turn
|
||||
# max_wait can be very short (e.g. 5s) for responsiveness, but
|
||||
# on resolution turns the resumed poller needs a network round-trip
|
||||
# to fetch the result. If the analysis is still running after 10s,
|
||||
# the token is kept and retried on the next turn.
|
||||
MIN_RESOLUTION_TIMEOUT = 10.0
|
||||
resolution_timeout = max(self.max_wait or MIN_RESOLUTION_TIMEOUT, MIN_RESOLUTION_TIMEOUT)
|
||||
try:
|
||||
result: AnalysisResult = await asyncio.wait_for(
|
||||
poller.result(), # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType]
|
||||
timeout=resolution_timeout,
|
||||
) # pyright: ignore[reportUnknownVariableType]
|
||||
except asyncio.TimeoutError:
|
||||
# Still running — update token and keep for next turn
|
||||
new_token: str = poller.continuation_token() # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType]
|
||||
token_info["continuation_token"] = new_token
|
||||
logger.info("Analysis for '%s' still running; keeping token for next turn.", doc_key)
|
||||
continue
|
||||
|
||||
completed_keys.append(doc_key)
|
||||
extracted = self._extract_sections(result) # pyright: ignore[reportUnknownArgumentType]
|
||||
entry["status"] = DocumentStatus.READY
|
||||
entry["analyzed_at"] = datetime.now(tz=timezone.utc).isoformat()
|
||||
entry["result"] = extracted
|
||||
entry["error"] = None
|
||||
logger.info("Background analysis of '%s' completed.", entry["filename"])
|
||||
|
||||
# Inject newly ready content
|
||||
if self.file_search:
|
||||
pending_uploads.append((doc_key, entry))
|
||||
else:
|
||||
context.extend_messages(
|
||||
self,
|
||||
[
|
||||
Message(role="user", contents=[format_result(entry["filename"], extracted)]),
|
||||
],
|
||||
)
|
||||
context.extend_messages(
|
||||
self.source_id,
|
||||
[
|
||||
Message(
|
||||
role="user",
|
||||
contents=[
|
||||
f"Document '{entry['filename']}' analysis is now complete."
|
||||
+ (
|
||||
" The document is being indexed in the vector store and will become"
|
||||
" searchable via file_search shortly."
|
||||
if self.file_search
|
||||
else " The content is provided above."
|
||||
)
|
||||
],
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
completed_keys.append(doc_key)
|
||||
logger.warning("Background analysis of '%s' failed: %s", entry.get("filename", doc_key), e)
|
||||
entry["status"] = DocumentStatus.FAILED
|
||||
entry["analyzed_at"] = datetime.now(tz=timezone.utc).isoformat()
|
||||
entry["error"] = str(e)
|
||||
context.extend_messages(
|
||||
self.source_id,
|
||||
[Message(role="user", contents=[f"Document '{entry['filename']}' analysis failed: {e}"])],
|
||||
)
|
||||
|
||||
for key in completed_keys:
|
||||
del pending_tokens[key]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Output Extraction & Formatting (delegates to _extraction module)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _extract_sections(self, result: AnalysisResult) -> dict[str, object]:
|
||||
return extract_sections(result, self.output_sections)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Tool Registration
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _register_tools(
|
||||
self,
|
||||
documents: dict[str, DocumentEntry],
|
||||
context: SessionContext,
|
||||
) -> None:
|
||||
"""Register document tools on the context.
|
||||
|
||||
Only ``list_documents`` is registered — the full document content is
|
||||
already injected into conversation history on the upload turn, so a
|
||||
separate retrieval tool is not needed.
|
||||
"""
|
||||
context.extend_tools(
|
||||
self.source_id,
|
||||
[self._make_list_documents_tool(documents)],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _make_list_documents_tool(documents: dict[str, DocumentEntry]) -> FunctionTool:
|
||||
"""Create a tool that lists all tracked documents with their status."""
|
||||
docs_ref = documents
|
||||
|
||||
def list_documents() -> str:
|
||||
"""List all documents that have been uploaded and their analysis status."""
|
||||
entries: list[dict[str, object]] = []
|
||||
for name, entry in docs_ref.items():
|
||||
entries.append({
|
||||
"name": name,
|
||||
"status": entry["status"],
|
||||
"media_type": entry["media_type"],
|
||||
"analyzed_at": entry["analyzed_at"],
|
||||
"analysis_duration_s": entry["analysis_duration_s"],
|
||||
"upload_duration_s": entry["upload_duration_s"],
|
||||
})
|
||||
return json.dumps(entries, indent=2, default=str)
|
||||
|
||||
return FunctionTool(
|
||||
name="list_documents",
|
||||
description=(
|
||||
"List all documents that have been uploaded in this session "
|
||||
"with their analysis status (analyzing, uploading, ready, or failed)."
|
||||
),
|
||||
func=list_documents,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# file_search Vector Store Integration
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _upload_to_vector_store(
|
||||
self,
|
||||
doc_key: str,
|
||||
entry: DocumentEntry,
|
||||
*,
|
||||
timeout: float | None = None,
|
||||
state: dict[str, Any] | None = None,
|
||||
) -> bool:
|
||||
"""Upload CU-extracted markdown to the caller's vector store.
|
||||
|
||||
Delegates to the configured ``FileSearchBackend`` (OpenAI, Foundry,
|
||||
or a custom implementation). The upload includes file upload **and**
|
||||
vector store indexing (embedding + ingestion) — ``create_and_poll``
|
||||
waits for the index to be fully ready before returning.
|
||||
|
||||
Args:
|
||||
doc_key: Document identifier.
|
||||
entry: The document entry with extracted results.
|
||||
timeout: Max seconds to wait for upload + indexing. ``None`` waits
|
||||
indefinitely. On timeout the upload is deferred to the
|
||||
per-session ``_pending_uploads`` queue for the next
|
||||
``before_run()`` call.
|
||||
state: Per-session state dict for tracking uploaded file IDs and
|
||||
pending uploads.
|
||||
|
||||
Returns:
|
||||
True if the upload succeeded, False otherwise.
|
||||
"""
|
||||
if not self.file_search:
|
||||
return False
|
||||
|
||||
result = entry.get("result")
|
||||
if not result:
|
||||
return False
|
||||
|
||||
# Upload the full formatted content (markdown + fields + segments),
|
||||
# not just raw markdown — consistent with what non-file_search mode injects.
|
||||
formatted = format_result(entry["filename"], result)
|
||||
if not formatted:
|
||||
return False
|
||||
|
||||
entry["status"] = DocumentStatus.UPLOADING
|
||||
t0 = time.monotonic()
|
||||
|
||||
try:
|
||||
upload_coro = self.file_search.backend.upload_file(
|
||||
self.file_search.vector_store_id, f"{doc_key}.md", formatted.encode("utf-8")
|
||||
)
|
||||
file_id = await asyncio.wait_for(upload_coro, timeout=timeout)
|
||||
upload_duration = round(time.monotonic() - t0, 2)
|
||||
# Track in per-session state and global list (for close() cleanup)
|
||||
if state is not None:
|
||||
state.setdefault("_uploaded_file_ids", []).append(file_id)
|
||||
self._all_uploaded_file_ids.append(file_id)
|
||||
entry["status"] = DocumentStatus.READY
|
||||
entry["upload_duration_s"] = upload_duration
|
||||
logger.info("Uploaded '%s' to vector store in %.1fs (%s bytes).", doc_key, upload_duration, len(formatted))
|
||||
return True
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
logger.info("Vector store upload for '%s' timed out; deferring to background.", doc_key)
|
||||
entry["status"] = DocumentStatus.UPLOADING
|
||||
if state is not None:
|
||||
state.setdefault("_pending_uploads", []).append((doc_key, entry))
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("Failed to upload '%s' to vector store: %s", doc_key, e)
|
||||
entry["status"] = DocumentStatus.FAILED
|
||||
entry["upload_duration_s"] = round(time.monotonic() - t0, 2)
|
||||
entry["error"] = f"Vector store upload failed: {e}"
|
||||
return False
|
||||
|
||||
async def _cleanup_uploaded_files(self) -> None:
|
||||
"""Delete files uploaded by this provider via the configured backend.
|
||||
|
||||
The vector store itself is caller-managed and is not deleted here.
|
||||
"""
|
||||
if not self.file_search:
|
||||
return
|
||||
|
||||
backend = self.file_search.backend
|
||||
|
||||
try:
|
||||
for file_id in self._all_uploaded_file_ids:
|
||||
await backend.delete_file(file_id)
|
||||
self._all_uploaded_file_ids.clear()
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("Failed to clean up uploaded files: %s", e)
|
||||
+234
@@ -0,0 +1,234 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""File detection utilities for Azure Content Understanding context provider.
|
||||
|
||||
Functions for scanning input messages, sniffing MIME types, deriving
|
||||
document keys, and extracting binary data from content items.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import logging
|
||||
import mimetypes
|
||||
import re
|
||||
import uuid
|
||||
|
||||
import filetype
|
||||
from agent_framework import Content, SessionContext
|
||||
|
||||
logger = logging.getLogger("agent_framework.azure_contentunderstanding")
|
||||
|
||||
# MIME types used to match against the resolved media type for routing files to CU analysis.
|
||||
# The media type may be provided via Content.media_type or inferred (e.g., via sniffing or filename)
|
||||
# when missing or generic (such as application/octet-stream). Only files whose resolved media type is
|
||||
# in this set will be processed; others are skipped.
|
||||
#
|
||||
# Supported input file types:
|
||||
# https://learn.microsoft.com/azure/ai-services/content-understanding/service-limits#input-file-limits
|
||||
SUPPORTED_MEDIA_TYPES: frozenset[str] = frozenset({
|
||||
# Documents and images
|
||||
"application/pdf",
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/tiff",
|
||||
"image/bmp",
|
||||
"image/heif",
|
||||
"image/heic",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
# Text
|
||||
"text/plain",
|
||||
"text/html",
|
||||
"text/markdown",
|
||||
"text/rtf",
|
||||
"text/xml",
|
||||
"application/xml",
|
||||
"message/rfc822",
|
||||
"application/vnd.ms-outlook",
|
||||
# Audio
|
||||
"audio/wav",
|
||||
"audio/mpeg",
|
||||
"audio/mp3",
|
||||
"audio/mp4",
|
||||
"audio/m4a",
|
||||
"audio/flac",
|
||||
"audio/ogg",
|
||||
"audio/opus",
|
||||
"audio/webm",
|
||||
"audio/x-ms-wma",
|
||||
"audio/aac",
|
||||
"audio/amr",
|
||||
"audio/3gpp",
|
||||
# Video
|
||||
"video/mp4",
|
||||
"video/quicktime",
|
||||
"video/x-msvideo",
|
||||
"video/webm",
|
||||
"video/x-flv",
|
||||
"video/x-ms-wmv",
|
||||
"video/x-ms-asf",
|
||||
"video/x-matroska",
|
||||
})
|
||||
|
||||
# Mapping from filetype's MIME output to our canonical SUPPORTED_MEDIA_TYPES values.
|
||||
# filetype uses some x-prefixed variants that differ from our set.
|
||||
MIME_ALIASES: dict[str, str] = {
|
||||
"audio/x-wav": "audio/wav",
|
||||
"audio/x-flac": "audio/flac",
|
||||
"video/x-m4v": "video/mp4",
|
||||
}
|
||||
|
||||
|
||||
def detect_and_strip_files(
|
||||
context: SessionContext,
|
||||
) -> list[tuple[str, Content, bytes | None]]:
|
||||
"""Scan input messages for supported file content and prepare for CU analysis.
|
||||
|
||||
Scans for type ``data`` or ``uri`` content supported by Azure Content
|
||||
Understanding, strips them from messages to prevent raw binary being sent
|
||||
to the LLM, and returns metadata for CU analysis.
|
||||
|
||||
Detected files are tracked via ``doc_key`` (derived from filename, URL,
|
||||
or UUID) and their analysis status is managed in session state.
|
||||
|
||||
When the upstream MIME type is unreliable (``application/octet-stream``
|
||||
or missing), binary content sniffing via ``filetype`` is used to
|
||||
determine the real media type, with ``mimetypes.guess_type`` as a
|
||||
filename-based fallback.
|
||||
|
||||
Returns:
|
||||
List of (doc_key, content_item, binary_data) tuples for files to analyze.
|
||||
"""
|
||||
results: list[tuple[str, Content, bytes | None]] = []
|
||||
strip_ids: set[int] = set()
|
||||
|
||||
for msg in context.input_messages:
|
||||
for c in msg.contents:
|
||||
if c.type not in ("data", "uri"):
|
||||
continue
|
||||
|
||||
media_type = c.media_type
|
||||
# Fast path: already a known supported type
|
||||
if media_type and media_type in SUPPORTED_MEDIA_TYPES:
|
||||
binary_data = extract_binary(c)
|
||||
results.append((derive_doc_key(c), c, binary_data))
|
||||
strip_ids.add(id(c))
|
||||
continue
|
||||
|
||||
# Slow path: unreliable MIME — sniff binary content
|
||||
if (not media_type) or (media_type == "application/octet-stream"):
|
||||
binary_data = extract_binary(c)
|
||||
resolved = sniff_media_type(binary_data, c)
|
||||
if resolved and (resolved in SUPPORTED_MEDIA_TYPES):
|
||||
c.media_type = resolved
|
||||
results.append((derive_doc_key(c), c, binary_data))
|
||||
strip_ids.add(id(c))
|
||||
|
||||
# Strip detected files from input so raw binary isn't sent to LLM
|
||||
msg.contents = [c for c in msg.contents if id(c) not in strip_ids]
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def sniff_media_type(binary_data: bytes | None, content: Content) -> str | None:
|
||||
"""Sniff the actual MIME type from binary data, with filename fallback.
|
||||
|
||||
Uses ``filetype`` (magic-bytes) first, then ``mimetypes.guess_type``
|
||||
on the filename. Normalizes filetype's variant MIME values (e.g.
|
||||
``audio/x-wav`` -> ``audio/wav``) via ``MIME_ALIASES``.
|
||||
"""
|
||||
# 1. Binary sniffing via filetype (needs only first 261 bytes)
|
||||
if binary_data:
|
||||
kind = filetype.guess(binary_data[:262]) # type: ignore[reportUnknownMemberType]
|
||||
if kind:
|
||||
mime: str = kind.mime # type: ignore[reportUnknownMemberType]
|
||||
return MIME_ALIASES.get(mime, mime)
|
||||
|
||||
# 2. Filename extension fallback — try additional_properties first,
|
||||
# then extract basename from external URL path
|
||||
filename: str | None = None
|
||||
if content.additional_properties:
|
||||
filename = content.additional_properties.get("filename")
|
||||
if not filename and content.uri and not content.uri.startswith("data:"):
|
||||
# Extract basename from URL path (e.g. "https://example.com/report.pdf?v=1" -> "report.pdf")
|
||||
filename = content.uri.split("?")[0].split("#")[0].rsplit("/", 1)[-1]
|
||||
if filename:
|
||||
guessed, _ = mimetypes.guess_type(filename) # uses file extension to guess MIME type
|
||||
if guessed:
|
||||
return MIME_ALIASES.get(guessed, guessed)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def is_supported_content(content: Content) -> bool:
|
||||
"""Check if a content item is a supported file type for CU analysis."""
|
||||
if content.type not in ("data", "uri"):
|
||||
return False
|
||||
media_type = content.media_type
|
||||
if not media_type:
|
||||
return False
|
||||
return media_type in SUPPORTED_MEDIA_TYPES
|
||||
|
||||
|
||||
def sanitize_doc_key(raw: str) -> str:
|
||||
"""Sanitize a document key to prevent prompt injection.
|
||||
|
||||
Removes control characters (newlines, tabs, etc.), collapses
|
||||
whitespace, strips surrounding whitespace, and caps length at
|
||||
255 characters.
|
||||
"""
|
||||
# Remove control characters (C0/C1 controls, including \n, \r, \t)
|
||||
cleaned = re.sub(r"[\x00-\x1f\x7f-\x9f]", "", raw)
|
||||
# Collapse whitespace
|
||||
cleaned = " ".join(cleaned.split())
|
||||
# Cap length
|
||||
return cleaned[:255] if cleaned else f"doc_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
def derive_doc_key(content: Content) -> str:
|
||||
"""Derive a unique document key from content metadata.
|
||||
|
||||
The key is used to track documents in session state. Duplicate keys
|
||||
within a session are rejected (not re-analyzed) to prevent orphaned
|
||||
vector store entries.
|
||||
|
||||
The returned key is sanitized to prevent prompt injection via
|
||||
crafted filenames (control characters removed, length capped).
|
||||
|
||||
Priority: filename > URL basename > generated UUID.
|
||||
"""
|
||||
# 1. Filename from additional_properties
|
||||
if content.additional_properties:
|
||||
filename = content.additional_properties.get("filename")
|
||||
if filename and isinstance(filename, str):
|
||||
return sanitize_doc_key(filename)
|
||||
|
||||
# 2. URL path basename for external URIs (e.g. "https://example.com/report.pdf" -> "report.pdf")
|
||||
if content.type == "uri" and content.uri and not content.uri.startswith("data:"):
|
||||
path = content.uri.split("?")[0].split("#")[0] # strip query params and fragments
|
||||
# rstrip("/") handles trailing slashes (e.g. ".../files/" -> ".../files")
|
||||
# rsplit("/", 1)[-1] splits from the right once to get the last path segment
|
||||
basename = path.rstrip("/").rsplit("/", 1)[-1]
|
||||
if basename:
|
||||
return sanitize_doc_key(basename)
|
||||
|
||||
# 3. Fallback: generate a unique ID for anonymous uploads (no filename, no URL)
|
||||
return f"doc_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
def extract_binary(content: Content) -> bytes | None:
|
||||
"""Extract binary data from a data URI content item.
|
||||
|
||||
Only handles ``data:`` URIs (base64-encoded). Returns ``None`` for
|
||||
external URLs -- those are passed directly to CU via ``begin_analyze``.
|
||||
"""
|
||||
if content.uri and content.uri.startswith("data:"):
|
||||
try:
|
||||
_, data_part = content.uri.split(",", 1)
|
||||
return base64.b64decode(data_part)
|
||||
except Exception:
|
||||
logger.warning("Failed to decode base64 data URI")
|
||||
return None
|
||||
return None
|
||||
+297
@@ -0,0 +1,297 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Output extraction and formatting for Azure Content Understanding results.
|
||||
|
||||
Converts CU ``AnalysisResult`` objects into plain Python dicts suitable
|
||||
for LLM consumption, and formats them as human-readable text.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, cast
|
||||
|
||||
from azure.ai.contentunderstanding.models import AnalysisResult
|
||||
|
||||
from ._models import AnalysisSection
|
||||
|
||||
|
||||
def extract_sections(
|
||||
result: AnalysisResult,
|
||||
output_sections: list[AnalysisSection],
|
||||
) -> dict[str, object]:
|
||||
"""Extract configured sections from a CU analysis result.
|
||||
|
||||
For single-segment results (documents, images, short audio), returns a flat
|
||||
dict with ``markdown`` and ``fields`` at the top level.
|
||||
|
||||
For multi-segment results (e.g. video split into scenes), fields are kept
|
||||
with their respective segments in a ``segments`` list so the LLM can see
|
||||
which fields belong to which part of the content:
|
||||
- ``segments``: list of per-segment dicts with ``markdown``, ``fields``,
|
||||
``start_time_s``, and ``end_time_s``
|
||||
- ``markdown``: still concatenated at top level for file_search uploads
|
||||
- ``duration_seconds``: computed from the global time span
|
||||
- ``kind`` / ``resolution``: taken from the first segment
|
||||
"""
|
||||
extracted: dict[str, object] = {}
|
||||
contents = result.contents
|
||||
if not contents:
|
||||
return extracted
|
||||
|
||||
# --- Warnings from the CU service (ODataV4Format with code/message/target) ---
|
||||
if result.warnings:
|
||||
warnings_out: list[dict[str, str]] = []
|
||||
for w in result.warnings:
|
||||
entry: dict[str, str] = {}
|
||||
code = getattr(w, "code", None)
|
||||
if code:
|
||||
entry["code"] = code
|
||||
msg = getattr(w, "message", None)
|
||||
entry["message"] = msg if msg else str(w)
|
||||
target = getattr(w, "target", None)
|
||||
if target:
|
||||
entry["target"] = target
|
||||
warnings_out.append(entry)
|
||||
extracted["warnings"] = warnings_out
|
||||
|
||||
# --- Media metadata (from first segment) ---
|
||||
first = contents[0]
|
||||
kind = getattr(first, "kind", None)
|
||||
if kind:
|
||||
extracted["kind"] = kind
|
||||
width = getattr(first, "width", None)
|
||||
height = getattr(first, "height", None)
|
||||
if width and height:
|
||||
extracted["resolution"] = f"{width}x{height}"
|
||||
|
||||
# Compute total duration from the global time span of all segments.
|
||||
global_start: int | None = None
|
||||
global_end: int | None = None
|
||||
for content in contents:
|
||||
s = getattr(content, "start_time_ms", None)
|
||||
if s is None:
|
||||
s = getattr(content, "startTimeMs", None)
|
||||
e = getattr(content, "end_time_ms", None)
|
||||
if e is None:
|
||||
e = getattr(content, "endTimeMs", None)
|
||||
if s is not None:
|
||||
global_start = s if global_start is None else min(global_start, s)
|
||||
if e is not None:
|
||||
global_end = e if global_end is None else max(global_end, e)
|
||||
if global_start is not None and global_end is not None:
|
||||
extracted["duration_seconds"] = round((global_end - global_start) / 1000, 1)
|
||||
|
||||
is_multi_segment = len(contents) > 1
|
||||
|
||||
# --- Single-segment: flat output (documents, images, short audio) ---
|
||||
if not is_multi_segment:
|
||||
if "markdown" in output_sections and contents[0].markdown:
|
||||
extracted["markdown"] = contents[0].markdown
|
||||
if "fields" in output_sections and contents[0].fields:
|
||||
fields: dict[str, object] = {}
|
||||
for name, field in contents[0].fields.items():
|
||||
entry_dict: dict[str, object] = {
|
||||
"type": getattr(field, "type", None),
|
||||
"value": extract_field_value(field),
|
||||
}
|
||||
confidence = getattr(field, "confidence", None)
|
||||
if confidence is not None:
|
||||
entry_dict["confidence"] = confidence
|
||||
fields[name] = entry_dict
|
||||
if fields:
|
||||
extracted["fields"] = fields
|
||||
# Content-level category (e.g. from classifier analyzers)
|
||||
category = getattr(contents[0], "category", None)
|
||||
if category:
|
||||
extracted["category"] = category
|
||||
return extracted
|
||||
|
||||
# --- Multi-segment: per-segment output (video scenes, long audio) ---
|
||||
# Each segment keeps its own markdown + fields together so the LLM can
|
||||
# see which fields (e.g. Summary) belong to which part of the content.
|
||||
segments_out: list[dict[str, object]] = []
|
||||
md_parts: list[str] = [] # also collect for top-level concatenated markdown
|
||||
|
||||
for content in contents:
|
||||
seg: dict[str, object] = {}
|
||||
|
||||
# Time range for this segment
|
||||
s = getattr(content, "start_time_ms", None)
|
||||
if s is None:
|
||||
s = getattr(content, "startTimeMs", None)
|
||||
e = getattr(content, "end_time_ms", None)
|
||||
if e is None:
|
||||
e = getattr(content, "endTimeMs", None)
|
||||
if s is not None:
|
||||
seg["start_time_s"] = round(s / 1000, 1)
|
||||
if e is not None:
|
||||
seg["end_time_s"] = round(e / 1000, 1)
|
||||
|
||||
# Per-segment markdown
|
||||
if "markdown" in output_sections and content.markdown:
|
||||
seg["markdown"] = content.markdown
|
||||
md_parts.append(content.markdown)
|
||||
|
||||
# Per-segment fields
|
||||
if "fields" in output_sections and content.fields:
|
||||
seg_fields: dict[str, object] = {}
|
||||
for name, field in content.fields.items():
|
||||
seg_entry: dict[str, object] = {
|
||||
"type": getattr(field, "type", None),
|
||||
"value": extract_field_value(field),
|
||||
}
|
||||
confidence = getattr(field, "confidence", None)
|
||||
if confidence is not None:
|
||||
seg_entry["confidence"] = confidence
|
||||
seg_fields[name] = seg_entry
|
||||
if seg_fields:
|
||||
seg["fields"] = seg_fields
|
||||
|
||||
# Per-segment category (e.g. from classifier analyzers)
|
||||
category = getattr(content, "category", None)
|
||||
if category:
|
||||
seg["category"] = category
|
||||
|
||||
segments_out.append(seg)
|
||||
|
||||
extracted["segments"] = segments_out
|
||||
|
||||
# Top-level concatenated markdown (used by file_search for vector store upload)
|
||||
if md_parts:
|
||||
extracted["markdown"] = "\n\n---\n\n".join(md_parts)
|
||||
|
||||
return extracted
|
||||
|
||||
|
||||
def extract_field_value(field: Any) -> object:
|
||||
"""Extract the plain Python value from a CU ``ContentField``.
|
||||
|
||||
Uses the SDK's ``.value`` convenience property, which dynamically
|
||||
reads the correct ``value_*`` attribute for each field type.
|
||||
Object and array types are recursively flattened so that the
|
||||
output contains only plain Python primitives (str, int, float,
|
||||
date, dict, list) -- no SDK model objects or raw wire format
|
||||
(``valueNumber``, ``spans``, ``source``, etc.).
|
||||
"""
|
||||
field_type = getattr(field, "type", None)
|
||||
raw = getattr(field, "value", None)
|
||||
|
||||
# Object fields -> recursively resolve nested sub-fields
|
||||
if field_type == "object" and raw is not None and isinstance(raw, dict):
|
||||
return {str(k): flatten_field(v) for k, v in cast(dict[str, Any], raw).items()}
|
||||
|
||||
# Array fields -> list of flattened items (each with value + optional confidence)
|
||||
if field_type == "array" and raw is not None and isinstance(raw, list):
|
||||
return [flatten_field(item) for item in cast(list[Any], raw)]
|
||||
|
||||
# Scalar fields (string, number, date, etc.) -- .value returns native Python type
|
||||
return raw
|
||||
|
||||
|
||||
def flatten_field(field: Any) -> object:
|
||||
"""Flatten a CU ``ContentField`` into a ``{type, value, confidence}`` dict.
|
||||
|
||||
Used for sub-fields inside object and array types to preserve
|
||||
per-field confidence scores. Confidence is omitted when ``None``
|
||||
to reduce token usage.
|
||||
"""
|
||||
field_type = getattr(field, "type", None)
|
||||
value = extract_field_value(field)
|
||||
confidence = getattr(field, "confidence", None)
|
||||
|
||||
result: dict[str, object] = {"type": field_type, "value": value}
|
||||
if confidence is not None:
|
||||
result["confidence"] = confidence
|
||||
return result
|
||||
|
||||
|
||||
def format_result(filename: str, result: dict[str, object]) -> str:
|
||||
"""Format extracted CU result for LLM consumption.
|
||||
|
||||
For multi-segment results (video/audio with ``segments``), each segment's
|
||||
markdown and fields are grouped together so the LLM can see which fields
|
||||
belong to which part of the content.
|
||||
"""
|
||||
kind = result.get("kind")
|
||||
is_video = kind == "audioVisual"
|
||||
is_audio = kind == "audio"
|
||||
|
||||
# Header -- media-aware label
|
||||
if is_video:
|
||||
label = "Video analysis"
|
||||
elif is_audio:
|
||||
label = "Audio analysis"
|
||||
else:
|
||||
label = "Document analysis"
|
||||
parts: list[str] = [f'{label} of "{filename}":']
|
||||
|
||||
# Media metadata line (duration, resolution)
|
||||
meta_items: list[str] = []
|
||||
duration = result.get("duration_seconds")
|
||||
if duration is not None:
|
||||
mins, secs = divmod(int(duration), 60) # type: ignore[call-overload]
|
||||
meta_items.append(f"Duration: {mins}:{secs:02d}")
|
||||
resolution = result.get("resolution")
|
||||
if resolution:
|
||||
meta_items.append(f"Resolution: {resolution}")
|
||||
if meta_items:
|
||||
parts.append(" | ".join(meta_items))
|
||||
|
||||
# --- Multi-segment: format each segment with its own content + fields ---
|
||||
raw_segments = result.get("segments")
|
||||
segments: list[dict[str, object]] = (
|
||||
cast(list[dict[str, object]], raw_segments) if isinstance(raw_segments, list) else []
|
||||
)
|
||||
if segments:
|
||||
for i, seg in enumerate(segments):
|
||||
# Segment header with time range
|
||||
start = seg.get("start_time_s")
|
||||
end = seg.get("end_time_s")
|
||||
if start is not None and end is not None:
|
||||
s_min, s_sec = divmod(int(start), 60) # type: ignore[call-overload]
|
||||
e_min, e_sec = divmod(int(end), 60) # type: ignore[call-overload]
|
||||
parts.append(f"\n### Segment {i + 1} ({s_min}:{s_sec:02d} - {e_min}:{e_sec:02d})")
|
||||
else:
|
||||
parts.append(f"\n### Segment {i + 1}")
|
||||
|
||||
# Segment markdown
|
||||
seg_md = seg.get("markdown")
|
||||
if seg_md:
|
||||
parts.append(f"\n```markdown\n{seg_md}\n```")
|
||||
|
||||
# Segment fields
|
||||
seg_fields = seg.get("fields")
|
||||
if isinstance(seg_fields, dict) and seg_fields:
|
||||
fields_json = json.dumps(seg_fields, indent=2, default=str)
|
||||
parts.append(f"\n**Fields:**\n```json\n{fields_json}\n```")
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
# --- Single-segment: flat format ---
|
||||
fields_raw = result.get("fields")
|
||||
fields: dict[str, object] = cast(dict[str, object], fields_raw) if isinstance(fields_raw, dict) else {}
|
||||
|
||||
# For audio: promote Summary field as prose before markdown
|
||||
if is_audio and fields:
|
||||
summary_field = fields.get("Summary")
|
||||
if isinstance(summary_field, dict):
|
||||
sf = cast(dict[str, object], summary_field)
|
||||
if sf.get("value"):
|
||||
parts.append(f"\n## Summary\n\n{sf['value']}")
|
||||
|
||||
# Markdown content
|
||||
markdown = result.get("markdown")
|
||||
if markdown:
|
||||
parts.append(f"\n## Content\n\n```markdown\n{markdown}\n```")
|
||||
|
||||
# Fields section
|
||||
if fields:
|
||||
remaining = dict(fields)
|
||||
if is_audio:
|
||||
remaining = {k: v for k, v in remaining.items() if k != "Summary"}
|
||||
if remaining:
|
||||
fields_json = json.dumps(remaining, indent=2, default=str)
|
||||
parts.append(f"\n## Extracted Fields\n\n```json\n{fields_json}\n```")
|
||||
|
||||
return "\n".join(parts)
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""File search backend abstraction for vector store file operations.
|
||||
|
||||
Provides a unified interface for uploading CU-extracted content to
|
||||
vector stores across different LLM clients. Two implementations:
|
||||
|
||||
- ``OpenAIFileSearchBackend`` — for ``OpenAIChatClient`` (Responses API)
|
||||
- ``FoundryFileSearchBackend`` — for ``FoundryChatClient`` (Responses API via Azure)
|
||||
|
||||
Both share the same OpenAI-compatible vector store file API but differ
|
||||
in the file upload ``purpose`` value.
|
||||
|
||||
Vector store creation, tool construction, and lifecycle management are
|
||||
the caller's responsibility — the backend only handles file upload/delete.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
|
||||
class FileSearchBackend(ABC):
|
||||
"""Abstract interface for vector store file operations.
|
||||
|
||||
Implementations handle the differences between OpenAI and Foundry
|
||||
file upload APIs (e.g., different ``purpose`` values).
|
||||
|
||||
Vector store creation, deletion, and ``file_search`` tool construction
|
||||
are **not** part of this interface — those are managed by the caller.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def upload_file(self, vector_store_id: str, filename: str, content: bytes) -> str:
|
||||
"""Upload a file to a vector store and return the file ID."""
|
||||
|
||||
@abstractmethod
|
||||
async def delete_file(self, file_id: str) -> None:
|
||||
"""Delete a previously uploaded file by ID."""
|
||||
|
||||
|
||||
class _OpenAICompatBackend(FileSearchBackend):
|
||||
"""Shared base for OpenAI-compatible file upload backends.
|
||||
|
||||
Both OpenAI and Foundry use the same ``client.files.*`` and
|
||||
``client.vector_stores.files.*`` API surface. Subclasses only
|
||||
override the file upload ``purpose``.
|
||||
"""
|
||||
|
||||
_FILE_PURPOSE: str # Subclasses must set this
|
||||
|
||||
def __init__(self, client: Any) -> None:
|
||||
self._client = client
|
||||
|
||||
async def upload_file(self, vector_store_id: str, filename: str, content: bytes) -> str:
|
||||
uploaded = await self._client.files.create(
|
||||
file=(filename, io.BytesIO(content)),
|
||||
purpose=self._FILE_PURPOSE,
|
||||
)
|
||||
# Use create_and_poll to wait for indexing to complete before returning.
|
||||
# Without this, file_search queries may return no results immediately
|
||||
# after upload because the vector store index isn't ready yet.
|
||||
await self._client.vector_stores.files.create_and_poll(
|
||||
vector_store_id=vector_store_id,
|
||||
file_id=uploaded.id,
|
||||
)
|
||||
return uploaded.id # type: ignore[no-any-return]
|
||||
|
||||
async def delete_file(self, file_id: str) -> None:
|
||||
await self._client.files.delete(file_id)
|
||||
|
||||
|
||||
class OpenAIFileSearchBackend(_OpenAICompatBackend):
|
||||
"""File search backend for OpenAI Responses API.
|
||||
|
||||
Use with ``OpenAIChatClient`` or ``AzureOpenAIResponsesClient``.
|
||||
Requires an ``AsyncOpenAI`` or ``AsyncAzureOpenAI`` client.
|
||||
|
||||
Args:
|
||||
client: An async OpenAI client (``AsyncOpenAI`` or ``AsyncAzureOpenAI``)
|
||||
that supports ``client.files.*`` and ``client.vector_stores.*`` APIs.
|
||||
"""
|
||||
|
||||
_FILE_PURPOSE = "user_data"
|
||||
|
||||
|
||||
class FoundryFileSearchBackend(_OpenAICompatBackend):
|
||||
"""File search backend for Azure AI Foundry.
|
||||
|
||||
Use with ``FoundryChatClient``. Requires the OpenAI-compatible client
|
||||
obtained from ``FoundryChatClient.client`` (i.e.,
|
||||
``project_client.get_openai_client()``).
|
||||
|
||||
Args:
|
||||
client: The OpenAI-compatible async client from a ``FoundryChatClient``
|
||||
(access via ``foundry_client.client``).
|
||||
"""
|
||||
|
||||
_FILE_PURPOSE = "assistants"
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Any, Literal, TypedDict
|
||||
|
||||
from ._file_search import FileSearchBackend, FoundryFileSearchBackend, OpenAIFileSearchBackend
|
||||
|
||||
|
||||
class DocumentStatus(str, Enum):
|
||||
"""Analysis lifecycle state of a tracked document."""
|
||||
|
||||
ANALYZING = "analyzing"
|
||||
"""CU analysis is in progress (deferred to background)."""
|
||||
|
||||
UPLOADING = "uploading"
|
||||
"""Analysis complete; vector store upload + indexing is in progress."""
|
||||
|
||||
READY = "ready"
|
||||
"""Analysis (and upload, if applicable) completed successfully."""
|
||||
|
||||
FAILED = "failed"
|
||||
"""Analysis or upload failed."""
|
||||
|
||||
|
||||
AnalysisSection = Literal["markdown", "fields"]
|
||||
"""Which sections of the CU output to pass to the LLM.
|
||||
|
||||
- ``"markdown"``: Full document text with tables as HTML, reading order preserved.
|
||||
- ``"fields"``: Extracted typed fields with confidence scores (when available).
|
||||
"""
|
||||
|
||||
|
||||
class DocumentEntry(TypedDict):
|
||||
"""Tracks the analysis state of a single document in session state."""
|
||||
|
||||
status: DocumentStatus
|
||||
filename: str
|
||||
media_type: str
|
||||
analyzer_id: str
|
||||
analyzed_at: str | None
|
||||
analysis_duration_s: float | None
|
||||
upload_duration_s: float | None
|
||||
result: dict[str, object] | None
|
||||
error: str | None
|
||||
|
||||
|
||||
@dataclass
|
||||
class FileSearchConfig:
|
||||
"""Configuration for uploading CU-extracted content to an existing vector store.
|
||||
|
||||
When provided to ``ContentUnderstandingContextProvider``, analyzed document
|
||||
markdown is automatically uploaded to the specified vector store and the
|
||||
given ``file_search`` tool is registered on the context. This enables
|
||||
token-efficient RAG retrieval on follow-up turns for large documents.
|
||||
|
||||
The caller is responsible for creating and managing the vector store and
|
||||
the ``file_search`` tool. Use :meth:`from_openai` or :meth:`from_foundry`
|
||||
factory methods for convenience.
|
||||
|
||||
Args:
|
||||
backend: A ``FileSearchBackend`` that handles file upload/delete
|
||||
operations for the target vector store.
|
||||
vector_store_id: The ID of a pre-existing vector store to upload to.
|
||||
file_search_tool: A ``file_search`` tool object created via the LLM
|
||||
client's ``get_file_search_tool()`` factory method. This is
|
||||
registered on the context via ``extend_tools`` so the LLM can
|
||||
retrieve uploaded content.
|
||||
"""
|
||||
|
||||
backend: FileSearchBackend
|
||||
vector_store_id: str
|
||||
file_search_tool: Any
|
||||
|
||||
@staticmethod
|
||||
def from_openai(
|
||||
client: Any,
|
||||
*,
|
||||
vector_store_id: str,
|
||||
file_search_tool: Any,
|
||||
) -> FileSearchConfig:
|
||||
"""Create a config for OpenAI Responses API (``OpenAIChatClient``).
|
||||
|
||||
Args:
|
||||
client: An ``AsyncOpenAI`` or ``AsyncAzureOpenAI`` client.
|
||||
vector_store_id: The ID of the vector store to upload to.
|
||||
file_search_tool: Tool from ``OpenAIChatClient.get_file_search_tool()``.
|
||||
"""
|
||||
return FileSearchConfig(
|
||||
backend=OpenAIFileSearchBackend(client),
|
||||
vector_store_id=vector_store_id,
|
||||
file_search_tool=file_search_tool,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def from_foundry(
|
||||
client: Any,
|
||||
*,
|
||||
vector_store_id: str,
|
||||
file_search_tool: Any,
|
||||
) -> FileSearchConfig:
|
||||
"""Create a config for Azure AI Foundry (``FoundryChatClient``).
|
||||
|
||||
Args:
|
||||
client: The OpenAI-compatible client from ``FoundryChatClient.client``.
|
||||
vector_store_id: The ID of the vector store to upload to.
|
||||
file_search_tool: Tool from ``FoundryChatClient.get_file_search_tool()``.
|
||||
"""
|
||||
return FileSearchConfig(
|
||||
backend=FoundryFileSearchBackend(client),
|
||||
vector_store_id=vector_store_id,
|
||||
file_search_tool=file_search_tool,
|
||||
)
|
||||
@@ -0,0 +1,101 @@
|
||||
[project]
|
||||
name = "agent-framework-azure-contentunderstanding"
|
||||
description = "Azure Content Understanding integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com" }]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0a260429"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true"
|
||||
urls.issues = "https://github.com/microsoft/agent-framework/issues"
|
||||
classifiers = [
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Development Status :: 3 - Alpha",
|
||||
"Intended Audience :: Developers",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Programming Language :: Python :: 3.14",
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.2.2,<2",
|
||||
"agent-framework-foundry>=1.2.2,<2",
|
||||
"azure-ai-contentunderstanding>=1.0.1,<1.1",
|
||||
"aiohttp>=3.9,<4",
|
||||
"filetype>=1.2,<2",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
prerelease = "if-necessary-or-explicit"
|
||||
environments = [
|
||||
"sys_platform == 'darwin'",
|
||||
"sys_platform == 'linux'",
|
||||
"sys_platform == 'win32'"
|
||||
]
|
||||
|
||||
[tool.uv-dynamic-versioning]
|
||||
fallback-version = "0.0.0"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = 'tests'
|
||||
addopts = "-ra -q -r fEX"
|
||||
asyncio_mode = "auto"
|
||||
asyncio_default_fixture_loop_scope = "function"
|
||||
timeout = 120
|
||||
markers = [
|
||||
"integration: marks tests as integration tests that require external services",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
extend = "../../pyproject.toml"
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"**/tests/**" = ["D", "INP", "TD", "ERA001", "RUF", "S"]
|
||||
"samples/**" = ["D", "INP", "ERA001", "RUF", "S", "T201", "CPY"]
|
||||
|
||||
[tool.coverage.run]
|
||||
omit = ["**/__init__.py"]
|
||||
|
||||
[tool.pyright]
|
||||
extends = "../../pyproject.toml"
|
||||
include = ["agent_framework_azure_contentunderstanding"]
|
||||
exclude = ['tests']
|
||||
|
||||
[tool.mypy]
|
||||
plugins = ['pydantic.mypy']
|
||||
strict = true
|
||||
python_version = "3.10"
|
||||
ignore_missing_imports = true
|
||||
disallow_untyped_defs = true
|
||||
no_implicit_optional = true
|
||||
check_untyped_defs = true
|
||||
warn_return_any = true
|
||||
show_error_codes = true
|
||||
warn_unused_ignores = false
|
||||
disallow_incomplete_defs = true
|
||||
disallow_untyped_decorators = true
|
||||
|
||||
[tool.bandit]
|
||||
targets = ["agent_framework_azure_contentunderstanding"]
|
||||
exclude_dirs = ["tests"]
|
||||
|
||||
[tool.poe]
|
||||
executor.type = "uv"
|
||||
include = "../../shared_tasks.toml"
|
||||
|
||||
[tool.poe.tasks.mypy]
|
||||
help = "Run MyPy for this package."
|
||||
cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_azure_contentunderstanding"
|
||||
|
||||
[tool.poe.tasks.test]
|
||||
help = "Run the default unit test suite for this package."
|
||||
cmd = 'pytest -m "not integration" --cov=agent_framework_azure_contentunderstanding --cov-report=term-missing:skip-covered tests'
|
||||
|
||||
[build-system]
|
||||
requires = ["flit-core >= 3.11,<4.0"]
|
||||
build-backend = "flit_core.buildapi"
|
||||
@@ -0,0 +1,117 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = [
|
||||
# "agent-framework-azure-contentunderstanding",
|
||||
# "agent-framework-foundry",
|
||||
# "azure-identity",
|
||||
# ]
|
||||
# ///
|
||||
# Run with: uv run packages/azure-contentunderstanding/samples/01-get-started/01_document_qa.py
|
||||
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from agent_framework import Agent, Content, Message
|
||||
from agent_framework.foundry import ContentUnderstandingContextProvider, FoundryChatClient
|
||||
from azure.identity import AzureCliCredential
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
"""
|
||||
Document Q&A — PDF upload with CU-powered extraction
|
||||
|
||||
This sample demonstrates the simplest CU integration: upload a PDF and
|
||||
ask questions about it. Azure Content Understanding extracts structured
|
||||
markdown with table preservation — superior to LLM-only vision for
|
||||
scanned PDFs, handwritten content, and complex layouts.
|
||||
|
||||
Environment variables:
|
||||
FOUNDRY_PROJECT_ENDPOINT — Azure AI Foundry project endpoint
|
||||
FOUNDRY_MODEL — Model deployment name (e.g. gpt-4.1)
|
||||
AZURE_CONTENTUNDERSTANDING_ENDPOINT — CU endpoint URL
|
||||
"""
|
||||
|
||||
# Path to a sample PDF — uses the shared sample asset if available,
|
||||
# otherwise falls back to a public URL
|
||||
SAMPLE_PDF_PATH = Path(__file__).resolve().parents[1] / "shared" / "sample_assets" / "invoice.pdf"
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
credential = AzureCliCredential()
|
||||
|
||||
# Set up Azure Content Understanding context provider
|
||||
cu = ContentUnderstandingContextProvider(
|
||||
endpoint=os.environ["AZURE_CONTENTUNDERSTANDING_ENDPOINT"],
|
||||
credential=credential,
|
||||
analyzer_id="prebuilt-documentSearch", # RAG-optimized document analyzer
|
||||
max_wait=None, # wait until CU analysis finishes (no background deferral)
|
||||
)
|
||||
|
||||
# Set up the LLM client
|
||||
client = FoundryChatClient(
|
||||
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
|
||||
model=os.environ["FOUNDRY_MODEL"],
|
||||
credential=credential,
|
||||
)
|
||||
|
||||
# Create agent with CU context provider.
|
||||
# The provider extracts document content via CU and injects it into the
|
||||
# LLM context so the agent can answer questions about the document.
|
||||
async with cu:
|
||||
agent = Agent(
|
||||
client=client,
|
||||
name="DocumentQA",
|
||||
instructions=(
|
||||
"You are a helpful document analyst. Use the analyzed document "
|
||||
"content and extracted fields to answer questions precisely."
|
||||
),
|
||||
context_providers=[cu],
|
||||
)
|
||||
|
||||
# --- Turn 1: Upload PDF and ask a question ---
|
||||
# 4. Upload PDF and ask questions
|
||||
# The CU provider extracts markdown + fields from the PDF and injects
|
||||
# the full content into context so the agent can answer precisely.
|
||||
print("--- Upload PDF and ask questions ---")
|
||||
|
||||
pdf_bytes = SAMPLE_PDF_PATH.read_bytes()
|
||||
|
||||
response = await agent.run(
|
||||
Message(
|
||||
role="user",
|
||||
contents=[
|
||||
Content.from_text(
|
||||
"What is this document about? Who is the vendor, and what is the total amount due?"
|
||||
),
|
||||
Content.from_data(
|
||||
pdf_bytes,
|
||||
"application/pdf",
|
||||
# Always provide filename — used as the document key
|
||||
additional_properties={"filename": SAMPLE_PDF_PATH.name},
|
||||
),
|
||||
],
|
||||
)
|
||||
)
|
||||
usage = response.usage_details or {}
|
||||
print(f"Agent: {response}")
|
||||
print(f" [Input tokens: {usage.get('input_token_count', 'N/A')}]\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
|
||||
"""
|
||||
Sample output:
|
||||
|
||||
--- Upload PDF and ask questions ---
|
||||
Agent: This document is an **invoice** for services and fees billed to
|
||||
**MICROSOFT CORPORATION** (Invoice **INV-100**), including line items
|
||||
(e.g., Consulting Services, Document Fee, Printing Fee) and a billing summary.
|
||||
- **Vendor:** **CONTOSO LTD.**
|
||||
- **Total amount due:** **$610.00**
|
||||
[Input tokens: 988]
|
||||
"""
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = [
|
||||
# "agent-framework-azure-contentunderstanding",
|
||||
# "agent-framework-foundry",
|
||||
# "azure-identity",
|
||||
# ]
|
||||
# ///
|
||||
# Run with: uv run packages/azure-contentunderstanding/samples/01-get-started/02_multi_turn_session.py
|
||||
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from agent_framework import Agent, AgentSession, Content, Message
|
||||
from agent_framework.foundry import ContentUnderstandingContextProvider, FoundryChatClient
|
||||
from azure.identity import AzureCliCredential
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
"""
|
||||
Multi-Turn Session — Cached results across turns
|
||||
|
||||
This sample demonstrates multi-turn document Q&A using an AgentSession.
|
||||
The session persists CU analysis results and conversation history across
|
||||
turns so the agent can answer follow-up questions about previously
|
||||
uploaded documents without re-analyzing them.
|
||||
|
||||
Key concepts:
|
||||
- AgentSession keeps CU state and conversation history across agent.run() calls
|
||||
- Turn 1: CU analyzes the PDF and injects full content into context
|
||||
- Turn 2: Unrelated question — agent answers from general knowledge
|
||||
- Turn 3: Detailed question — agent uses document content from conversation
|
||||
history (injected in Turn 1) to answer precisely
|
||||
|
||||
Environment variables:
|
||||
FOUNDRY_PROJECT_ENDPOINT — Azure AI Foundry project endpoint
|
||||
FOUNDRY_MODEL — Model deployment name (e.g. gpt-4.1)
|
||||
AZURE_CONTENTUNDERSTANDING_ENDPOINT — CU endpoint URL
|
||||
"""
|
||||
|
||||
SAMPLE_PDF_PATH = Path(__file__).resolve().parents[1] / "shared" / "sample_assets" / "invoice.pdf"
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
# 1. Set up credentials and CU context provider
|
||||
credential = AzureCliCredential()
|
||||
|
||||
cu = ContentUnderstandingContextProvider(
|
||||
endpoint=os.environ["AZURE_CONTENTUNDERSTANDING_ENDPOINT"],
|
||||
credential=credential,
|
||||
analyzer_id="prebuilt-documentSearch",
|
||||
max_wait=None, # wait until CU analysis finishes (no background deferral)
|
||||
)
|
||||
|
||||
# 2. Set up the LLM client
|
||||
client = FoundryChatClient(
|
||||
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
|
||||
model=os.environ["FOUNDRY_MODEL"],
|
||||
credential=credential,
|
||||
)
|
||||
|
||||
# 3. Create agent and persistent session
|
||||
async with cu:
|
||||
agent = Agent(
|
||||
client=client,
|
||||
name="DocumentQA",
|
||||
instructions=(
|
||||
"You are a helpful document analyst. Use the analyzed document "
|
||||
"content and extracted fields to answer questions precisely."
|
||||
),
|
||||
context_providers=[cu],
|
||||
)
|
||||
|
||||
# Create a persistent session — this keeps CU state across turns
|
||||
session = AgentSession()
|
||||
|
||||
# 4. Turn 1: Upload PDF
|
||||
# CU analyzes the PDF and injects full content into context.
|
||||
print("--- Turn 1: Upload PDF ---")
|
||||
pdf_bytes = SAMPLE_PDF_PATH.read_bytes()
|
||||
response = await agent.run(
|
||||
Message(
|
||||
role="user",
|
||||
contents=[
|
||||
Content.from_text("What is this document about?"),
|
||||
Content.from_data(
|
||||
pdf_bytes,
|
||||
"application/pdf",
|
||||
additional_properties={"filename": SAMPLE_PDF_PATH.name},
|
||||
),
|
||||
],
|
||||
),
|
||||
session=session, # <-- persist state across turns
|
||||
)
|
||||
usage = response.usage_details or {}
|
||||
print(f"Agent: {response}")
|
||||
print(f" [Input tokens: {usage.get('input_token_count', 'N/A')}]\n")
|
||||
|
||||
# 5. Turn 2: Unrelated question
|
||||
# No document needed — agent answers from general knowledge.
|
||||
print("--- Turn 2: Unrelated question ---")
|
||||
response = await agent.run("What is the capital of France?", session=session)
|
||||
usage = response.usage_details or {}
|
||||
print(f"Agent: {response}")
|
||||
print(f" [Input tokens: {usage.get('input_token_count', 'N/A')}]\n")
|
||||
|
||||
# 6. Turn 3: Detailed follow-up
|
||||
# The agent answers from the full document content that was injected
|
||||
# into conversation history in Turn 1. No re-analysis or tool call needed.
|
||||
print("--- Turn 3: Detailed follow-up ---")
|
||||
response = await agent.run(
|
||||
"What is the shipping address on the invoice?",
|
||||
session=session,
|
||||
)
|
||||
usage = response.usage_details or {}
|
||||
print(f"Agent: {response}")
|
||||
print(f" [Input tokens: {usage.get('input_token_count', 'N/A')}]\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
|
||||
"""
|
||||
Sample output:
|
||||
|
||||
--- Turn 1: Upload PDF ---
|
||||
Agent: This document is an **invoice** from **CONTOSO LTD.** to **MICROSOFT
|
||||
CORPORATION**. Amount Due: $610.00. Invoice INV-100, dated 11/15/2019.
|
||||
[Input tokens: 975]
|
||||
|
||||
--- Turn 2: Unrelated question ---
|
||||
Agent: Paris.
|
||||
[Input tokens: 1134]
|
||||
|
||||
--- Turn 3: Detailed follow-up ---
|
||||
Agent: Shipping address (SHIP TO): Microsoft Delivery, 123 Ship St,
|
||||
Redmond WA, 98052.
|
||||
[Input tokens: 1155]
|
||||
"""
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = [
|
||||
# "agent-framework-azure-contentunderstanding",
|
||||
# "agent-framework-foundry",
|
||||
# "azure-identity",
|
||||
# ]
|
||||
# ///
|
||||
# Run with: uv run packages/azure-contentunderstanding/samples/01-get-started/03_multimodal_chat.py
|
||||
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from agent_framework import Agent, AgentSession, Content, Message
|
||||
from agent_framework.foundry import ContentUnderstandingContextProvider, FoundryChatClient
|
||||
from azure.identity import AzureCliCredential
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
"""
|
||||
Multi-Modal Chat — PDF, audio, and video in a single turn
|
||||
|
||||
This sample demonstrates CU's multi-modal capability: upload a PDF invoice,
|
||||
an audio call recording, and a video file all at once. The provider analyzes
|
||||
all three in parallel using the right CU analyzer for each media type.
|
||||
|
||||
The provider auto-detects the media type and selects the right CU analyzer:
|
||||
- PDF/images → prebuilt-documentSearch
|
||||
- Audio → prebuilt-audioSearch
|
||||
- Video → prebuilt-videoSearch
|
||||
|
||||
Environment variables:
|
||||
FOUNDRY_PROJECT_ENDPOINT — Azure AI Foundry project endpoint
|
||||
FOUNDRY_MODEL — Model deployment name (e.g. gpt-4.1)
|
||||
AZURE_CONTENTUNDERSTANDING_ENDPOINT — CU endpoint URL
|
||||
"""
|
||||
|
||||
# Local PDF from package assets
|
||||
SAMPLE_PDF = Path(__file__).resolve().parents[1] / "shared" / "sample_assets" / "invoice.pdf"
|
||||
|
||||
# Public audio/video from Azure CU samples repo (raw GitHub URLs)
|
||||
_CU_ASSETS = "https://raw.githubusercontent.com/Azure-Samples/azure-ai-content-understanding-assets/main"
|
||||
AUDIO_URL = f"{_CU_ASSETS}/audio/callCenterRecording.mp3"
|
||||
VIDEO_URL = f"{_CU_ASSETS}/videos/sdk_samples/FlightSimulator.mp4"
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
# 1. Set up credentials and CU context provider
|
||||
credential = AzureCliCredential()
|
||||
|
||||
# No analyzer_id specified — the provider auto-detects from media type:
|
||||
# PDF/images → prebuilt-documentSearch
|
||||
# Audio → prebuilt-audioSearch
|
||||
# Video → prebuilt-videoSearch
|
||||
cu = ContentUnderstandingContextProvider(
|
||||
endpoint=os.environ["AZURE_CONTENTUNDERSTANDING_ENDPOINT"],
|
||||
credential=credential,
|
||||
max_wait=None, # wait until each analysis finishes
|
||||
)
|
||||
|
||||
# 2. Set up the LLM client
|
||||
client = FoundryChatClient(
|
||||
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
|
||||
model=os.environ["FOUNDRY_MODEL"],
|
||||
credential=credential,
|
||||
)
|
||||
|
||||
# 3. Create agent and session
|
||||
async with cu:
|
||||
agent = Agent(
|
||||
client=client,
|
||||
name="MultiModalAgent",
|
||||
instructions=(
|
||||
"You are a helpful assistant that can analyze documents, audio, "
|
||||
"and video files. Answer questions using the extracted content."
|
||||
),
|
||||
context_providers=[cu],
|
||||
)
|
||||
|
||||
session = AgentSession()
|
||||
|
||||
# --- Turn 1: Upload all 3 modalities at once ---
|
||||
# The provider analyzes all files in parallel using the appropriate
|
||||
# CU analyzer for each media type. All results are injected into
|
||||
# the same context so the agent can answer about all of them.
|
||||
turn1_prompt = (
|
||||
"I'm uploading three files: an invoice PDF, a call center "
|
||||
"audio recording, and a flight simulator video. "
|
||||
"Give a brief summary of each file."
|
||||
)
|
||||
print("--- Turn 1: Upload PDF + audio + video (parallel analysis) ---")
|
||||
print(" (CU analysis may take a few minutes for these audio/video files...)")
|
||||
print(f"User: {turn1_prompt}")
|
||||
t0 = time.perf_counter()
|
||||
response = await agent.run(
|
||||
Message(
|
||||
role="user",
|
||||
contents=[
|
||||
Content.from_text(turn1_prompt),
|
||||
Content.from_data(
|
||||
SAMPLE_PDF.read_bytes(),
|
||||
"application/pdf",
|
||||
additional_properties={"filename": "invoice.pdf"},
|
||||
),
|
||||
Content.from_uri(
|
||||
AUDIO_URL,
|
||||
media_type="audio/mp3",
|
||||
additional_properties={"filename": "callCenterRecording.mp3"},
|
||||
),
|
||||
Content.from_uri(
|
||||
VIDEO_URL,
|
||||
media_type="video/mp4",
|
||||
additional_properties={"filename": "FlightSimulator.mp4"},
|
||||
),
|
||||
],
|
||||
),
|
||||
session=session,
|
||||
)
|
||||
elapsed = time.perf_counter() - t0
|
||||
usage = response.usage_details or {}
|
||||
print(f" [Analyzed in {elapsed:.1f}s | Input tokens: {usage.get('input_token_count', 'N/A')}]")
|
||||
print(f"Agent: {response}\n")
|
||||
|
||||
# --- Turn 2: Detail question about the PDF ---
|
||||
turn2_prompt = "What are the line items and their amounts on the invoice?"
|
||||
print("--- Turn 2: PDF detail ---")
|
||||
print(f"User: {turn2_prompt}")
|
||||
response = await agent.run(turn2_prompt, session=session)
|
||||
usage = response.usage_details or {}
|
||||
print(f" [Input tokens: {usage.get('input_token_count', 'N/A')}]")
|
||||
print(f"Agent: {response}\n")
|
||||
|
||||
# --- Turn 3: Detail question about the audio ---
|
||||
turn3_prompt = "What was the customer's issue in the call recording?"
|
||||
print("--- Turn 3: Audio detail ---")
|
||||
print(f"User: {turn3_prompt}")
|
||||
response = await agent.run(turn3_prompt, session=session)
|
||||
usage = response.usage_details or {}
|
||||
print(f" [Input tokens: {usage.get('input_token_count', 'N/A')}]")
|
||||
print(f"Agent: {response}\n")
|
||||
|
||||
# --- Turn 4: Detail question about the video ---
|
||||
turn4_prompt = "What key scenes or actions are shown in the flight simulator video?"
|
||||
print("--- Turn 4: Video detail ---")
|
||||
print(f"User: {turn4_prompt}")
|
||||
response = await agent.run(turn4_prompt, session=session)
|
||||
usage = response.usage_details or {}
|
||||
print(f" [Input tokens: {usage.get('input_token_count', 'N/A')}]")
|
||||
print(f"Agent: {response}\n")
|
||||
|
||||
# --- Turn 5: Cross-document question ---
|
||||
turn5_prompt = (
|
||||
"Across all three files, which one contains financial data, "
|
||||
"which one involves a customer interaction, and which one is "
|
||||
"a visual demonstration?"
|
||||
)
|
||||
print("--- Turn 5: Cross-document question ---")
|
||||
print(f"User: {turn5_prompt}")
|
||||
response = await agent.run(turn5_prompt, session=session)
|
||||
usage = response.usage_details or {}
|
||||
print(f" [Input tokens: {usage.get('input_token_count', 'N/A')}]")
|
||||
print(f"Agent: {response}\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
|
||||
"""
|
||||
Sample output:
|
||||
|
||||
--- Turn 1: Upload PDF + audio + video (parallel analysis) ---
|
||||
User: I'm uploading three files...
|
||||
(CU analysis may take 1-2 minutes for audio/video files...)
|
||||
[Analyzed in ~94s | Input tokens: ~2939]
|
||||
Agent: ### invoice.pdf: An invoice from CONTOSO LTD. to MICROSOFT CORPORATION...
|
||||
### callCenterRecording.mp3: A customer service call about point balance...
|
||||
### FlightSimulator.mp4: A clip discussing neural text-to-speech...
|
||||
|
||||
--- Turn 2-5: Detail and cross-document questions ---
|
||||
(Agent answers from conversation history without re-analysis)
|
||||
"""
|
||||
+193
@@ -0,0 +1,193 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = [
|
||||
# "agent-framework-azure-contentunderstanding",
|
||||
# "agent-framework-foundry",
|
||||
# "azure-identity",
|
||||
# "pydantic",
|
||||
# ]
|
||||
# ///
|
||||
# Run with: uv run packages/azure-contentunderstanding/samples/01-get-started/04_invoice_processing.py
|
||||
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from agent_framework import Agent, AgentSession, Content, Message
|
||||
from agent_framework.foundry import ContentUnderstandingContextProvider, FoundryChatClient
|
||||
from azure.identity import AzureCliCredential
|
||||
from dotenv import load_dotenv
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
load_dotenv()
|
||||
|
||||
"""
|
||||
Invoice Processing — Structured output with prebuilt-invoice analyzer
|
||||
|
||||
This sample demonstrates CU's structured field extraction combined with
|
||||
LLM structured output (Pydantic model). The prebuilt-invoice analyzer extracts
|
||||
typed fields (VendorName, InvoiceTotal, DueDate, LineItems, etc.) with
|
||||
confidence scores. We use output_sections=["fields"] only (no markdown needed)
|
||||
since we want the LLM to produce a structured JSON response from the extracted
|
||||
fields, not summarize document text.
|
||||
|
||||
Environment variables:
|
||||
FOUNDRY_PROJECT_ENDPOINT — Azure AI Foundry project endpoint
|
||||
FOUNDRY_MODEL — Model deployment name (e.g. gpt-4.1)
|
||||
AZURE_CONTENTUNDERSTANDING_ENDPOINT — CU endpoint URL
|
||||
"""
|
||||
|
||||
SAMPLE_PDF_PATH = Path(__file__).resolve().parents[1] / "shared" / "sample_assets" / "invoice.pdf"
|
||||
|
||||
|
||||
# Structured output model — the LLM will return JSON matching this schema
|
||||
# Structured output models — the LLM returns JSON matching this schema.
|
||||
#
|
||||
# Note: the prebuilt-invoice analyzer extracts an extensive set of fields
|
||||
# (VendorName, BillingAddress, ShippingAddress, TaxDetails, PONumber, etc.).
|
||||
# This sample defines a simplified schema to extract only the fields of
|
||||
# interest to the caller. The LLM maps the full CU field output to this
|
||||
# subset automatically.
|
||||
# Learn more about prebuilt analyzers: https://learn.microsoft.com/azure/ai-services/content-understanding/concepts/prebuilt-analyzers
|
||||
|
||||
|
||||
class LineItem(BaseModel):
|
||||
description: str
|
||||
quantity: float | None = None
|
||||
unit_price: float | None = None
|
||||
amount: float | None = None
|
||||
|
||||
|
||||
class LowConfidenceField(BaseModel):
|
||||
field_name: str
|
||||
confidence: float
|
||||
|
||||
|
||||
class InvoiceResult(BaseModel):
|
||||
vendor_name: str
|
||||
total_amount: float | None = None
|
||||
currency: str = "USD"
|
||||
due_date: str | None = None
|
||||
line_items: list[LineItem] = Field(default_factory=list)
|
||||
low_confidence_fields: list[LowConfidenceField] = Field(
|
||||
default_factory=list,
|
||||
description="Fields with confidence < 0.8, including their confidence score",
|
||||
)
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
# 1. Set up credentials and CU context provider
|
||||
credential = AzureCliCredential()
|
||||
|
||||
# Default analyzer is prebuilt-documentSearch (RAG-optimized).
|
||||
# Per-file override via additional_properties["analyzer_id"] lets us
|
||||
# use prebuilt-invoice for structured field extraction on specific files.
|
||||
#
|
||||
# Only request "fields" (not "markdown") — we want the extracted typed
|
||||
# fields for structured output, not the raw document text.
|
||||
cu = ContentUnderstandingContextProvider(
|
||||
endpoint=os.environ["AZURE_CONTENTUNDERSTANDING_ENDPOINT"],
|
||||
credential=credential,
|
||||
analyzer_id="prebuilt-documentSearch", # default for all files
|
||||
max_wait=None, # wait until CU analysis finishes
|
||||
output_sections=["fields"], # fields only — structured output doesn't need markdown
|
||||
)
|
||||
|
||||
# 2. Set up the LLM client
|
||||
client = FoundryChatClient(
|
||||
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
|
||||
model=os.environ["FOUNDRY_MODEL"],
|
||||
credential=credential,
|
||||
)
|
||||
|
||||
# 3. Create agent and session
|
||||
async with cu:
|
||||
agent = Agent(
|
||||
client=client,
|
||||
name="InvoiceProcessor",
|
||||
instructions=(
|
||||
"You are an invoice processing assistant. Extract invoice data from "
|
||||
"the provided CU fields (JSON with confidence scores). Return structured "
|
||||
"output matching the requested schema. Flag fields with confidence < 0.8 "
|
||||
"in the low_confidence_fields list."
|
||||
),
|
||||
context_providers=[cu],
|
||||
)
|
||||
|
||||
session = AgentSession()
|
||||
|
||||
# 4. Upload an invoice PDF — uses structured output (Pydantic model)
|
||||
print("--- Upload Invoice (Structured Output) ---")
|
||||
|
||||
pdf_bytes = SAMPLE_PDF_PATH.read_bytes()
|
||||
|
||||
response = await agent.run(
|
||||
Message(
|
||||
role="user",
|
||||
contents=[
|
||||
Content.from_text(
|
||||
"Process this invoice. Extract the vendor name, total amount, due date, and all line items."
|
||||
),
|
||||
Content.from_data(
|
||||
pdf_bytes,
|
||||
"application/pdf",
|
||||
# Per-file analyzer override: use prebuilt-invoice for
|
||||
# structured field extraction (VendorName, InvoiceTotal, etc.)
|
||||
# instead of the provider default (prebuilt-documentSearch).
|
||||
additional_properties={
|
||||
"filename": SAMPLE_PDF_PATH.name,
|
||||
"analyzer_id": "prebuilt-invoice",
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
session=session,
|
||||
options={"response_format": InvoiceResult},
|
||||
)
|
||||
|
||||
# Parse the structured output from JSON text
|
||||
try:
|
||||
invoice = InvoiceResult.model_validate_json(response.text)
|
||||
print(f"Vendor: {invoice.vendor_name}")
|
||||
print(f"Total: {invoice.currency} {invoice.total_amount}")
|
||||
print(f"Due date: {invoice.due_date}")
|
||||
print(f"Line items ({len(invoice.line_items)}):")
|
||||
for item in invoice.line_items:
|
||||
print(f" - {item.description}: {item.amount}")
|
||||
if invoice.low_confidence_fields:
|
||||
print("⚠ Low confidence fields:")
|
||||
for f in invoice.low_confidence_fields:
|
||||
print(f" - {f.field_name}: {f.confidence:.3f}")
|
||||
except Exception:
|
||||
print(f"Agent (raw): {response.text}\n")
|
||||
|
||||
# 5. Follow-up: free-text question about the invoice
|
||||
print("\n--- Follow-up (Free Text) ---")
|
||||
response = await agent.run(
|
||||
"What is the payment term? Are there any fields with low confidence?",
|
||||
session=session,
|
||||
)
|
||||
print(f"Agent: {response}\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
|
||||
"""
|
||||
Sample output:
|
||||
|
||||
--- Upload Invoice (Structured Output) ---
|
||||
Vendor: CONTOSO LTD.
|
||||
Total: USD 110.0
|
||||
Due date: 2019-12-15
|
||||
Line items (3):
|
||||
- Consulting Services: 60.0
|
||||
- Document Fee: 30.0
|
||||
- Printing Fee: 10.0
|
||||
⚠ Low confidence: VendorName, CustomerName
|
||||
|
||||
--- Follow-up (Free Text) ---
|
||||
Agent: The payment terms are not explicitly stated on the invoice...
|
||||
"""
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user