mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5769c0e8ab | ||
|
|
bf4c0be52b | ||
|
|
a3362e2896 | ||
|
|
28a86d6d73 | ||
|
|
03f7dc86d3 |
@@ -336,53 +336,6 @@ 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
|
||||
@@ -449,7 +402,6 @@ jobs:
|
||||
python-tests-misc-integration,
|
||||
python-tests-functions,
|
||||
python-tests-foundry,
|
||||
python-tests-foundry-hosting,
|
||||
python-tests-cosmos,
|
||||
]
|
||||
runs-on: ubuntu-latest
|
||||
@@ -513,7 +465,6 @@ jobs:
|
||||
python-tests-misc-integration,
|
||||
python-tests-functions,
|
||||
python-tests-foundry,
|
||||
python-tests-foundry-hosting,
|
||||
python-tests-cosmos
|
||||
]
|
||||
steps:
|
||||
|
||||
@@ -38,7 +38,6 @@ 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
|
||||
@@ -81,8 +80,6 @@ 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
|
||||
@@ -491,67 +488,6 @@ 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
|
||||
@@ -633,7 +569,6 @@ jobs:
|
||||
python-tests-misc-integration,
|
||||
python-tests-functions,
|
||||
python-tests-foundry,
|
||||
python-tests-foundry-hosting,
|
||||
python-tests-cosmos,
|
||||
]
|
||||
runs-on: ubuntu-latest
|
||||
@@ -694,7 +629,6 @@ jobs:
|
||||
python-tests-misc-integration,
|
||||
python-tests-functions,
|
||||
python-tests-foundry,
|
||||
python-tests-foundry-hosting,
|
||||
python-tests-cosmos,
|
||||
]
|
||||
steps:
|
||||
|
||||
@@ -242,7 +242,3 @@ 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.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.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.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.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" />
|
||||
<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" />
|
||||
<!-- 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,13 +160,12 @@
|
||||
<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" />
|
||||
<Project Path="samples/02-agents/Evaluation/Evaluation_CustomEvals/Evaluation_CustomEvals.csproj" />
|
||||
<Project Path="samples/02-agents/Evaluation/Evaluation_ExpectedOutputs/Evaluation_ExpectedOutputs.csproj" />
|
||||
<Project Path="samples/02-agents/Evaluation/Evaluation_Multimodal/Evaluation_Multimodal.csproj" />
|
||||
<Project Path="samples/02-agents/Evaluation/Evaluation_SimpleEval/Evaluation_SimpleEval.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/02-agents/AgentWithMemory/">
|
||||
<File Path="samples/02-agents/AgentWithMemory/README.md" />
|
||||
@@ -226,7 +225,6 @@
|
||||
<Project Path="samples/03-workflows/Declarative/HostedWorkflow/HostedWorkflow.csproj" />
|
||||
<Project Path="samples/03-workflows/Declarative/InputArguments/InputArguments.csproj" />
|
||||
<Project Path="samples/03-workflows/Declarative/InvokeFunctionTool/InvokeFunctionTool.csproj" />
|
||||
<Project Path="samples/03-workflows/Declarative/InvokeHttpRequest/InvokeHttpRequest.csproj" />
|
||||
<Project Path="samples/03-workflows/Declarative/InvokeMcpTool/InvokeMcpTool.csproj" />
|
||||
<Project Path="samples/03-workflows/Declarative/Marketing/Marketing.csproj" />
|
||||
<Project Path="samples/03-workflows/Declarative/StudentTeacher/StudentTeacher.csproj" />
|
||||
@@ -348,17 +346,17 @@
|
||||
<File Path="samples/02-agents/A2A/README.md" />
|
||||
<Project Path="samples/02-agents/A2A/A2AAgent_AsFunctionTools/A2AAgent_AsFunctionTools.csproj" />
|
||||
<Project Path="samples/02-agents/A2A/A2AAgent_PollingForTaskCompletion/A2AAgent_PollingForTaskCompletion.csproj" />
|
||||
<Project Path="samples/02-agents/A2A/A2AAgent_ProtocolSelection/A2AAgent_ProtocolSelection.csproj" />
|
||||
<Project Path="samples/02-agents/A2A/A2AAgent_StreamReconnection/A2AAgent_StreamReconnection.csproj" />
|
||||
<Project Path="samples/02-agents/A2A/A2AAgent_ProtocolSelection/A2AAgent_ProtocolSelection.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/05-end-to-end/">
|
||||
<Project Path="samples/05-end-to-end/AgentWithPurview/AgentWithPurview.csproj" />
|
||||
<Project Path="samples/05-end-to-end/M365Agent/M365Agent.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/05-end-to-end/Evaluation/">
|
||||
<Project Path="samples/05-end-to-end/Evaluation/Evaluation_ConversationSplits/Evaluation_ConversationSplits.csproj" />
|
||||
<Project Path="samples/05-end-to-end/Evaluation/Evaluation_FoundryQuality/Evaluation_FoundryQuality.csproj" />
|
||||
<Project Path="samples/05-end-to-end/Evaluation/Evaluation_MixedProviders/Evaluation_MixedProviders.csproj" />
|
||||
<Project Path="samples/05-end-to-end/Evaluation/Evaluation_ConversationSplits/Evaluation_ConversationSplits.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/05-end-to-end/A2AClientServer/">
|
||||
<File Path="samples/05-end-to-end/A2AClientServer/README.md" />
|
||||
@@ -544,8 +542,8 @@
|
||||
<Project Path="src/Microsoft.Agents.AI.Declarative/Microsoft.Agents.AI.Declarative.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.DevUI/Microsoft.Agents.AI.DevUI.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.DurableTask/Microsoft.Agents.AI.DurableTask.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Foundry.Hosting/Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Foundry/Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Foundry.Hosting/Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.GitHub.Copilot/Microsoft.Agents.AI.GitHub.Copilot.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Hosting.A2A/Microsoft.Agents.AI.Hosting.A2A.csproj" />
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<!-- Central version prefix - applies to all nuget packages. -->
|
||||
<VersionPrefix>1.3.0</VersionPrefix>
|
||||
<VersionPrefix>1.2.0</VersionPrefix>
|
||||
<RCNumber>1</RCNumber>
|
||||
<DateSuffix>260423</DateSuffix>
|
||||
<DateSuffix>260421</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.3.0</GitTag>
|
||||
<GitTag>1.2.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 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.
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
internal static class SubprocessScriptRunner
|
||||
{
|
||||
@@ -24,8 +24,7 @@ internal static class SubprocessScriptRunner
|
||||
public static async Task<object?> RunAsync(
|
||||
AgentFileSkill skill,
|
||||
AgentFileSkillScript script,
|
||||
JsonElement? arguments,
|
||||
IServiceProvider? serviceProvider,
|
||||
AIFunctionArguments arguments,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!File.Exists(script.FullPath))
|
||||
@@ -62,27 +61,24 @@ internal static class SubprocessScriptRunner
|
||||
startInfo.FileName = script.FullPath;
|
||||
}
|
||||
|
||||
if (arguments is { ValueKind: JsonValueKind.Array } json)
|
||||
if (arguments is not null)
|
||||
{
|
||||
// Positional CLI arguments
|
||||
foreach (var element in json.EnumerateArray())
|
||||
foreach (var (key, value) in arguments)
|
||||
{
|
||||
if (element.ValueKind != JsonValueKind.String)
|
||||
if (value is bool boolValue)
|
||||
{
|
||||
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.");
|
||||
if (boolValue)
|
||||
{
|
||||
startInfo.ArgumentList.Add(NormalizeKey(key));
|
||||
}
|
||||
}
|
||||
else if (value is not null)
|
||||
{
|
||||
startInfo.ArgumentList.Add(NormalizeKey(key));
|
||||
startInfo.ArgumentList.Add(value.ToString()!);
|
||||
}
|
||||
|
||||
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
|
||||
@@ -132,4 +128,10 @@ 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
@@ -1,17 +0,0 @@
|
||||
<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
@@ -1,148 +0,0 @@
|
||||
// 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
@@ -1,46 +0,0 @@
|
||||
# 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
|
||||
```
|
||||
@@ -1,38 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<InjectIsExternalInitOnLegacy>true</InjectIsExternalInitOnLegacy>
|
||||
<InjectSharedFoundryAgents>true</InjectSharedFoundryAgents>
|
||||
<InjectSharedWorkflowsExecution>true</InjectSharedWorkflowsExecution>
|
||||
<InjectSharedWorkflowsSettings>true</InjectSharedWorkflowsSettings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Workflows.Declarative\Microsoft.Agents.AI.Workflows.Declarative.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Workflows.Declarative.Foundry\Microsoft.Agents.AI.Workflows.Declarative.Foundry.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="InvokeHttpRequest.yaml">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,76 +0,0 @@
|
||||
#
|
||||
# This workflow demonstrates using HttpRequestAction to call a REST API directly
|
||||
# from the workflow without going through an AI agent first.
|
||||
#
|
||||
# HttpRequestAction allows workflows to:
|
||||
# - Fetch data from external HTTP endpoints
|
||||
# - Store the parsed response in workflow variables for later use
|
||||
# - Add the response body to the conversation so a downstream agent can
|
||||
# answer questions based on it
|
||||
#
|
||||
# This sample fetches public metadata for the dotnet/runtime repository from
|
||||
# the GitHub REST API (no authentication required) and uses an agent to
|
||||
# answer follow-up questions about it.
|
||||
#
|
||||
# Example input:
|
||||
# How many subscribers does the repository have?
|
||||
#
|
||||
kind: Workflow
|
||||
trigger:
|
||||
|
||||
kind: OnConversationStart
|
||||
id: workflow_invoke_http_request_demo
|
||||
actions:
|
||||
|
||||
# Capture the original user message for input to the follow-up agent.
|
||||
- kind: SetVariable
|
||||
id: set_user_message
|
||||
variable: Local.InputMessage
|
||||
value: =System.LastMessage
|
||||
|
||||
# Set the repository org/name used to form the request URL.
|
||||
- kind: SetVariable
|
||||
id: set_repo_name
|
||||
variable: Local.RepoName
|
||||
value: microsoft/agent-framework
|
||||
|
||||
# Invoke the GitHub repo API. The response body is parsed into Local.RepoInfo
|
||||
# and also added to the conversation (via conversationId) so the agent below
|
||||
# can answer questions based on it.
|
||||
- kind: HttpRequestAction
|
||||
id: fetch_repo_info
|
||||
conversationId: =System.ConversationId
|
||||
method: GET
|
||||
url: =Concatenate("https://api.github.com/repos/", Local.RepoName)
|
||||
headers:
|
||||
Accept: application/vnd.github+json
|
||||
User-Agent: agent-framework-sample
|
||||
response: Local.RepoInfo
|
||||
|
||||
# Display a confirmation message showing key fields from the parsed response.
|
||||
- kind: SendMessage
|
||||
id: show_repo_summary
|
||||
message: "Fetched repo: visibility={Local.RepoInfo.visibility}, description={Local.RepoInfo.description}"
|
||||
|
||||
# Use the agent to summarize the repo using the conversation context.
|
||||
- kind: InvokeAzureAgent
|
||||
id: summarize_repo
|
||||
conversationId: =System.ConversationId
|
||||
agent:
|
||||
name: GitHubRepoInfoAgent
|
||||
input:
|
||||
messages: =UserMessage("Please provide a brief summary of this GitHub repository based on the data already in the conversation.")
|
||||
output:
|
||||
autoSend: true
|
||||
messages: Local.AgentResponse
|
||||
|
||||
# Allow the user to ask follow-up questions about the repo in a loop.
|
||||
- kind: InvokeAzureAgent
|
||||
id: invoke_followup
|
||||
conversationId: =System.ConversationId
|
||||
agent:
|
||||
name: GitHubRepoInfoAgent
|
||||
input:
|
||||
messages: =Local.InputMessage
|
||||
externalLoop:
|
||||
when: =Upper(System.LastMessage.Text) <> "EXIT"
|
||||
@@ -1,95 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Azure.AI.Projects;
|
||||
using Azure.AI.Projects.Agents;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Shared.Foundry;
|
||||
using Shared.Workflows;
|
||||
|
||||
namespace Demo.Workflows.Declarative.InvokeHttpRequest;
|
||||
|
||||
/// <summary>
|
||||
/// Demonstrates a workflow that uses HttpRequestAction to call a REST API
|
||||
/// directly from the workflow.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The HttpRequestAction allows workflows to issue HTTP requests and:
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item>Fetch data from external REST endpoints</item>
|
||||
/// <item>Store the parsed response in workflow variables</item>
|
||||
/// <item>Add the response body to the conversation so an agent can answer
|
||||
/// questions based on it</item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// This sample fetches public metadata for the dotnet/runtime repository from
|
||||
/// the GitHub REST API (no authentication required) and uses a Foundry agent
|
||||
/// to answer follow-up questions about it. Type "EXIT" to end the conversation.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// See the README.md file in the parent folder (../README.md) for detailed
|
||||
/// information about the configuration required to run this sample.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class Program
|
||||
{
|
||||
public static async Task Main(string[] args)
|
||||
{
|
||||
// Initialize configuration
|
||||
IConfiguration configuration = Application.InitializeConfig();
|
||||
Uri foundryEndpoint = new(configuration.GetValue(Application.Settings.FoundryEndpoint));
|
||||
|
||||
// Ensure sample agent exists in Foundry. The agent has no tools - it answers
|
||||
// questions about the GitHub repository using only the JSON data that the
|
||||
// HttpRequestAction adds to the conversation.
|
||||
await CreateAgentAsync(foundryEndpoint, configuration);
|
||||
|
||||
// Get input from command line or console
|
||||
string workflowInput = Application.GetInput(args);
|
||||
|
||||
// The default HttpRequestHandler is sufficient for this sample because the
|
||||
// GitHub REST endpoint used here does not require authentication. For
|
||||
// authenticated endpoints, supply a custom Func<HttpRequestInfo, ..., HttpClient?>
|
||||
// to DefaultHttpRequestHandler so each request can be routed through a
|
||||
// pre-configured (cached) HttpClient with the appropriate credentials.
|
||||
await using DefaultHttpRequestHandler httpRequestHandler = new();
|
||||
|
||||
// Create the workflow factory with the HTTP request handler
|
||||
WorkflowFactory workflowFactory = new("InvokeHttpRequest.yaml", foundryEndpoint)
|
||||
{
|
||||
HttpRequestHandler = httpRequestHandler
|
||||
};
|
||||
|
||||
// Execute the workflow
|
||||
WorkflowRunner runner = new() { UseJsonCheckpoints = true };
|
||||
await runner.ExecuteAsync(workflowFactory.CreateWorkflow, workflowInput);
|
||||
}
|
||||
|
||||
private static async Task CreateAgentAsync(Uri foundryEndpoint, IConfiguration configuration)
|
||||
{
|
||||
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
|
||||
AIProjectClient aiProjectClient = new(foundryEndpoint, new DefaultAzureCredential());
|
||||
|
||||
await aiProjectClient.CreateAgentAsync(
|
||||
agentName: "GitHubRepoInfoAgent",
|
||||
agentDefinition: DefineAgent(configuration),
|
||||
agentDescription: "Answers questions about a GitHub repository using HTTP response data in the conversation");
|
||||
}
|
||||
|
||||
private static DeclarativeAgentDefinition DefineAgent(IConfiguration configuration)
|
||||
{
|
||||
return new DeclarativeAgentDefinition(configuration.GetValue(Application.Settings.FoundryModel))
|
||||
{
|
||||
Instructions =
|
||||
"""
|
||||
Answer the user's questions about the GitHub repository using only the
|
||||
JSON data already present in the conversation history.
|
||||
If the answer is not contained in the conversation, say so plainly
|
||||
rather than guessing. Be concise and helpful.
|
||||
"""
|
||||
};
|
||||
}
|
||||
}
|
||||
-47
@@ -65,53 +65,6 @@ 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,21 +7,6 @@ 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
|
||||
@@ -34,13 +19,6 @@ 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,8 +13,6 @@
|
||||
<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 -->
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
// 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),
|
||||
OutputItemFunctionToolCallOutput funcOutput => ConvertFunctionToolCallOutput(funcOutput),
|
||||
FunctionToolCallOutputResource funcOutput => ConvertFunctionToolCallOutputResource(funcOutput),
|
||||
OutputItemReasoningItem => null,
|
||||
_ => null
|
||||
};
|
||||
@@ -332,7 +332,7 @@ internal static class InputConverter
|
||||
[new FunctionCallContent(funcCall.CallId, funcCall.Name, arguments)]);
|
||||
}
|
||||
|
||||
private static ChatMessage ConvertFunctionToolCallOutput(OutputItemFunctionToolCallOutput funcOutput)
|
||||
private static ChatMessage ConvertFunctionToolCallOutputResource(FunctionToolCallOutputResource funcOutput)
|
||||
{
|
||||
return new ChatMessage(
|
||||
ChatRole.Tool,
|
||||
|
||||
-1
@@ -34,7 +34,6 @@
|
||||
<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,25 +251,16 @@ 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 new ResponseUsage(
|
||||
return AzureAIAgentServerResponsesModelFactory.ResponseUsage(
|
||||
inputTokens: inputTokens,
|
||||
inputTokensDetails: new ResponseUsageInputTokensDetails(cachedTokens),
|
||||
outputTokens: outputTokens,
|
||||
outputTokensDetails: new ResponseUsageOutputTokensDetails(reasoningTokens),
|
||||
totalTokens: totalTokens);
|
||||
}
|
||||
|
||||
|
||||
@@ -42,19 +42,11 @@ 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);
|
||||
}
|
||||
|
||||
@@ -88,19 +80,13 @@ internal sealed class A2AAgentHandler : IAgentHandler
|
||||
? new AgentRunOptions { AllowBackgroundResponses = allowBackgroundResponses }
|
||||
: new AgentRunOptions { AllowBackgroundResponses = allowBackgroundResponses, AdditionalProperties = context.Metadata.ToAdditionalProperties() };
|
||||
|
||||
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);
|
||||
}
|
||||
var response = await this._hostAgent.RunAsync(
|
||||
chatMessages,
|
||||
session: session,
|
||||
options: options,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await this._hostAgent.SaveSessionAsync(contextId, session, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.ContinuationToken is null)
|
||||
{
|
||||
@@ -122,39 +108,6 @@ 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");
|
||||
@@ -188,10 +141,8 @@ internal sealed class A2AAgentHandler : IAgentHandler
|
||||
await failUpdater.FailAsync(message: null, CancellationToken.None).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
await this._hostAgent.SaveSessionAsync(contextId, session, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await this._hostAgent.SaveSessionAsync(contextId, session, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.ContinuationToken is null)
|
||||
{
|
||||
@@ -223,16 +174,6 @@ 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,26 +8,6 @@ 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,8 +21,6 @@ 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)}";
|
||||
@@ -64,11 +62,6 @@ 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;
|
||||
@@ -311,7 +304,15 @@ internal static class BuiltInFunctions
|
||||
}
|
||||
|
||||
// Check if we should wait for response (default is true)
|
||||
bool waitForResponse = ShouldWaitForResponse(req, defaultValue: 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;
|
||||
}
|
||||
}
|
||||
|
||||
AIAgent agentProxy = client.AsDurableAgentProxy(context, agentName);
|
||||
|
||||
@@ -427,95 +428,6 @@ 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>
|
||||
@@ -523,18 +435,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,
|
||||
bool? acceptsJson = null)
|
||||
string errorMessage)
|
||||
{
|
||||
HttpResponseData response = req.CreateResponse(statusCode);
|
||||
bool acceptsJson = req.Headers.TryGetValues("Accept", out IEnumerable<string>? acceptValues) &&
|
||||
acceptValues.Contains("application/json", StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (acceptsJson ?? AcceptsJson(req))
|
||||
if (acceptsJson)
|
||||
{
|
||||
ErrorResponse errorResponse = new((int)statusCode, errorMessage);
|
||||
await response.WriteAsJsonAsync(errorResponse, context.CancellationToken);
|
||||
@@ -567,7 +479,10 @@ internal static class BuiltInFunctions
|
||||
HttpResponseData response = req.CreateResponse(statusCode);
|
||||
response.Headers.Add("x-ms-thread-id", sessionId);
|
||||
|
||||
if (AcceptsJson(req))
|
||||
bool acceptsJson = req.Headers.TryGetValues("Accept", out IEnumerable<string>? acceptValues) &&
|
||||
acceptValues.Contains("application/json", StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (acceptsJson)
|
||||
{
|
||||
AgentRunSuccessResponse successResponse = new((int)statusCode, sessionId, agentResponse);
|
||||
await response.WriteAsJsonAsync(successResponse, context.CancellationToken);
|
||||
@@ -596,7 +511,10 @@ internal static class BuiltInFunctions
|
||||
HttpResponseData response = req.CreateResponse(HttpStatusCode.Accepted);
|
||||
response.Headers.Add("x-ms-thread-id", sessionId);
|
||||
|
||||
if (AcceptsJson(req))
|
||||
bool acceptsJson = req.Headers.TryGetValues("Accept", out IEnumerable<string>? acceptValues) &&
|
||||
acceptValues.Contains("application/json", StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (acceptsJson)
|
||||
{
|
||||
AgentRunAcceptedResponse acceptedResponse = new((int)HttpStatusCode.Accepted, sessionId);
|
||||
await response.WriteAsJsonAsync(acceptedResponse, context.CancellationToken);
|
||||
@@ -610,34 +528,6 @@ 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
|
||||
@@ -701,19 +591,6 @@ 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,7 +2,6 @@
|
||||
|
||||
## [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,12 +26,6 @@ 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>
|
||||
|
||||
@@ -1,289 +0,0 @@
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-54
@@ -16,60 +16,6 @@ internal static class ChatMessageExtensions
|
||||
public static RecordValue ToRecord(this ChatMessage message) =>
|
||||
FormulaValue.NewRecordFromFields(message.GetMessageFields());
|
||||
|
||||
/// <summary>
|
||||
/// Merges the user-authored <paramref name="input"/> with the round-tripped
|
||||
/// <paramref name="inputMessage"/> returned by <c>AgentProvider.CreateMessageAsync</c>
|
||||
/// to produce the value stored in <c>System.LastMessage</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The agent service often strips or alters <see cref="TextContent"/> on round-trip,
|
||||
/// while replacing inline media (<see cref="DataContent"/>, <see cref="UriContent"/>)
|
||||
/// with server-side references (typically <see cref="HostedFileContent"/>).
|
||||
/// We want both: the original text (so <c>=System.LastMessage.Text</c> works) and
|
||||
/// the server's media references (so subsequent actions don't re-upload large blobs).
|
||||
/// <para>
|
||||
/// Strategy: keep <paramref name="inputMessage"/> as the base — it has the server-generated
|
||||
/// <see cref="ChatMessage.MessageId"/> and any provider-augmented metadata, and is forward-
|
||||
/// compatible with new properties added on <see cref="ChatMessage"/> in the abstractions
|
||||
/// layer. Only the <see cref="ChatMessage.Contents"/> list is mutated to substitute
|
||||
/// original <see cref="TextContent"/> items in place (and append any extras the round-trip
|
||||
/// dropped). Non-text content items returned by the service are left untouched so
|
||||
/// server-side references survive.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static ChatMessage MergeForLastMessage(this ChatMessage input, ChatMessage? inputMessage)
|
||||
{
|
||||
if (inputMessage is null)
|
||||
{
|
||||
return input;
|
||||
}
|
||||
|
||||
// Build a queue of the original text items, in order. Fall back to ChatMessage.Text
|
||||
// if the input has no explicit TextContent entries.
|
||||
Queue<TextContent> originalTexts = new(input.Contents.OfType<TextContent>());
|
||||
if (originalTexts.Count == 0 && !string.IsNullOrEmpty(input.Text))
|
||||
{
|
||||
originalTexts.Enqueue(new TextContent(input.Text));
|
||||
}
|
||||
|
||||
// Replace TextContent items in inputMessage.Contents with the originals, in order.
|
||||
for (int i = 0; i < inputMessage.Contents.Count && originalTexts.Count > 0; i++)
|
||||
{
|
||||
if (inputMessage.Contents[i] is TextContent)
|
||||
{
|
||||
inputMessage.Contents[i] = originalTexts.Dequeue();
|
||||
}
|
||||
}
|
||||
|
||||
// Append any remaining original text items that the round-trip dropped entirely.
|
||||
while (originalTexts.Count > 0)
|
||||
{
|
||||
inputMessage.Contents.Add(originalTexts.Dequeue());
|
||||
}
|
||||
|
||||
return inputMessage;
|
||||
}
|
||||
|
||||
public static TableValue ToTable(this IEnumerable<ChatMessage> messages) =>
|
||||
FormulaValue.NewTable(TypeSchema.Message.RecordType, messages.Select(message => message.ToRecord()));
|
||||
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
// 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; }
|
||||
}
|
||||
+1
-5
@@ -43,11 +43,7 @@ internal sealed class DeclarativeWorkflowExecutor<TInput>(
|
||||
await declarativeContext.QueueConversationUpdateAsync(conversationId, isExternal: true, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
ChatMessage inputMessage = await options.AgentProvider.CreateMessageAsync(conversationId, input, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Use the original input for System.LastMessage to ensure Text is preserved (the
|
||||
// service may strip text on round-trip), but substitute server-side media references
|
||||
// (e.g., HostedFileContent) so subsequent actions don't re-upload large blobs.
|
||||
await declarativeContext.SetLastMessageAsync(input.MergeForLastMessage(inputMessage)).ConfigureAwait(false);
|
||||
await declarativeContext.SetLastMessageAsync(inputMessage).ConfigureAwait(false);
|
||||
|
||||
await context.SendResultMessageAsync(this.Id, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
+2
-12
@@ -529,18 +529,6 @@ 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);
|
||||
@@ -585,6 +573,8 @@ 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);
|
||||
|
||||
@@ -58,6 +58,7 @@ public abstract class RootExecutor<TInput> : Executor<TInput>, IResettableExecut
|
||||
public override async ValueTask HandleAsync(TInput message, IWorkflowContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
DeclarativeWorkflowContext declarativeContext = new(context, this._state);
|
||||
await this.ExecuteAsync(message, declarativeContext, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
ChatMessage input = (this._inputTransform ?? DefaultInputTransform).Invoke(message);
|
||||
|
||||
@@ -68,13 +69,7 @@ public abstract class RootExecutor<TInput> : Executor<TInput>, IResettableExecut
|
||||
await declarativeContext.QueueConversationUpdateAsync(this._conversationId, isExternal: true, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
ChatMessage inputMessage = await this._agentProvider.CreateMessageAsync(this._conversationId, input, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Use the original input for System.LastMessage to ensure Text is preserved (the
|
||||
// service may strip text on round-trip), but substitute server-side media references
|
||||
// (e.g., HostedFileContent) so subsequent actions don't re-upload large blobs.
|
||||
await declarativeContext.SetLastMessageAsync(input.MergeForLastMessage(inputMessage)).ConfigureAwait(false);
|
||||
|
||||
await this.ExecuteAsync(message, declarativeContext, cancellationToken).ConfigureAwait(false);
|
||||
await declarativeContext.SetLastMessageAsync(inputMessage).ConfigureAwait(false);
|
||||
|
||||
await declarativeContext.SendResultMessageAsync(this.Id, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
-346
@@ -1,346 +0,0 @@
|
||||
// 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,20 +1,6 @@
|
||||
<?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>
|
||||
@@ -43,13 +29,6 @@
|
||||
<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>
|
||||
@@ -64,20 +43,6 @@
|
||||
<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>
|
||||
@@ -106,13 +71,6 @@
|
||||
<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>
|
||||
@@ -127,20 +85,6 @@
|
||||
<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>
|
||||
@@ -169,13 +113,6 @@
|
||||
<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>
|
||||
@@ -190,20 +127,6 @@
|
||||
<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>
|
||||
@@ -232,13 +155,6 @@
|
||||
<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>
|
||||
@@ -253,20 +169,6 @@
|
||||
<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>
|
||||
@@ -295,13 +197,6 @@
|
||||
<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>
|
||||
@@ -316,39 +211,4 @@
|
||||
<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,8 +35,7 @@ public abstract class AgentSkill
|
||||
/// Gets the full skill content.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// For file-based skills this is the raw SKILL.md file content, optionally
|
||||
/// augmented with a synthesized scripts block when scripts are present.
|
||||
/// For file-based skills this is the raw SKILL.md file content.
|
||||
/// 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,9 +46,8 @@ 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">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="arguments">Arguments for script execution.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The script execution result.</returns>
|
||||
public abstract Task<object?> RunAsync(AgentSkill skill, JsonElement? arguments, IServiceProvider? serviceProvider, CancellationToken cancellationToken = default);
|
||||
public abstract Task<object?> RunAsync(AgentSkill skill, AIFunctionArguments arguments, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ 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;
|
||||
@@ -244,7 +243,7 @@ public sealed partial class AgentSkillsProvider : AIContextProvider
|
||||
}
|
||||
|
||||
AIFunction scriptFunction = AIFunctionFactory.Create(
|
||||
(string skillName, string scriptName, JsonElement? arguments = null, IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default) =>
|
||||
(string skillName, string scriptName, IDictionary<string, object?>? 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.");
|
||||
@@ -341,7 +340,7 @@ public sealed partial class AgentSkillsProvider : AIContextProvider
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<object?> RunSkillScriptAsync(IList<AgentSkill> skills, string skillName, string scriptName, JsonElement? arguments = null, IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default)
|
||||
private async Task<object?> RunSkillScriptAsync(IList<AgentSkill> skills, string skillName, string scriptName, IDictionary<string, object?>? arguments = null, IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(skillName))
|
||||
{
|
||||
@@ -367,7 +366,7 @@ public sealed partial class AgentSkillsProvider : AIContextProvider
|
||||
|
||||
try
|
||||
{
|
||||
return await script.RunAsync(skill, arguments, serviceProvider, cancellationToken).ConfigureAwait(false);
|
||||
return await script.RunAsync(skill, new AIFunctionArguments(arguments) { Services = serviceProvider }, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -15,8 +15,6 @@ 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.
|
||||
@@ -34,7 +32,7 @@ public sealed class AgentFileSkill : AgentSkill
|
||||
IReadOnlyList<AgentSkillScript>? scripts = null)
|
||||
{
|
||||
this.Frontmatter = Throw.IfNull(frontmatter);
|
||||
this._originalContent = Throw.IfNull(content);
|
||||
this.Content = Throw.IfNull(content);
|
||||
this.Path = Throw.IfNullOrWhitespace(path);
|
||||
this._resources = resources ?? [];
|
||||
this._scripts = scripts ?? [];
|
||||
@@ -44,18 +42,7 @@ public sealed class AgentFileSkill : AgentSkill
|
||||
public override AgentSkillFrontmatter Frontmatter { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <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;
|
||||
}
|
||||
public override string Content { get; }
|
||||
|
||||
/// <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,11 +16,6 @@ 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>
|
||||
@@ -42,14 +37,7 @@ public sealed class AgentFileSkillScript : AgentSkillScript
|
||||
public string FullPath { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <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)
|
||||
public override async Task<object?> RunAsync(AgentSkill skill, AIFunctionArguments arguments, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (skill is not AgentFileSkill fileSkill)
|
||||
{
|
||||
@@ -63,12 +51,6 @@ public sealed class AgentFileSkillScript : AgentSkillScript
|
||||
$"Supply a script runner when constructing {nameof(AgentFileSkillsSource)} to enable script execution.");
|
||||
}
|
||||
|
||||
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();
|
||||
return await this._runner(fileSkill, this, arguments, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
// 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;
|
||||
@@ -14,19 +13,15 @@ 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">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="arguments">Optional arguments for the script, provided by the agent/LLM.</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,
|
||||
JsonElement? arguments,
|
||||
IServiceProvider? serviceProvider,
|
||||
AIFunctionArguments arguments,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
+19
-43
@@ -59,56 +59,32 @@ internal static class AgentInlineSkillContentBuilder
|
||||
|
||||
if (scripts is { Count: > 0 })
|
||||
{
|
||||
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("\n\n<scripts>\n");
|
||||
foreach (var script in scripts)
|
||||
{
|
||||
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");
|
||||
var parametersSchema = script.ParametersSchema;
|
||||
|
||||
if (parametersSchema is not null)
|
||||
if (script.Description is null && parametersSchema is null)
|
||||
{
|
||||
sb.Append($" <parameters_schema>{EscapeXmlString(parametersSchema.Value.GetRawText(), preserveQuotes: true)}</parameters_schema>\n");
|
||||
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");
|
||||
|
||||
sb.Append(" </script>\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("</scripts>");
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
@@ -68,42 +67,8 @@ internal sealed class AgentInlineSkillScript : AgentSkillScript
|
||||
public override JsonElement? ParametersSchema => this._function.JsonSchema;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task<object?> RunAsync(AgentSkill skill, JsonElement? arguments, IServiceProvider? serviceProvider, CancellationToken cancellationToken = default)
|
||||
public override async Task<object?> RunAsync(AgentSkill skill, AIFunctionArguments arguments, CancellationToken cancellationToken = default)
|
||||
{
|
||||
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);
|
||||
return await this._function.InvokeAsync(arguments, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,9 +25,6 @@ internal sealed class WorkflowFactory(string workflowFile, Uri foundryEndpoint)
|
||||
// Assign to provide MCP tool capabilities
|
||||
public IMcpToolHandler? McpToolHandler { get; init; }
|
||||
|
||||
// Assign to enable HttpRequestAction support
|
||||
public IHttpRequestHandler? HttpRequestHandler { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Create the workflow from the declarative YAML. Includes definition of the
|
||||
/// <see cref="DeclarativeWorkflowOptions" /> and the associated <see cref="ResponseAgentProvider"/>.
|
||||
@@ -49,7 +46,6 @@ internal sealed class WorkflowFactory(string workflowFile, Uri foundryEndpoint)
|
||||
ConversationId = this.ConversationId,
|
||||
LoggerFactory = this.LoggerFactory,
|
||||
McpToolHandler = this.McpToolHandler,
|
||||
HttpRequestHandler = this.HttpRequestHandler,
|
||||
};
|
||||
|
||||
string workflowPath = Path.Combine(AppContext.BaseDirectory, workflowFile);
|
||||
|
||||
@@ -162,10 +162,7 @@ internal sealed class WorkflowRunner
|
||||
|
||||
case RequestInfoEvent requestInfo:
|
||||
Debug.WriteLine($"REQUEST #{requestInfo.Request.RequestId}");
|
||||
if (response is null || !string.Equals(requestInfo.Request.RequestId, response.RequestId, StringComparison.Ordinal))
|
||||
{
|
||||
externalResponse = requestInfo.Request;
|
||||
}
|
||||
externalResponse = requestInfo.Request;
|
||||
break;
|
||||
|
||||
case ConversationUpdateEvent invokeEvent:
|
||||
|
||||
+4
-2
@@ -164,8 +164,10 @@ public class AgentFrameworkResponseHandlerTelemetryTests
|
||||
private static (CreateResponse request, ResponseContext context) BuildRequest(string? agentKey = null)
|
||||
{
|
||||
var request = agentKey is null
|
||||
? new CreateResponse { Model = "test" }
|
||||
: new CreateResponse { Model = "test", AgentReference = new AgentReference(agentKey) };
|
||||
? AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test")
|
||||
: AzureAIAgentServerResponsesModelFactory.CreateResponse(
|
||||
model: "test",
|
||||
agentReference: new AgentReference(agentKey));
|
||||
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
|
||||
+27
-21
@@ -34,7 +34,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
var request = new CreateResponse { Model = "test" };
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test");
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_1", status = "completed", role = "user",
|
||||
@@ -72,7 +72,9 @@ public class AgentFrameworkResponseHandlerTests
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
var request = new CreateResponse { Model = "test", AgentReference = new AgentReference("my-agent") };
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(
|
||||
model: "test",
|
||||
agentReference: new AgentReference("my-agent"));
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_1", status = "completed", role = "user",
|
||||
@@ -107,7 +109,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
var request = new CreateResponse { Model = "test" };
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test");
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_1", status = "completed", role = "user",
|
||||
@@ -156,7 +158,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
var request = new CreateResponse { Model = "my-agent" };
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "my-agent");
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_1", status = "completed", role = "user",
|
||||
@@ -193,7 +195,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
var request = new CreateResponse { Model = "" };
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "");
|
||||
var metadata = new Metadata();
|
||||
metadata.AdditionalProperties["entity_id"] = "entity-agent";
|
||||
request.Metadata = metadata;
|
||||
@@ -233,7 +235,9 @@ public class AgentFrameworkResponseHandlerTests
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
var request = new CreateResponse { Model = "test", AgentReference = new AgentReference("nonexistent-agent") };
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(
|
||||
model: "test",
|
||||
agentReference: new AgentReference("nonexistent-agent"));
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_1", status = "completed", role = "user",
|
||||
@@ -268,7 +272,9 @@ public class AgentFrameworkResponseHandlerTests
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
var request = new CreateResponse { Model = "test", AgentReference = new AgentReference("missing-agent") };
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(
|
||||
model: "test",
|
||||
agentReference: new AgentReference("missing-agent"));
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_1", status = "completed", role = "user",
|
||||
@@ -302,7 +308,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
var request = new CreateResponse { Model = "" };
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "");
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_1", status = "completed", role = "user",
|
||||
@@ -336,7 +342,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
var request = new CreateResponse { Model = "test" };
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test");
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_1", status = "completed", role = "user",
|
||||
@@ -381,7 +387,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
var request = new CreateResponse { Model = "test" };
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test");
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_1", status = "completed", role = "user",
|
||||
@@ -429,7 +435,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
var request = new CreateResponse { Model = "test" };
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test");
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_1", status = "completed", role = "user",
|
||||
@@ -472,7 +478,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
var request = new CreateResponse { Model = "test" };
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test");
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_1", status = "completed", role = "user",
|
||||
@@ -511,11 +517,9 @@ public class AgentFrameworkResponseHandlerTests
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
var request = new CreateResponse
|
||||
{
|
||||
Model = "test",
|
||||
Instructions = "You are a helpful assistant.",
|
||||
};
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(
|
||||
model: "test",
|
||||
instructions: "You are a helpful assistant.");
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_1", status = "completed", role = "user",
|
||||
@@ -553,7 +557,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
var request = new CreateResponse { Model = "test" };
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test");
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_1", status = "completed", role = "user",
|
||||
@@ -594,7 +598,9 @@ public class AgentFrameworkResponseHandlerTests
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
var request = new CreateResponse { Model = "test", AgentReference = new AgentReference("agent-2") };
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(
|
||||
model: "test",
|
||||
agentReference: new AgentReference("agent-2"));
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_1", status = "completed", role = "user",
|
||||
@@ -631,7 +637,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
var request = new CreateResponse { Model = "test" };
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test");
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_1", status = "completed", role = "user",
|
||||
@@ -668,7 +674,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
var request = new CreateResponse { Model = "test" };
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test");
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_1", status = "completed", role = "user",
|
||||
|
||||
@@ -1,329 +0,0 @@
|
||||
// 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,6 +2,7 @@
|
||||
|
||||
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;
|
||||
@@ -145,7 +146,11 @@ public class InputConverterTests
|
||||
[Fact]
|
||||
public void ConvertToChatOptions_SetsTemperatureAndTopP()
|
||||
{
|
||||
var request = new CreateResponse { Temperature = 0.7, TopP = 0.9, MaxOutputTokens = 1000, Model = "gpt-4o" };
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(
|
||||
temperature: 0.7,
|
||||
topP: 0.9,
|
||||
maxOutputTokens: 1000,
|
||||
model: "gpt-4o");
|
||||
|
||||
var options = InputConverter.ConvertToChatOptions(request);
|
||||
|
||||
@@ -206,9 +211,9 @@ public class InputConverterTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertOutputItemsToMessages_FunctionToolCallOutput_ReturnsToolMessage()
|
||||
public void ConvertOutputItemsToMessages_FunctionToolCallOutputResource_ReturnsToolMessage()
|
||||
{
|
||||
var funcOutput = new OutputItemFunctionToolCallOutput(
|
||||
var funcOutput = new FunctionToolCallOutputResource(
|
||||
callId: "call_def",
|
||||
output: BinaryData.FromString("result data"));
|
||||
|
||||
@@ -224,7 +229,8 @@ public class InputConverterTests
|
||||
[Fact]
|
||||
public void ConvertOutputItemsToMessages_ReasoningItem_ReturnsNull()
|
||||
{
|
||||
var reasoning = new OutputItemReasoningItem("reason_001", []);
|
||||
var reasoning = AzureAIAgentServerResponsesModelFactory.OutputItemReasoningItem(
|
||||
id: "reason_001");
|
||||
|
||||
var messages = InputConverter.ConvertOutputItemsToMessages([reasoning]);
|
||||
|
||||
@@ -655,7 +661,7 @@ public class InputConverterTests
|
||||
[Fact]
|
||||
public void ConvertToChatOptions_ModelId_NotSetFromRequest()
|
||||
{
|
||||
var request = new CreateResponse { Model = "my-model" };
|
||||
var request = AzureAIAgentServerResponsesModelFactory.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 = new CreateResponse { Model = "test-model" };
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test-model");
|
||||
var stream = new ResponseEventStream(mockContext.Object, request);
|
||||
return (stream, mockContext);
|
||||
}
|
||||
|
||||
+5
-3
@@ -160,7 +160,9 @@ public class WorkflowIntegrationTests
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
var request = new CreateResponse { Model = "test", AgentReference = new AgentReference("my-workflow") };
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(
|
||||
model: "test",
|
||||
agentReference: new AgentReference("my-workflow"));
|
||||
request.Input = CreateUserInput("Test keyed workflow");
|
||||
var mockContext = CreateMockContext();
|
||||
|
||||
@@ -361,7 +363,7 @@ public class WorkflowIntegrationTests
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
var request = new CreateResponse { Model = "test" };
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test");
|
||||
request.Input = CreateUserInput(userMessage);
|
||||
var mockContext = CreateMockContext();
|
||||
|
||||
@@ -391,7 +393,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 = new CreateResponse { Model = "test-model" };
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test-model");
|
||||
var stream = new ResponseEventStream(mockContext.Object, request);
|
||||
return (stream, mockContext);
|
||||
}
|
||||
|
||||
+2
-11
@@ -10,7 +10,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="'$(TargetFrameworkIdentifier)' != '.NETCoreApp'">
|
||||
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="'$(TargetFrameworkIdentifier)' == '.NETCoreApp'">
|
||||
@@ -34,7 +34,7 @@
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- FoundryEval tests require net8.0+ (MEAI.Evaluation does not support legacy TFMs) -->
|
||||
<!-- Evaluation 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,15 +50,6 @@
|
||||
<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>
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"id": "tbx-123",
|
||||
"name": "research_tools",
|
||||
"default_version": "v5"
|
||||
}
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"metadata": {},
|
||||
"id": "tbv-research_tools-v5",
|
||||
"name": "research_tools",
|
||||
"version": "v5",
|
||||
"description": "Example research toolbox",
|
||||
"created_at": 1775779200,
|
||||
"tools": [
|
||||
{ "type": "code_interpreter" }
|
||||
]
|
||||
}
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"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,9 +14,6 @@ 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\"";
|
||||
|
||||
@@ -165,19 +162,4 @@ 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,457 +586,6 @@ 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.
|
||||
@@ -1272,308 +821,6 @@ 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,
|
||||
@@ -1658,68 +905,6 @@ 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,67 +147,4 @@ 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,7 +3,6 @@
|
||||
using System.Diagnostics;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ModelContextProtocol.Client;
|
||||
@@ -126,45 +125,6 @@ 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,6 +8,7 @@ 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;
|
||||
@@ -127,9 +128,8 @@ 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);
|
||||
using var argsDoc = JsonDocument.Parse($$"""{ "request": {{inputJson.GetRawText()}} }""");
|
||||
var args = argsDoc.RootElement;
|
||||
var scriptResult = await script.RunAsync(skill, args, null, CancellationToken.None);
|
||||
var args = new AIFunctionArguments { ["request"] = inputJson };
|
||||
var scriptResult = await script.RunAsync(skill, args, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(scriptResult);
|
||||
@@ -173,14 +173,12 @@ public sealed class AgentClassSkillTests
|
||||
|
||||
// Act & Assert — static method
|
||||
var doWorkScript = skill.Scripts!.First(s => s.Name == "do-work");
|
||||
using var doWorkDoc = JsonDocument.Parse("""{"input":"hello"}""");
|
||||
var doWorkResult = await doWorkScript.RunAsync(skill, doWorkDoc.RootElement, null, CancellationToken.None);
|
||||
var doWorkResult = await doWorkScript.RunAsync(skill, new AIFunctionArguments { ["input"] = "hello" }, CancellationToken.None);
|
||||
Assert.Equal("HELLO", doWorkResult?.ToString());
|
||||
|
||||
// Act & Assert — instance method
|
||||
var appendScript = skill.Scripts!.First(s => s.Name == "append");
|
||||
using var appendDoc = JsonDocument.Parse("""{"input":"test"}""");
|
||||
var appendResult = await appendScript.RunAsync(skill, appendDoc.RootElement, null, CancellationToken.None);
|
||||
var appendResult = await appendScript.RunAsync(skill, new AIFunctionArguments { ["input"] = "test" }, CancellationToken.None);
|
||||
Assert.Equal("test-suffix", appendResult?.ToString());
|
||||
}
|
||||
|
||||
@@ -369,7 +367,7 @@ public sealed class AgentClassSkillTests
|
||||
// Act & Assert — all scripts produce values
|
||||
foreach (var script in skill.Scripts!)
|
||||
{
|
||||
var result = await script.RunAsync(skill, null, null, CancellationToken.None);
|
||||
var result = await script.RunAsync(skill, new AIFunctionArguments(), CancellationToken.None);
|
||||
Assert.NotNull(result);
|
||||
}
|
||||
}
|
||||
@@ -384,9 +382,8 @@ 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);
|
||||
using var argsDoc = JsonDocument.Parse($$"""{ "request": {{inputJson.GetRawText()}} }""");
|
||||
var args = argsDoc.RootElement;
|
||||
var scriptResult = await script.RunAsync(skill, args, null, CancellationToken.None);
|
||||
var args = new AIFunctionArguments { ["request"] = inputJson };
|
||||
var scriptResult = await script.RunAsync(skill, args, CancellationToken.None);
|
||||
Assert.NotNull(scriptResult);
|
||||
Assert.Contains("test", scriptResult!.ToString()!);
|
||||
Assert.Contains("3", scriptResult!.ToString()!);
|
||||
@@ -500,9 +497,8 @@ 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);
|
||||
using var argsDoc = JsonDocument.Parse($$"""{ "request": {{inputJson.GetRawText()}} }""");
|
||||
var args = argsDoc.RootElement;
|
||||
var result = await script.RunAsync(skill, args, null, CancellationToken.None);
|
||||
var args = new AIFunctionArguments { ["request"] = inputJson };
|
||||
var result = await script.RunAsync(skill, args, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
@@ -535,9 +531,8 @@ 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);
|
||||
using var argsDoc = JsonDocument.Parse($$"""{ "request": {{inputJson.GetRawText()}} }""");
|
||||
var args = argsDoc.RootElement;
|
||||
var result = await script.RunAsync(skill, args, null, CancellationToken.None);
|
||||
var args = new AIFunctionArguments { ["request"] = inputJson };
|
||||
var result = await script.RunAsync(skill, args, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
|
||||
+10
-181
@@ -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, JsonElement? a, IServiceProvider? sp, CancellationToken ct) => Task.FromResult<object?>("result");
|
||||
static Task<object?> RunnerAsync(AgentFileSkill s, AgentFileSkillScript sc, AIFunctionArguments a, 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, null, null, CancellationToken.None));
|
||||
() => script.RunAsync(nonFileSkill, new AIFunctionArguments(), CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -30,7 +30,7 @@ public sealed class AgentFileSkillScriptTests
|
||||
{
|
||||
// Arrange
|
||||
var runnerCalled = false;
|
||||
Task<object?> runnerAsync(AgentFileSkill skill, AgentFileSkillScript scriptArg, JsonElement? args, IServiceProvider? sp, CancellationToken ct)
|
||||
Task<object?> runnerAsync(AgentFileSkill skill, AgentFileSkillScript scriptArg, AIFunctionArguments args, 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, null, null, CancellationToken.None);
|
||||
var result = await script.RunAsync(fileSkill, new AIFunctionArguments(), 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, JsonElement? args, IServiceProvider? sp, CancellationToken ct)
|
||||
Task<object?> runnerAsync(AgentFileSkill skill, AgentFileSkillScript scriptArg, AIFunctionArguments args, CancellationToken ct)
|
||||
{
|
||||
capturedSkill = skill;
|
||||
capturedScript = scriptArg;
|
||||
@@ -68,7 +68,7 @@ public sealed class AgentFileSkillScriptTests
|
||||
"/skills/owner-skill");
|
||||
|
||||
// Act
|
||||
await script.RunAsync(fileSkill, null, null, CancellationToken.None);
|
||||
await script.RunAsync(fileSkill, new AIFunctionArguments(), 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, JsonElement? a, IServiceProvider? sp, CancellationToken ct) => Task.FromResult<object?>(null);
|
||||
static Task<object?> RunnerAsync(AgentFileSkill s, AgentFileSkillScript sc, AIFunctionArguments a, CancellationToken ct) => Task.FromResult<object?>(null);
|
||||
var script = CreateScript("my-script", "/path/to/my-script.py", RunnerAsync);
|
||||
|
||||
// Assert
|
||||
@@ -87,173 +87,10 @@ 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? runner)
|
||||
private static AgentFileSkillScript CreateScript(string name, string fullPath, AgentFileSkillScriptRunner executor)
|
||||
{
|
||||
var ctor = typeof(AgentFileSkillScript).GetConstructor(
|
||||
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance,
|
||||
@@ -261,14 +98,6 @@ 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, runner]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimal <see cref="IServiceProvider"/> for testing service forwarding.
|
||||
/// </summary>
|
||||
private sealed class TestServiceProvider : IServiceProvider
|
||||
{
|
||||
public object? GetService(Type serviceType) => null;
|
||||
return (AgentFileSkillScript)ctor.Invoke([name, fullPath, executor]);
|
||||
}
|
||||
}
|
||||
|
||||
+15
-13
@@ -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, sp, ct) => Task.FromResult<object?>(null);
|
||||
private static readonly AgentFileSkillScriptRunner s_noOpExecutor = (skill, script, args, 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, sp, ct) =>
|
||||
(skill, script, args, 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], null, null, CancellationToken.None);
|
||||
var scriptResult = await skills[0].Scripts![0].RunAsync(skills[0], new AIFunctionArguments(), 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], null, null, CancellationToken.None));
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => script.RunAsync(skills[0], new AIFunctionArguments(), 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')");
|
||||
JsonElement? capturedArgs = null;
|
||||
AIFunctionArguments? capturedArgs = null;
|
||||
var source = new AgentFileSkillsSource(
|
||||
this._testRoot,
|
||||
(skill, script, args, sp, ct) =>
|
||||
(skill, script, args, ct) =>
|
||||
{
|
||||
capturedArgs = args;
|
||||
return Task.FromResult<object?>("done");
|
||||
@@ -215,15 +215,17 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync(CancellationToken.None);
|
||||
using var argumentsDoc = JsonDocument.Parse("""{"value":26.2,"factor":1.60934}""");
|
||||
var arguments = argumentsDoc.RootElement;
|
||||
await skills[0].Scripts![0].RunAsync(skills[0], arguments, null, CancellationToken.None);
|
||||
var arguments = new AIFunctionArguments
|
||||
{
|
||||
["value"] = 26.2,
|
||||
["factor"] = 1.60934
|
||||
};
|
||||
await skills[0].Scripts![0].RunAsync(skills[0], arguments, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedArgs);
|
||||
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());
|
||||
Assert.Equal(26.2, capturedArgs["value"]);
|
||||
Assert.Equal(1.60934, capturedArgs["factor"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
+12
-72
@@ -5,6 +5,7 @@ using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
|
||||
|
||||
@@ -21,7 +22,7 @@ public sealed class AgentInlineSkillScriptTests
|
||||
var skill = new AgentInlineSkill("test-skill", "Test.", "Instructions.");
|
||||
|
||||
// Act
|
||||
var result = await script.RunAsync(skill, null, null, CancellationToken.None);
|
||||
var result = await script.RunAsync(skill, new AIFunctionArguments(), CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("hello", result?.ToString());
|
||||
@@ -33,11 +34,10 @@ public sealed class AgentInlineSkillScriptTests
|
||||
// Arrange
|
||||
var script = new AgentInlineSkillScript("add", (int a, int b) => a + b);
|
||||
var skill = new AgentInlineSkill("calc-skill", "Calc.", "Instructions.");
|
||||
using var argsDoc = JsonDocument.Parse("""{"a":3,"b":7}""");
|
||||
var args = argsDoc.RootElement;
|
||||
var args = new AIFunctionArguments { ["a"] = 3, ["b"] = 7 };
|
||||
|
||||
// Act
|
||||
var result = await script.RunAsync(skill, args, null, CancellationToken.None);
|
||||
var result = await script.RunAsync(skill, args, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(10, int.Parse(result?.ToString()!));
|
||||
@@ -129,11 +129,10 @@ 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);
|
||||
using var argsDoc = JsonDocument.Parse($$"""{ "request": {{inputJson.GetRawText()}} }""");
|
||||
var args = argsDoc.RootElement;
|
||||
var args = new AIFunctionArguments { ["request"] = inputJson };
|
||||
|
||||
// Act
|
||||
var result = await script.RunAsync(skill, args, null, CancellationToken.None);
|
||||
var result = await script.RunAsync(skill, args, CancellationToken.None);
|
||||
|
||||
// Assert — the custom input type was deserialized and the response was produced
|
||||
Assert.NotNull(result);
|
||||
@@ -146,11 +145,10 @@ public sealed class AgentInlineSkillScriptTests
|
||||
// Arrange
|
||||
var script = new AgentInlineSkillScript("echo", (string message) => message);
|
||||
var skill = new AgentInlineSkill("test-skill", "Test.", "Instructions.");
|
||||
using var argsDoc = JsonDocument.Parse("""{"message":"hello world"}""");
|
||||
var args = argsDoc.RootElement;
|
||||
var args = new AIFunctionArguments { ["message"] = "hello world" };
|
||||
|
||||
// Act
|
||||
var result = await script.RunAsync(skill, args, null, CancellationToken.None);
|
||||
var result = await script.RunAsync(skill, args, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("hello world", result?.ToString());
|
||||
@@ -177,11 +175,10 @@ 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.");
|
||||
using var argsDoc = JsonDocument.Parse("""{"input":"hello"}""");
|
||||
var args = argsDoc.RootElement;
|
||||
var args = new AIFunctionArguments { ["input"] = "hello" };
|
||||
|
||||
// Act
|
||||
var result = await script.RunAsync(skill, args, null, CancellationToken.None);
|
||||
var result = await script.RunAsync(skill, args, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("HELLO", result?.ToString());
|
||||
@@ -194,11 +191,10 @@ 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.");
|
||||
using var argsDoc2 = JsonDocument.Parse("""{"input":"test"}""");
|
||||
var args2 = argsDoc2.RootElement;
|
||||
var args = new AIFunctionArguments { ["input"] = "test" };
|
||||
|
||||
// Act
|
||||
var result = await script.RunAsync(skill, args2, null, CancellationToken.None);
|
||||
var result = await script.RunAsync(skill, args, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("test-suffix", result?.ToString());
|
||||
@@ -227,63 +223,7 @@ 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,11 +433,10 @@ public sealed class AgentInlineSkillTests
|
||||
TotalCount = request.MaxResults,
|
||||
});
|
||||
var inputJson = JsonSerializer.SerializeToElement(new LookupRequest { Query = "test", MaxResults = 3 }, jso);
|
||||
using var argsDoc = JsonDocument.Parse($$"""{ "request": {{inputJson.GetRawText()}} }""");
|
||||
var args = argsDoc.RootElement;
|
||||
var args = new AIFunctionArguments { ["request"] = inputJson };
|
||||
|
||||
// Act
|
||||
var result = await skill.Scripts![0].RunAsync(skill, args, null, CancellationToken.None);
|
||||
var result = await skill.Scripts![0].RunAsync(skill, args, CancellationToken.None);
|
||||
|
||||
// Assert — the custom input was deserialized via skill-level JSO and response was produced
|
||||
Assert.NotNull(result);
|
||||
@@ -457,11 +456,10 @@ public sealed class AgentInlineSkillTests
|
||||
TotalCount = request.MaxResults,
|
||||
}, serializerOptions: scriptJso);
|
||||
var inputJson = JsonSerializer.SerializeToElement(new LookupRequest { Query = "override", MaxResults = 7 }, scriptJso);
|
||||
using var argsDoc = JsonDocument.Parse($$"""{ "request": {{inputJson.GetRawText()}} }""");
|
||||
var args = argsDoc.RootElement;
|
||||
var args = new AIFunctionArguments { ["request"] = inputJson };
|
||||
|
||||
// Act
|
||||
var result = await skill.Scripts![0].RunAsync(skill, args, null, CancellationToken.None);
|
||||
var result = await skill.Scripts![0].RunAsync(skill, args, CancellationToken.None);
|
||||
|
||||
// Assert — per-script JSO takes effect and custom types are properly marshaled
|
||||
Assert.NotNull(result);
|
||||
|
||||
@@ -4,7 +4,6 @@ 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;
|
||||
@@ -16,7 +15,7 @@ namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
|
||||
/// </summary>
|
||||
public sealed class AgentSkillsProviderTests : IDisposable
|
||||
{
|
||||
private static readonly AgentFileSkillScriptRunner s_noOpExecutor = (skill, script, args, sp, ct) => Task.FromResult<object?>(null);
|
||||
private static readonly AgentFileSkillScriptRunner s_noOpExecutor = (skill, script, args, ct) => Task.FromResult<object?>(null);
|
||||
private readonly string _testRoot;
|
||||
private readonly TestAIAgent _agent = new();
|
||||
|
||||
@@ -463,7 +462,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, sp, ct) =>
|
||||
.UseFileScriptRunner((skill, script, args, ct) =>
|
||||
{
|
||||
executorCalled = true;
|
||||
return Task.FromResult<object?>("executed");
|
||||
@@ -488,62 +487,6 @@ 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, sp, ct) => Task.FromResult<object?>(null);
|
||||
private static readonly AgentFileSkillScriptRunner s_noOpExecutor = (skill, script, args, ct) => Task.FromResult<object?>(null);
|
||||
|
||||
private readonly string _testRoot;
|
||||
|
||||
|
||||
+2
-13
@@ -60,20 +60,10 @@ public abstract class IntegrationTest : IDisposable
|
||||
|
||||
protected async ValueTask<DeclarativeWorkflowOptions> CreateOptionsAsync(bool externalConversation = false, params IEnumerable<AIFunction> functionTools)
|
||||
{
|
||||
return await this.CreateOptionsAsync(externalConversation, mcpToolProvider: null, httpRequestHandler: null, functionTools).ConfigureAwait(false);
|
||||
return await this.CreateOptionsAsync(externalConversation, mcpToolProvider: 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())
|
||||
@@ -92,8 +82,7 @@ public abstract class IntegrationTest : IDisposable
|
||||
{
|
||||
ConversationId = conversationId,
|
||||
LoggerFactory = this.Output,
|
||||
McpToolHandler = mcpToolProvider,
|
||||
HttpRequestHandler = httpRequestHandler,
|
||||
McpToolHandler = mcpToolProvider
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
-43
@@ -45,15 +45,6 @@ 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>
|
||||
@@ -259,40 +250,6 @@ 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
@@ -1,32 +0,0 @@
|
||||
#
|
||||
# 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}"
|
||||
+2
-22
@@ -181,7 +181,6 @@ 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);
|
||||
@@ -201,6 +200,7 @@ 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,7 +266,6 @@ 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
|
||||
@@ -375,12 +374,7 @@ 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,
|
||||
HttpRequestHandler = CreateMockHttpRequestHandler().Object,
|
||||
};
|
||||
DeclarativeWorkflowOptions workflowContext = new(mockAgentProvider.Object) { LoggerFactory = this.Output };
|
||||
return DeclarativeWorkflowBuilder.Build<TInput>(yamlReader, workflowContext);
|
||||
}
|
||||
|
||||
@@ -391,18 +385,4 @@ 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
@@ -1,510 +0,0 @@
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
-161
@@ -769,165 +769,4 @@ public sealed class ChatMessageExtensionsTests
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MergeForLastMessageReturnsInputWhenInputMessageIsNull()
|
||||
{
|
||||
// Arrange
|
||||
ChatMessage input = new(ChatRole.User, "hello") { MessageId = "local" };
|
||||
|
||||
// Act
|
||||
ChatMessage result = input.MergeForLastMessage(null);
|
||||
|
||||
// Assert
|
||||
Assert.Same(input, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MergeForLastMessageReturnsSameInstanceAsRoundTripped()
|
||||
{
|
||||
// Arrange: returning the round-tripped instance keeps the merge forward-compatible
|
||||
// with future ChatMessage properties (e.g., new metadata fields) without explicit copies.
|
||||
ChatMessage input = new(ChatRole.User, "original");
|
||||
ChatMessage roundTripped = new(ChatRole.User, "stripped") { MessageId = "server" };
|
||||
|
||||
// Act
|
||||
ChatMessage result = input.MergeForLastMessage(roundTripped);
|
||||
|
||||
// Assert
|
||||
Assert.Same(roundTripped, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MergeForLastMessagePrefersOriginalTextOverRoundTrippedText()
|
||||
{
|
||||
// Arrange
|
||||
ChatMessage input = new(ChatRole.User, "original text");
|
||||
ChatMessage roundTripped = new(ChatRole.User, "stripped") { MessageId = "server-id" };
|
||||
|
||||
// Act
|
||||
ChatMessage result = input.MergeForLastMessage(roundTripped);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("server-id", result.MessageId);
|
||||
Assert.Equal("original text", result.Text);
|
||||
TextContent text = Assert.IsType<TextContent>(Assert.Single(result.Contents));
|
||||
Assert.Equal("original text", text.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MergeForLastMessageReplacesTextInPlaceAndKeepsServerMedia()
|
||||
{
|
||||
// Arrange
|
||||
HostedFileContent serverRef = new("file-abc");
|
||||
ChatMessage input = new(ChatRole.User, [new TextContent("look at this:"), new DataContent("data:image/jpeg;base64,QUJD", "image/jpeg")]);
|
||||
ChatMessage roundTripped = new(ChatRole.User, [new TextContent("stripped"), serverRef]) { MessageId = "server-id" };
|
||||
|
||||
// Act
|
||||
ChatMessage result = input.MergeForLastMessage(roundTripped);
|
||||
|
||||
// Assert: server's text slot is replaced with original text; server's media reference is preserved.
|
||||
Assert.Equal("server-id", result.MessageId);
|
||||
Assert.Collection(result.Contents,
|
||||
c => Assert.Equal("look at this:", Assert.IsType<TextContent>(c).Text),
|
||||
c => Assert.Same(serverRef, c));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MergeForLastMessageAppendsOriginalTextWhenRoundTripHasNoTextSlot()
|
||||
{
|
||||
// Arrange: round-tripped message has only media (no text slot to replace).
|
||||
HostedFileContent serverRef = new("file-1");
|
||||
ChatMessage input = new(ChatRole.User, [new TextContent("middle"), new DataContent("data:image/jpeg;base64,QUE=", "image/jpeg")]);
|
||||
ChatMessage roundTripped = new(ChatRole.User, [serverRef]) { MessageId = "id" };
|
||||
|
||||
// Act
|
||||
ChatMessage result = input.MergeForLastMessage(roundTripped);
|
||||
|
||||
// Assert: media kept; original text appended at end.
|
||||
Assert.Collection(result.Contents,
|
||||
c => Assert.Same(serverRef, c),
|
||||
c => Assert.Equal("middle", Assert.IsType<TextContent>(c).Text));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MergeForLastMessageReplacesMultipleTextSlotsInOrder()
|
||||
{
|
||||
// Arrange: input has two text items; round-tripped has two text slots interleaved with media.
|
||||
HostedFileContent firstRef = new("file-1");
|
||||
HostedFileContent secondRef = new("file-2");
|
||||
ChatMessage input = new(ChatRole.User, [new TextContent("first"), new TextContent("second")]);
|
||||
ChatMessage roundTripped = new(ChatRole.User, [firstRef, new TextContent("a"), secondRef, new TextContent("b")]) { MessageId = "id" };
|
||||
|
||||
// Act
|
||||
ChatMessage result = input.MergeForLastMessage(roundTripped);
|
||||
|
||||
// Assert
|
||||
Assert.Collection(result.Contents,
|
||||
c => Assert.Same(firstRef, c),
|
||||
c => Assert.Equal("first", Assert.IsType<TextContent>(c).Text),
|
||||
c => Assert.Same(secondRef, c),
|
||||
c => Assert.Equal("second", Assert.IsType<TextContent>(c).Text));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MergeForLastMessageFallsBackToInputTextWhenInputHasNoTextContent()
|
||||
{
|
||||
// Arrange: ChatMessage(role, "string") populates Text but no explicit TextContent
|
||||
// when Contents is initially empty in some construction paths. Verify we still
|
||||
// recover the original Text via input.Text.
|
||||
ChatMessage input = new(ChatRole.User, "fallback text");
|
||||
ChatMessage roundTripped = new(ChatRole.User, [new TextContent("stripped")]) { MessageId = "id" };
|
||||
|
||||
// Act
|
||||
ChatMessage result = input.MergeForLastMessage(roundTripped);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("fallback text", Assert.IsType<TextContent>(Assert.Single(result.Contents)).Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MergeForLastMessagePreservesServerAuthoredProperties()
|
||||
{
|
||||
// Arrange: server (round-trip) is authoritative for metadata. Returning the
|
||||
// round-tripped instance means any future ChatMessage property is automatically
|
||||
// preserved without code changes here.
|
||||
ChatMessage input = new(ChatRole.User, "hi")
|
||||
{
|
||||
AuthorName = "client-side",
|
||||
AdditionalProperties = new AdditionalPropertiesDictionary { ["client"] = "value" },
|
||||
};
|
||||
ChatMessage roundTripped = new(ChatRole.User, [new TextContent("stripped")])
|
||||
{
|
||||
MessageId = "server",
|
||||
AuthorName = "server-side",
|
||||
AdditionalProperties = new AdditionalPropertiesDictionary { ["server"] = "value" },
|
||||
};
|
||||
|
||||
// Act
|
||||
ChatMessage result = input.MergeForLastMessage(roundTripped);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("server", result.MessageId);
|
||||
Assert.Equal("server-side", result.AuthorName);
|
||||
Assert.NotNull(result.AdditionalProperties);
|
||||
Assert.True(result.AdditionalProperties.ContainsKey("server"));
|
||||
Assert.False(result.AdditionalProperties.ContainsKey("client"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MergeForLastMessageHandlesEmptyInputContents()
|
||||
{
|
||||
// Arrange
|
||||
ChatMessage input = new(ChatRole.User, new List<AIContent>());
|
||||
HostedFileContent serverRef = new("file-only");
|
||||
ChatMessage roundTripped = new(ChatRole.User, [serverRef]) { MessageId = "id" };
|
||||
|
||||
// Act
|
||||
ChatMessage result = input.MergeForLastMessage(roundTripped);
|
||||
|
||||
// Assert: nothing to splice; round-tripped returned unchanged.
|
||||
Assert.Same(roundTripped, result);
|
||||
Assert.Equal("file-only", Assert.IsType<HostedFileContent>(Assert.Single(result.Contents)).FileId);
|
||||
}
|
||||
}
|
||||
|
||||
-759
@@ -1,759 +0,0 @@
|
||||
// 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
@@ -1,15 +0,0 @@
|
||||
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.11.6
|
||||
rev: 0.10.10
|
||||
hooks:
|
||||
# Update the uv lockfile
|
||||
- id: uv-lock
|
||||
|
||||
@@ -69,7 +69,6 @@ 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
|
||||
|
||||
+3
-64
@@ -7,64 +7,6 @@ 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
|
||||
@@ -84,7 +26,8 @@ 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
|
||||
|
||||
@@ -1018,11 +961,7 @@ 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.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
|
||||
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.1.0...HEAD
|
||||
[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,7 +18,6 @@ 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,48 +4,20 @@ Agent-to-Agent (A2A) protocol support for inter-agent communication.
|
||||
|
||||
## Main Classes
|
||||
|
||||
- **`A2AAgent`** - Client to connect to remote A2A-compliant agents.
|
||||
- **`A2AExecutor`** - Bridge to expose Agent Framework agents via the A2A protocol.
|
||||
- **`A2AAgent`** - Agent wrapper that exposes an agent via the A2A protocol
|
||||
|
||||
## Usage
|
||||
|
||||
### A2AAgent (Client)
|
||||
|
||||
```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 (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()
|
||||
a2a_agent = A2AAgent(agent=my_agent)
|
||||
```
|
||||
|
||||
## Import Path
|
||||
|
||||
```python
|
||||
from agent_framework.a2a import A2AAgent, A2AExecutor
|
||||
from agent_framework.a2a import A2AAgent
|
||||
# or directly:
|
||||
from agent_framework_a2a import A2AAgent, A2AExecutor
|
||||
from agent_framework_a2a import A2AAgent
|
||||
```
|
||||
|
||||
@@ -10,49 +10,11 @@ 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,7 +2,6 @@
|
||||
|
||||
import importlib.metadata
|
||||
|
||||
from ._a2a_executor import A2AExecutor
|
||||
from ._agent import A2AAgent, A2AContinuationToken
|
||||
|
||||
try:
|
||||
@@ -13,6 +12,5 @@ except importlib.metadata.PackageNotFoundError:
|
||||
__all__ = [
|
||||
"A2AAgent",
|
||||
"A2AContinuationToken",
|
||||
"A2AExecutor",
|
||||
"__version__",
|
||||
]
|
||||
|
||||
@@ -1,275 +0,0 @@
|
||||
# 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,6 +4,7 @@ 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
|
||||
@@ -48,7 +49,7 @@ from agent_framework.observability import AgentTelemetryLayer
|
||||
|
||||
__all__ = ["A2AAgent", "A2AContinuationToken"]
|
||||
|
||||
from agent_framework_a2a._utils import get_uri_data
|
||||
URI_PATTERN = re.compile(r"^data:(?P<media_type>[^;]+);base64,(?P<base64_data>[A-Za-z0-9+/=]+)$")
|
||||
|
||||
|
||||
class A2AContinuationToken(ContinuationToken):
|
||||
@@ -77,6 +78,14 @@ 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.
|
||||
|
||||
@@ -643,7 +652,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,
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
# 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.0b260429"
|
||||
version = "1.0.0b260423"
|
||||
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.2.2,<2",
|
||||
"agent-framework-core>=1.1.1,<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._utils import get_uri_data
|
||||
from agent_framework_a2a._agent import _get_uri_data # type: ignore
|
||||
|
||||
|
||||
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:
|
||||
|
||||
@@ -1,910 +0,0 @@
|
||||
# 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()
|
||||
@@ -1,41 +0,0 @@
|
||||
# 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,21 +263,27 @@ def _deduplicate_messages(messages: list[Message]) -> list[Message]:
|
||||
return unique_messages
|
||||
|
||||
|
||||
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.
|
||||
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")
|
||||
|
||||
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"))
|
||||
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"),
|
||||
)
|
||||
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()
|
||||
@@ -288,31 +294,14 @@ def _extract_multimodal_source_fields(
|
||||
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("value") or source_dict.get("data"))
|
||||
data = cast(str | None, 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("value") or source_dict.get("data") or data)
|
||||
data = cast(str | None, 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)
|
||||
|
||||
@@ -400,7 +389,30 @@ 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"}
|
||||
url, data, binary_id, mime_type = _extract_multimodal_source_fields(part)
|
||||
|
||||
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)
|
||||
|
||||
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="reasoning"))
|
||||
events.append(ReasoningMessageStartEvent(message_id=message_id, role="assistant"))
|
||||
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="reasoning"))
|
||||
events.append(ReasoningMessageStartEvent(message_id=message_id, role="assistant"))
|
||||
|
||||
if text:
|
||||
events.append(ReasoningMessageContentEvent(message_id=message_id, delta=text))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "agent-framework-ag-ui"
|
||||
version = "1.0.0b260429"
|
||||
version = "1.0.0b260423"
|
||||
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.2.2,<2",
|
||||
"ag-ui-protocol>=0.1.16,<0.2",
|
||||
"agent-framework-core>=1.1.1,<2",
|
||||
"ag-ui-protocol==0.1.13",
|
||||
"fastapi>=0.115.0,<0.133.1",
|
||||
"uvicorn[standard]>=0.30.0,<0.42.0"
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest==9.0.3",
|
||||
"pytest==9.0.2",
|
||||
"httpx==0.28.1",
|
||||
]
|
||||
|
||||
|
||||
@@ -536,77 +536,6 @@ 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 = {
|
||||
@@ -1831,67 +1760,3 @@ 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 == "reasoning"
|
||||
assert events[1].role == "assistant"
|
||||
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,37 +1642,6 @@ 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
|
||||
|
||||
@@ -872,8 +872,6 @@ class RawAnthropicClient(
|
||||
tool_mode = validate_tool_mode(options.get("tool_choice"))
|
||||
if tool_mode is None:
|
||||
return result or None
|
||||
if "allowed_tools" in tool_mode:
|
||||
logger.warning("allowed_tools is not supported by Anthropic; the setting will be ignored")
|
||||
allow_multiple = options.get("allow_multiple_tool_calls")
|
||||
match tool_mode.get("mode"):
|
||||
case "auto":
|
||||
|
||||
@@ -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.0b260429"
|
||||
version = "1.0.0b260423"
|
||||
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.2.2,<2",
|
||||
"agent-framework-core>=1.1.1,<2",
|
||||
"anthropic>=0.80.0,<0.80.1",
|
||||
]
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Azure AI Search integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260429"
|
||||
version = "1.0.0b260423"
|
||||
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.2.2,<2",
|
||||
"agent-framework-core>=1.1.1,<2",
|
||||
"azure-search-documents>=11.7.0b2,<11.7.0b3",
|
||||
]
|
||||
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
# Local-only files (not committed)
|
||||
_local_only/
|
||||
*_local_only*
|
||||
@@ -1,71 +0,0 @@
|
||||
# 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
|
||||
```
|
||||
@@ -1,21 +0,0 @@
|
||||
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
|
||||
@@ -1,127 +0,0 @@
|
||||
# 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
@@ -1,28 +0,0 @@
|
||||
# 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__",
|
||||
]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user