mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63c0a51797 | ||
|
|
b00465d7be | ||
|
|
4adfd244ac | ||
|
|
932ceddf95 | ||
|
|
0989e68d1c | ||
|
|
b084d0461d | ||
|
|
5fe8941ff9 | ||
|
|
0dbcc9fe9d | ||
|
|
4d3e4f865f |
@@ -22,9 +22,9 @@
|
||||
<PackageVersion Include="Aspire.Microsoft.Azure.Cosmos" Version="$(AspireAppHostSdkVersion)" />
|
||||
<PackageVersion Include="CommunityToolkit.Aspire.OllamaSharp" Version="13.0.0" />
|
||||
<!-- Azure.* -->
|
||||
<PackageVersion Include="Azure.AI.AgentServer.Core" Version="1.0.0-beta.22" />
|
||||
<PackageVersion Include="Azure.AI.AgentServer.Invocations" Version="1.0.0-beta.1" />
|
||||
<PackageVersion Include="Azure.AI.AgentServer.Responses" Version="1.0.0-beta.3" />
|
||||
<PackageVersion Include="Azure.AI.AgentServer.Core" Version="1.0.0-beta.23" />
|
||||
<PackageVersion Include="Azure.AI.AgentServer.Invocations" Version="1.0.0-beta.3" />
|
||||
<PackageVersion Include="Azure.AI.AgentServer.Responses" Version="1.0.0-beta.4" />
|
||||
<PackageVersion Include="Azure.AI.Projects" Version="2.0.0" />
|
||||
<PackageVersion Include="Azure.AI.Agents.Persistent" Version="1.2.0-beta.10" />
|
||||
<PackageVersion Include="Azure.AI.OpenAI" Version="2.9.0-beta.1" />
|
||||
@@ -188,4 +188,4 @@
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
@@ -160,6 +160,7 @@
|
||||
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step22_MemorySearch/Agent_Step22_MemorySearch.csproj" />
|
||||
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step23_LocalMCP/Agent_Step23_LocalMCP.csproj" />
|
||||
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step24_CodeInterpreterFileDownload/Agent_Step24_CodeInterpreterFileDownload.csproj" />
|
||||
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step25_ToolboxServerSideTools/Agent_Step25_ToolboxServerSideTools.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/02-agents/Evaluation/">
|
||||
<Project Path="samples/02-agents/Evaluation/Evaluation_SimpleEval/Evaluation_SimpleEval.csproj" />
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<!-- Central version prefix - applies to all nuget packages. -->
|
||||
<VersionPrefix>1.2.0</VersionPrefix>
|
||||
<VersionPrefix>1.3.0</VersionPrefix>
|
||||
<RCNumber>1</RCNumber>
|
||||
<DateSuffix>260421</DateSuffix>
|
||||
<DateSuffix>260423</DateSuffix>
|
||||
<PackageVersion Condition="'$(IsReleaseCandidate)' == 'true'">$(VersionPrefix)-rc$(RCNumber)</PackageVersion>
|
||||
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).$(DateSuffix).1</PackageVersion>
|
||||
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' == ''">$(VersionPrefix)-preview.$(DateSuffix).1</PackageVersion>
|
||||
<PackageVersion Condition="'$(IsReleased)' == 'true'">$(VersionPrefix)</PackageVersion>
|
||||
<GitTag>1.2.0</GitTag>
|
||||
<GitTag>1.3.0</GitTag>
|
||||
|
||||
<Configurations>Debug;Release;Publish</Configurations>
|
||||
<IsPackable>true</IsPackable>
|
||||
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample shows how to load a Foundry toolbox and pass its tools as server-side
|
||||
// tools when creating an agent. The Foundry platform handles tool execution — the agent
|
||||
// process does not invoke tools locally.
|
||||
|
||||
using System.ClientModel;
|
||||
using System.ClientModel.Primitives;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.AI.Projects.Agents;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using OpenAI.Responses;
|
||||
|
||||
#pragma warning disable OPENAI001 // Experimental API
|
||||
#pragma warning disable AAIP001 // AgentToolboxes is experimental
|
||||
#pragma warning disable CS8321 // Local functions may be commented-out alternatives
|
||||
|
||||
// Replace with your own Foundry toolbox name.
|
||||
const string ToolboxName = "research_toolbox";
|
||||
// Used only by CombineToolboxes — swap in a second toolbox you own.
|
||||
const string SecondToolboxName = "analysis_toolbox";
|
||||
// Replace with any question that exercises the tools configured in your toolbox.
|
||||
const string Query = "Introduce yourself and briefly describe the tools you can use to help me.";
|
||||
|
||||
string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT")
|
||||
?? throw new InvalidOperationException("Set FOUNDRY_PROJECT_ENDPOINT to your Foundry project endpoint.");
|
||||
string model = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini";
|
||||
|
||||
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
|
||||
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
|
||||
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
|
||||
var projectClient = new AIProjectClient(new Uri(endpoint), new DefaultAzureCredential());
|
||||
|
||||
await Main(projectClient, model, endpoint);
|
||||
// await CombineToolboxes(projectClient, model, endpoint);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main: single toolbox
|
||||
// ---------------------------------------------------------------------------
|
||||
static async Task Main(AIProjectClient projectClient, string model, string endpoint)
|
||||
{
|
||||
Console.WriteLine("=== Foundry Toolbox Server-Side Tools Example ===");
|
||||
|
||||
// Comment out if the toolbox already exists in your Foundry project.
|
||||
await CreateSampleToolboxAsync(ToolboxName, endpoint);
|
||||
|
||||
// Omit the version to resolve the toolbox's current default version at runtime.
|
||||
var tools = await projectClient.GetToolboxToolsAsync(ToolboxName);
|
||||
|
||||
AIAgent agent = projectClient
|
||||
.AsAIAgent(
|
||||
model: model,
|
||||
instructions: "You are a research assistant. Use the available tools to answer questions.",
|
||||
tools: tools.ToList());
|
||||
|
||||
Console.WriteLine($"User: {Query}");
|
||||
Console.WriteLine($"Result: {await agent.RunAsync(Query)}\n");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Alternative: combine tools from multiple toolboxes
|
||||
// ---------------------------------------------------------------------------
|
||||
static async Task CombineToolboxes(AIProjectClient projectClient, string model, string endpoint)
|
||||
{
|
||||
Console.WriteLine("=== Combine Toolboxes Example ===");
|
||||
|
||||
// Comment out if the toolboxes already exist in your Foundry project.
|
||||
await CreateSampleToolboxAsync(ToolboxName, endpoint);
|
||||
await CreateSampleToolboxAsync(SecondToolboxName, endpoint);
|
||||
|
||||
var toolboxA = await projectClient.GetToolboxToolsAsync(ToolboxName);
|
||||
var toolboxB = await projectClient.GetToolboxToolsAsync(SecondToolboxName);
|
||||
|
||||
var allTools = toolboxA.Concat(toolboxB).ToList();
|
||||
|
||||
AIAgent agent = projectClient
|
||||
.AsAIAgent(
|
||||
model: model,
|
||||
instructions: "You are a research assistant. Use all available tools to answer questions.",
|
||||
tools: allTools);
|
||||
|
||||
Console.WriteLine($"User: {Query}");
|
||||
Console.WriteLine($"Combined-toolbox result: {await agent.RunAsync(Query)}\n");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: create (or replace) a sample toolbox so the sample works out-of-the-box
|
||||
// ---------------------------------------------------------------------------
|
||||
static async Task CreateSampleToolboxAsync(string name, string endpoint)
|
||||
{
|
||||
// Toolboxes are normally configured in the Foundry portal or a deployment
|
||||
// script, not the application itself. This helper exists so the sample can
|
||||
// be run end-to-end without first setting a toolbox up by hand.
|
||||
|
||||
// The Foundry-Features header is currently required for toolbox CRUD operations.
|
||||
var options = new AgentAdministrationClientOptions();
|
||||
options.AddPolicy(new FoundryFeaturesPolicy("Toolboxes=V1Preview"), PipelinePosition.PerCall);
|
||||
var adminClient = new AgentAdministrationClient(
|
||||
new Uri(endpoint),
|
||||
new DefaultAzureCredential(),
|
||||
options);
|
||||
var toolboxClient = adminClient.GetAgentToolboxes();
|
||||
|
||||
// Delete existing toolbox if present (ignore 404).
|
||||
try
|
||||
{
|
||||
await toolboxClient.DeleteToolboxAsync(name);
|
||||
Console.WriteLine($"Deleted existing toolbox '{name}'");
|
||||
}
|
||||
catch (ClientResultException ex) when (ex.Status == 404)
|
||||
{
|
||||
// Toolbox does not exist — nothing to delete.
|
||||
}
|
||||
|
||||
// Create a fresh version with a single MCP tool.
|
||||
ProjectsAgentTool mcpTool = ProjectsAgentTool.AsProjectTool(ResponseTool.CreateMcpTool(
|
||||
serverLabel: "api-specs",
|
||||
serverUri: new Uri("https://gitmcp.io/Azure/azure-rest-api-specs"),
|
||||
toolCallApprovalPolicy: new McpToolCallApprovalPolicy(GlobalMcpToolCallApprovalPolicy.NeverRequireApproval)));
|
||||
|
||||
var created = (await toolboxClient.CreateToolboxVersionAsync(
|
||||
name: name,
|
||||
tools: [mcpTool],
|
||||
description: "Sample toolbox with an MCP tool — created by Agent_Step25 sample.")).Value;
|
||||
|
||||
Console.WriteLine($"Created toolbox '{created.Name}' v{created.Version} ({created.Tools.Count} tool(s))");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pipeline policy that adds the Foundry-Features header for toolbox CRUD
|
||||
// ---------------------------------------------------------------------------
|
||||
internal sealed class FoundryFeaturesPolicy(string feature) : PipelinePolicy
|
||||
{
|
||||
private const string FeatureHeader = "Foundry-Features";
|
||||
|
||||
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
message.Request.Headers.Add(FeatureHeader, feature);
|
||||
ProcessNext(message, pipeline, currentIndex);
|
||||
}
|
||||
|
||||
public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
message.Request.Headers.Add(FeatureHeader, feature);
|
||||
return ProcessNextAsync(message, pipeline, currentIndex);
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
# Agent_Step25_ToolboxServerSideTools
|
||||
|
||||
This sample demonstrates loading a named Foundry toolbox and passing its tools as
|
||||
**server-side tools** when creating an agent via `AsAIAgent()`.
|
||||
|
||||
When tools from a toolbox are passed this way, they are sent as tool definitions in
|
||||
the Responses API request. The Foundry platform handles tool execution — the agent
|
||||
process does not invoke tools locally.
|
||||
|
||||
This is the dotnet equivalent of the Python sample:
|
||||
`python/samples/02-agents/providers/foundry/foundry_chat_client_with_toolbox.py`
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A Microsoft Foundry project
|
||||
- `AZURE_AI_PROJECT_ENDPOINT` environment variable set to your Foundry project endpoint
|
||||
- `AZURE_AI_MODEL_DEPLOYMENT_NAME` environment variable set (defaults to `gpt-5.4-mini`)
|
||||
|
||||
The sample recreates the toolbox on each run, replacing any existing toolbox with
|
||||
the same name. Comment out the `CreateSampleToolboxAsync` call if you want to keep
|
||||
an existing toolbox unchanged.
|
||||
|
||||
## How it works
|
||||
|
||||
1. `projectClient.GetToolboxVersionAsync(name)` fetches the toolbox definition from the
|
||||
Foundry project API (resolving the default version if none is specified)
|
||||
2. `ToolboxVersion.ToAITools()` converts each tool definition to an `AITool` instance
|
||||
3. The tools are passed to `AsAIAgent(tools: ...)` which includes them in the Responses
|
||||
API request as server-side tool definitions
|
||||
|
||||
For a one-liner, use `projectClient.GetToolboxToolsAsync(name)` to fetch and convert in one call.
|
||||
|
||||
## Sample flows
|
||||
|
||||
| Flow | Description |
|
||||
|------|-------------|
|
||||
| `Main` (default) | Loads a single toolbox and runs an agent with its tools |
|
||||
| `CombineToolboxes` | Loads two toolboxes and merges their tools into one agent |
|
||||
|
||||
Uncomment the desired flow in the top-level statements to try each one.
|
||||
|
||||
## Running the sample
|
||||
|
||||
```bash
|
||||
dotnet run
|
||||
```
|
||||
@@ -0,0 +1,57 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
#pragma warning disable OPENAI001
|
||||
#pragma warning disable AAIP001 // AgentToolboxes is experimental in Azure.AI.Projects.Agents
|
||||
|
||||
namespace Azure.AI.Projects;
|
||||
|
||||
/// <summary>
|
||||
/// Provides extension methods on <see cref="AIProjectClient"/> for fetching
|
||||
/// Foundry toolbox definitions as server-side tools.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// These extensions mirror Python's <c>FoundryChatClient.get_toolbox()</c> pattern,
|
||||
/// allowing a single call on the project client to retrieve tools ready for use
|
||||
/// with <c>AsAIAgent(model, instructions, tools: ...)</c>.
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
|
||||
public static class AIProjectClientToolboxExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Fetches a toolbox from the Foundry project and returns its tools as <see cref="AITool"/> instances
|
||||
/// ready for use as server-side tools in the Responses API.
|
||||
/// </summary>
|
||||
/// <param name="projectClient">The <see cref="AIProjectClient"/> to use. Cannot be <see langword="null"/>.</param>
|
||||
/// <param name="name">The name of the toolbox to fetch.</param>
|
||||
/// <param name="version">
|
||||
/// The specific toolbox version to fetch. When <see langword="null"/>, the toolbox's
|
||||
/// default version is resolved automatically.
|
||||
/// </param>
|
||||
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
|
||||
/// <returns>A read-only list of <see cref="AITool"/> instances from the toolbox.</returns>
|
||||
/// <exception cref="System.ArgumentNullException">
|
||||
/// Thrown when <paramref name="projectClient"/> or <paramref name="name"/> is <see langword="null"/>.
|
||||
/// </exception>
|
||||
public static async Task<IReadOnlyList<AITool>> GetToolboxToolsAsync(
|
||||
this AIProjectClient projectClient,
|
||||
string name,
|
||||
string? version = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Throw.IfNull(projectClient);
|
||||
Throw.IfNullOrWhitespace(name);
|
||||
|
||||
var toolboxClient = projectClient.AgentAdministrationClient.GetAgentToolboxes();
|
||||
var toolboxVersion = await FoundryToolbox.GetToolboxVersionCoreAsync(toolboxClient, name, version, cancellationToken).ConfigureAwait(false);
|
||||
return toolboxVersion.ToAITools();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.ClientModel;
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.Projects.Agents;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
using OpenAI.Responses;
|
||||
|
||||
#pragma warning disable OPENAI001
|
||||
#pragma warning disable AAIP001 // AgentToolboxes is experimental in Azure.AI.Projects.Agents
|
||||
#pragma warning disable IL2026 // ModelReaderWriter.Read<ResponseTool> uses reflection; suppressed for Azure SDK model types.
|
||||
#pragma warning disable IL3050 // ModelReaderWriter.Read<ResponseTool> requires dynamic code; suppressed for Azure SDK model types.
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Provides methods for fetching Foundry toolbox definitions and converting their tools
|
||||
/// to <see cref="AITool"/> instances for use as server-side tools in the Responses API.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// When tools from a toolbox are passed to a Foundry agent (e.g. via <c>AsAIAgent(model, instructions, tools: ...)</c>),
|
||||
/// they are sent as server-side tool definitions in the Responses API request. The Foundry platform
|
||||
/// handles tool execution — the agent process does not invoke tools locally.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This is the dotnet equivalent of Python's <c>FoundryChatClient.get_toolbox()</c> pattern.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
|
||||
public static class FoundryToolbox
|
||||
{
|
||||
/// <summary>
|
||||
/// Fetches a toolbox version from the Foundry project and returns the raw SDK <see cref="ToolboxVersion"/>.
|
||||
/// </summary>
|
||||
/// <param name="projectEndpoint">The Foundry project endpoint URI.</param>
|
||||
/// <param name="credential">The authentication credential used to access the Foundry project.</param>
|
||||
/// <param name="name">The name of the toolbox to fetch.</param>
|
||||
/// <param name="version">
|
||||
/// The specific toolbox version to fetch. When <see langword="null"/>, the toolbox's
|
||||
/// default version is resolved automatically (requires an additional API call).
|
||||
/// </param>
|
||||
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
|
||||
/// <returns>The <see cref="ToolboxVersion"/> containing tool definitions.</returns>
|
||||
/// <exception cref="ArgumentNullException">
|
||||
/// Thrown when <paramref name="projectEndpoint"/>, <paramref name="credential"/>, or <paramref name="name"/> is <see langword="null"/>.
|
||||
/// </exception>
|
||||
/// <exception cref="ClientResultException">Thrown when the Foundry project API returns an error.</exception>
|
||||
public static async Task<ToolboxVersion> GetToolboxVersionAsync(
|
||||
Uri projectEndpoint,
|
||||
AuthenticationTokenProvider credential,
|
||||
string name,
|
||||
string? version = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Throw.IfNull(projectEndpoint);
|
||||
Throw.IfNull(credential);
|
||||
Throw.IfNullOrWhitespace(name);
|
||||
|
||||
var toolboxClient = CreateToolboxClient(projectEndpoint, credential);
|
||||
return await GetToolboxVersionCoreAsync(toolboxClient, name, version, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches a toolbox from the Foundry project and returns its tools as <see cref="AITool"/> instances
|
||||
/// ready for use as server-side tools in the Responses API.
|
||||
/// </summary>
|
||||
/// <param name="projectEndpoint">The Foundry project endpoint URI.</param>
|
||||
/// <param name="credential">The authentication credential used to access the Foundry project.</param>
|
||||
/// <param name="name">The name of the toolbox to fetch.</param>
|
||||
/// <param name="version">
|
||||
/// The specific toolbox version to fetch. When <see langword="null"/>, the toolbox's
|
||||
/// default version is resolved automatically.
|
||||
/// </param>
|
||||
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
|
||||
/// <returns>A read-only list of <see cref="AITool"/> instances from the toolbox.</returns>
|
||||
/// <exception cref="ArgumentNullException">
|
||||
/// Thrown when <paramref name="projectEndpoint"/>, <paramref name="credential"/>, or <paramref name="name"/> is <see langword="null"/>.
|
||||
/// </exception>
|
||||
/// <exception cref="ClientResultException">Thrown when the Foundry project API returns an error.</exception>
|
||||
public static async Task<IReadOnlyList<AITool>> GetToolsAsync(
|
||||
Uri projectEndpoint,
|
||||
AuthenticationTokenProvider credential,
|
||||
string name,
|
||||
string? version = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var toolboxVersion = await GetToolboxVersionAsync(projectEndpoint, credential, name, version, cancellationToken).ConfigureAwait(false);
|
||||
return toolboxVersion.ToAITools();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts the tools in a <see cref="ToolboxVersion"/> to <see cref="AITool"/> instances
|
||||
/// suitable for use as server-side tools in the Responses API.
|
||||
/// </summary>
|
||||
/// <param name="toolboxVersion">The toolbox version whose tools to convert.</param>
|
||||
/// <returns>A read-only list of <see cref="AITool"/> instances.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="toolboxVersion"/> is <see langword="null"/>.</exception>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Each <see cref="ProjectsAgentTool"/> in the toolbox is cast to <see cref="ResponseTool"/>
|
||||
/// and converted via <c>AsAITool()</c>. Non-function hosted tools (MCP, web_search,
|
||||
/// code_interpreter, etc.) are included as server-side tool definitions — the Foundry
|
||||
/// platform handles their execution.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Non-function tools are sanitized to remove decoration fields (<c>name</c>, <c>description</c>)
|
||||
/// that the toolbox API returns but the Responses API rejects.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static IReadOnlyList<AITool> ToAITools(this ToolboxVersion toolboxVersion)
|
||||
{
|
||||
Throw.IfNull(toolboxVersion);
|
||||
|
||||
if (toolboxVersion.Tools?.Any() != true)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return toolboxVersion.Tools
|
||||
.Select(SanitizeAndConvert)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
#region Internal helpers (visible to unit tests via InternalsVisibleTo)
|
||||
|
||||
/// <summary>
|
||||
/// Sanitizes a <see cref="ProjectsAgentTool"/> by removing decoration fields that the
|
||||
/// toolbox API returns but the Responses API rejects, then converts to <see cref="AITool"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The Azure AI Projects toolbox API may return <c>name</c> and <c>description</c> on
|
||||
/// hosted tool objects (MCP, code_interpreter, file_search, etc.). The Responses API
|
||||
/// rejects at least <c>name</c> with "Unknown parameter: 'tools[0].name'". We strip
|
||||
/// these decoration fields for non-function tools. Function tools keep them since
|
||||
/// <c>name</c> and <c>description</c> are expected parts of the function schema.
|
||||
/// </remarks>
|
||||
internal static AITool SanitizeAndConvert(ProjectsAgentTool tool)
|
||||
{
|
||||
var toolJson = ModelReaderWriter.Write(tool, new ModelReaderWriterOptions("J"));
|
||||
var node = JsonNode.Parse(toolJson.ToString());
|
||||
if (node is not JsonObject obj)
|
||||
{
|
||||
return ((ResponseTool)tool).AsAITool();
|
||||
}
|
||||
|
||||
var toolType = obj["type"]?.GetValue<string>();
|
||||
|
||||
// Function tools need name/description — don't strip
|
||||
if (toolType is "function" or "custom")
|
||||
{
|
||||
return ((ResponseTool)tool).AsAITool();
|
||||
}
|
||||
|
||||
// Strip decoration fields that the Responses API rejects
|
||||
bool modified = false;
|
||||
modified |= obj.Remove("name");
|
||||
modified |= obj.Remove("description");
|
||||
|
||||
if (!modified)
|
||||
{
|
||||
return ((ResponseTool)tool).AsAITool();
|
||||
}
|
||||
|
||||
var sanitizedJson = obj.ToJsonString();
|
||||
var sanitizedTool = ModelReaderWriter.Read<ResponseTool>(BinaryData.FromString(sanitizedJson))!;
|
||||
return sanitizedTool.AsAITool();
|
||||
}
|
||||
|
||||
internal static async Task<ToolboxVersion> GetToolboxVersionAsync(
|
||||
Uri projectEndpoint,
|
||||
AuthenticationTokenProvider credential,
|
||||
string name,
|
||||
string? version,
|
||||
AgentAdministrationClientOptions? clientOptions,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Throw.IfNull(projectEndpoint);
|
||||
Throw.IfNull(credential);
|
||||
Throw.IfNullOrWhitespace(name);
|
||||
|
||||
var toolboxClient = CreateToolboxClient(projectEndpoint, credential, clientOptions);
|
||||
return await GetToolboxVersionCoreAsync(toolboxClient, name, version, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
internal static AgentToolboxes CreateToolboxClient(
|
||||
Uri projectEndpoint,
|
||||
AuthenticationTokenProvider credential,
|
||||
AgentAdministrationClientOptions? clientOptions = null)
|
||||
{
|
||||
clientOptions ??= new AgentAdministrationClientOptions();
|
||||
var adminClient = new AgentAdministrationClient(projectEndpoint, credential, clientOptions);
|
||||
return adminClient.GetAgentToolboxes();
|
||||
}
|
||||
|
||||
internal static async Task<ToolboxVersion> GetToolboxVersionCoreAsync(
|
||||
AgentToolboxes toolboxClient,
|
||||
string name,
|
||||
string? version,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (version is null)
|
||||
{
|
||||
var record = await toolboxClient.GetToolboxAsync(name, cancellationToken).ConfigureAwait(false);
|
||||
version = record.Value.DefaultVersion
|
||||
?? throw new InvalidOperationException($"Toolbox '{name}' does not have a default version. Specify an explicit version.");
|
||||
}
|
||||
|
||||
var result = await toolboxClient.GetToolboxVersionAsync(name, version, cancellationToken).ConfigureAwait(false);
|
||||
return result.Value;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -237,7 +237,7 @@ internal static class InputConverter
|
||||
{
|
||||
OutputItemMessage msg => ConvertOutputItemMessageToChat(msg),
|
||||
OutputItemFunctionToolCall funcCall => ConvertOutputItemFunctionCall(funcCall),
|
||||
FunctionToolCallOutputResource funcOutput => ConvertFunctionToolCallOutputResource(funcOutput),
|
||||
OutputItemFunctionToolCallOutput funcOutput => ConvertFunctionToolCallOutput(funcOutput),
|
||||
OutputItemReasoningItem => null,
|
||||
_ => null
|
||||
};
|
||||
@@ -332,7 +332,7 @@ internal static class InputConverter
|
||||
[new FunctionCallContent(funcCall.CallId, funcCall.Name, arguments)]);
|
||||
}
|
||||
|
||||
private static ChatMessage ConvertFunctionToolCallOutputResource(FunctionToolCallOutputResource funcOutput)
|
||||
private static ChatMessage ConvertFunctionToolCallOutput(OutputItemFunctionToolCallOutput funcOutput)
|
||||
{
|
||||
return new ChatMessage(
|
||||
ChatRole.Tool,
|
||||
|
||||
@@ -251,16 +251,25 @@ internal static class OutputConverter
|
||||
var outputTokens = details.OutputTokenCount ?? 0;
|
||||
var totalTokens = details.TotalTokenCount ?? 0;
|
||||
|
||||
var cachedTokens = details.AdditionalCounts?.TryGetValue("InputTokenDetails.CachedTokenCount", out var cached) ?? false
|
||||
? cached : 0;
|
||||
var reasoningTokens = details.AdditionalCounts?.TryGetValue("OutputTokenDetails.ReasoningTokenCount", out var reasoning) ?? false
|
||||
? reasoning : 0;
|
||||
|
||||
if (existing is not null)
|
||||
{
|
||||
inputTokens += existing.InputTokens;
|
||||
outputTokens += existing.OutputTokens;
|
||||
totalTokens += existing.TotalTokens;
|
||||
cachedTokens += existing.InputTokensDetails?.CachedTokens ?? 0;
|
||||
reasoningTokens += existing.OutputTokensDetails?.ReasoningTokens ?? 0;
|
||||
}
|
||||
|
||||
return AzureAIAgentServerResponsesModelFactory.ResponseUsage(
|
||||
return new ResponseUsage(
|
||||
inputTokens: inputTokens,
|
||||
inputTokensDetails: new ResponseUsageInputTokensDetails(cachedTokens),
|
||||
outputTokens: outputTokens,
|
||||
outputTokensDetails: new ResponseUsageOutputTokensDetails(reasoningTokens),
|
||||
totalTokens: totalTokens);
|
||||
}
|
||||
|
||||
|
||||
@@ -42,11 +42,19 @@ internal sealed class A2AAgentHandler : IAgentHandler
|
||||
/// <inheritdoc/>
|
||||
public Task ExecuteAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken)
|
||||
{
|
||||
// Handle task updates
|
||||
if (context.IsContinuation)
|
||||
{
|
||||
return this.HandleTaskUpdateAsync(context, eventQueue, cancellationToken);
|
||||
}
|
||||
|
||||
// Handle messages received via streaming endpoint
|
||||
if (context.StreamingResponse)
|
||||
{
|
||||
return this.HandleNewMessageStreamingAsync(context, eventQueue, cancellationToken);
|
||||
}
|
||||
|
||||
// Handle new messages received via non-streaming endpoint
|
||||
return this.HandleNewMessageAsync(context, eventQueue, cancellationToken);
|
||||
}
|
||||
|
||||
@@ -80,13 +88,19 @@ internal sealed class A2AAgentHandler : IAgentHandler
|
||||
? new AgentRunOptions { AllowBackgroundResponses = allowBackgroundResponses }
|
||||
: new AgentRunOptions { AllowBackgroundResponses = allowBackgroundResponses, AdditionalProperties = context.Metadata.ToAdditionalProperties() };
|
||||
|
||||
var response = await this._hostAgent.RunAsync(
|
||||
chatMessages,
|
||||
session: session,
|
||||
options: options,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await this._hostAgent.SaveSessionAsync(contextId, session, cancellationToken).ConfigureAwait(false);
|
||||
AgentResponse response;
|
||||
try
|
||||
{
|
||||
response = await this._hostAgent.RunAsync(
|
||||
chatMessages,
|
||||
session: session,
|
||||
options: options,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await this._hostAgent.SaveSessionAsync(contextId, session, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (response.ContinuationToken is null)
|
||||
{
|
||||
@@ -108,6 +122,39 @@ internal sealed class A2AAgentHandler : IAgentHandler
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleNewMessageStreamingAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken)
|
||||
{
|
||||
var contextId = context.ContextId ?? Guid.NewGuid().ToString("N");
|
||||
var session = await this._hostAgent.GetOrCreateSessionAsync(contextId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// AIAgent does not support resuming from arbitrary prior tasks.
|
||||
// Throw explicitly so the client gets a clear error rather than a response
|
||||
// that silently ignores the referenced task context.
|
||||
if (context.Message?.ReferenceTaskIds is { Count: > 0 })
|
||||
{
|
||||
throw new NotSupportedException("ReferenceTaskIds is not supported. AIAgent cannot resume from arbitrary prior task context.");
|
||||
}
|
||||
|
||||
List<ChatMessage> chatMessages = context.Message is not null ? [context.Message.ToChatMessage()] : [];
|
||||
|
||||
var options = context.Metadata is { Count: > 0 }
|
||||
? new AgentRunOptions { AdditionalProperties = context.Metadata.ToAdditionalProperties() }
|
||||
: null;
|
||||
|
||||
try
|
||||
{
|
||||
await foreach (var update in this._hostAgent.RunStreamingAsync(chatMessages, session, options, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
var message = CreateMessageFromUpdate(contextId, update);
|
||||
await eventQueue.EnqueueMessageAsync(message, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
await this._hostAgent.SaveSessionAsync(contextId, session, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleTaskUpdateAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken)
|
||||
{
|
||||
var contextId = context.ContextId ?? Guid.NewGuid().ToString("N");
|
||||
@@ -141,8 +188,10 @@ internal sealed class A2AAgentHandler : IAgentHandler
|
||||
await failUpdater.FailAsync(message: null, CancellationToken.None).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
|
||||
await this._hostAgent.SaveSessionAsync(contextId, session, cancellationToken).ConfigureAwait(false);
|
||||
finally
|
||||
{
|
||||
await this._hostAgent.SaveSessionAsync(contextId, session, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (response.ContinuationToken is null)
|
||||
{
|
||||
@@ -174,6 +223,16 @@ internal sealed class A2AAgentHandler : IAgentHandler
|
||||
Metadata = response.AdditionalProperties?.ToA2AMetadata()
|
||||
};
|
||||
|
||||
private static Message CreateMessageFromUpdate(string contextId, AgentResponseUpdate update) =>
|
||||
new()
|
||||
{
|
||||
MessageId = update.ResponseId ?? Guid.NewGuid().ToString("N"),
|
||||
ContextId = contextId,
|
||||
Role = Role.Agent,
|
||||
Parts = update.ToParts(),
|
||||
Metadata = update.AdditionalProperties?.ToA2AMetadata()
|
||||
};
|
||||
|
||||
private static List<ChatMessage> ExtractChatMessagesFromTaskHistory(AgentTask? agentTask)
|
||||
{
|
||||
if (agentTask?.History is not { Count: > 0 })
|
||||
|
||||
@@ -8,6 +8,26 @@ namespace Microsoft.Agents.AI.Hosting.A2A.Converters;
|
||||
|
||||
internal static class MessageConverter
|
||||
{
|
||||
public static List<Part> ToParts(this AgentResponseUpdate update)
|
||||
{
|
||||
if (update is null || update.Contents is not { Count: > 0 })
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var parts = new List<Part>();
|
||||
foreach (var content in update.Contents)
|
||||
{
|
||||
var part = content.ToPart();
|
||||
if (part is not null)
|
||||
{
|
||||
parts.Add(part);
|
||||
}
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
public static List<Part> ToParts(this IList<ChatMessage> chatMessages)
|
||||
{
|
||||
if (chatMessages is null || chatMessages.Count == 0)
|
||||
|
||||
+2
-4
@@ -164,10 +164,8 @@ public class AgentFrameworkResponseHandlerTelemetryTests
|
||||
private static (CreateResponse request, ResponseContext context) BuildRequest(string? agentKey = null)
|
||||
{
|
||||
var request = agentKey is null
|
||||
? AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test")
|
||||
: AzureAIAgentServerResponsesModelFactory.CreateResponse(
|
||||
model: "test",
|
||||
agentReference: new AgentReference(agentKey));
|
||||
? new CreateResponse { Model = "test" }
|
||||
: new CreateResponse { Model = "test", AgentReference = new AgentReference(agentKey) };
|
||||
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
|
||||
+21
-27
@@ -34,7 +34,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test");
|
||||
var request = new CreateResponse { Model = "test" };
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_1", status = "completed", role = "user",
|
||||
@@ -72,9 +72,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(
|
||||
model: "test",
|
||||
agentReference: new AgentReference("my-agent"));
|
||||
var request = new CreateResponse { Model = "test", AgentReference = new AgentReference("my-agent") };
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_1", status = "completed", role = "user",
|
||||
@@ -109,7 +107,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test");
|
||||
var request = new CreateResponse { Model = "test" };
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_1", status = "completed", role = "user",
|
||||
@@ -158,7 +156,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "my-agent");
|
||||
var request = new CreateResponse { Model = "my-agent" };
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_1", status = "completed", role = "user",
|
||||
@@ -195,7 +193,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "");
|
||||
var request = new CreateResponse { Model = "" };
|
||||
var metadata = new Metadata();
|
||||
metadata.AdditionalProperties["entity_id"] = "entity-agent";
|
||||
request.Metadata = metadata;
|
||||
@@ -235,9 +233,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(
|
||||
model: "test",
|
||||
agentReference: new AgentReference("nonexistent-agent"));
|
||||
var request = new CreateResponse { Model = "test", AgentReference = new AgentReference("nonexistent-agent") };
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_1", status = "completed", role = "user",
|
||||
@@ -272,9 +268,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(
|
||||
model: "test",
|
||||
agentReference: new AgentReference("missing-agent"));
|
||||
var request = new CreateResponse { Model = "test", AgentReference = new AgentReference("missing-agent") };
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_1", status = "completed", role = "user",
|
||||
@@ -308,7 +302,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "");
|
||||
var request = new CreateResponse { Model = "" };
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_1", status = "completed", role = "user",
|
||||
@@ -342,7 +336,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test");
|
||||
var request = new CreateResponse { Model = "test" };
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_1", status = "completed", role = "user",
|
||||
@@ -387,7 +381,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test");
|
||||
var request = new CreateResponse { Model = "test" };
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_1", status = "completed", role = "user",
|
||||
@@ -435,7 +429,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test");
|
||||
var request = new CreateResponse { Model = "test" };
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_1", status = "completed", role = "user",
|
||||
@@ -478,7 +472,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test");
|
||||
var request = new CreateResponse { Model = "test" };
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_1", status = "completed", role = "user",
|
||||
@@ -517,9 +511,11 @@ public class AgentFrameworkResponseHandlerTests
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(
|
||||
model: "test",
|
||||
instructions: "You are a helpful assistant.");
|
||||
var request = new CreateResponse
|
||||
{
|
||||
Model = "test",
|
||||
Instructions = "You are a helpful assistant.",
|
||||
};
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_1", status = "completed", role = "user",
|
||||
@@ -557,7 +553,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test");
|
||||
var request = new CreateResponse { Model = "test" };
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_1", status = "completed", role = "user",
|
||||
@@ -598,9 +594,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(
|
||||
model: "test",
|
||||
agentReference: new AgentReference("agent-2"));
|
||||
var request = new CreateResponse { Model = "test", AgentReference = new AgentReference("agent-2") };
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_1", status = "completed", role = "user",
|
||||
@@ -637,7 +631,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test");
|
||||
var request = new CreateResponse { Model = "test" };
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_1", status = "completed", role = "user",
|
||||
@@ -674,7 +668,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test");
|
||||
var request = new CreateResponse { Model = "test" };
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_1", status = "completed", role = "user",
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.ClientModel;
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.AI.Projects.Agents;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
#pragma warning disable OPENAI001
|
||||
#pragma warning disable AAIP001
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="FoundryToolbox"/> class.
|
||||
/// </summary>
|
||||
public class FoundryToolboxTests
|
||||
{
|
||||
private static readonly Uri s_testEndpoint = new("https://test.services.ai.azure.com/api/projects/test-project");
|
||||
|
||||
#region Parameter validation tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetToolboxVersionAsync_NullEndpoint_ThrowsAsync()
|
||||
{
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() =>
|
||||
FoundryToolbox.GetToolboxVersionAsync(
|
||||
projectEndpoint: null!,
|
||||
credential: new FakeAuthenticationTokenProvider(),
|
||||
name: "test-toolbox"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetToolboxVersionAsync_NullCredential_ThrowsAsync()
|
||||
{
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() =>
|
||||
FoundryToolbox.GetToolboxVersionAsync(
|
||||
projectEndpoint: s_testEndpoint,
|
||||
credential: null!,
|
||||
name: "test-toolbox"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public async Task GetToolboxVersionAsync_InvalidName_ThrowsAsync(string? name)
|
||||
{
|
||||
await Assert.ThrowsAnyAsync<ArgumentException>(() =>
|
||||
FoundryToolbox.GetToolboxVersionAsync(
|
||||
projectEndpoint: s_testEndpoint,
|
||||
credential: new FakeAuthenticationTokenProvider(),
|
||||
name: name!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetToolsAsync_NullEndpoint_ThrowsAsync()
|
||||
{
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() =>
|
||||
FoundryToolbox.GetToolsAsync(
|
||||
projectEndpoint: null!,
|
||||
credential: new FakeAuthenticationTokenProvider(),
|
||||
name: "test-toolbox"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToAITools_NullToolboxVersion_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
FoundryToolbox.ToAITools(null!));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ToAITools conversion tests
|
||||
|
||||
[Fact]
|
||||
public void ToAITools_EmptyTools_ReturnsEmptyList()
|
||||
{
|
||||
var version = ProjectsAgentsModelFactory.ToolboxVersion(
|
||||
metadata: null,
|
||||
id: "ver-1",
|
||||
name: "empty-toolbox",
|
||||
version: "v1",
|
||||
description: "Empty",
|
||||
createdAt: DateTimeOffset.UtcNow,
|
||||
tools: Array.Empty<ProjectsAgentTool>(),
|
||||
policies: null);
|
||||
|
||||
var tools = version.ToAITools();
|
||||
|
||||
Assert.Empty(tools);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToAITools_NullTools_ReturnsEmptyList()
|
||||
{
|
||||
var version = ProjectsAgentsModelFactory.ToolboxVersion(
|
||||
metadata: null,
|
||||
id: "ver-1",
|
||||
name: "null-tools-toolbox",
|
||||
version: "v1",
|
||||
description: "Null tools",
|
||||
createdAt: DateTimeOffset.UtcNow,
|
||||
tools: null,
|
||||
policies: null);
|
||||
|
||||
var tools = version.ToAITools();
|
||||
|
||||
Assert.Empty(tools);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToAITools_WithCodeInterpreterTool_ReturnsAITool()
|
||||
{
|
||||
var json = TestDataUtil.GetToolboxVersionResponseJson();
|
||||
var version = ModelReaderWriter.Read<ToolboxVersion>(BinaryData.FromString(json))!;
|
||||
|
||||
var tools = version.ToAITools();
|
||||
|
||||
Assert.Single(tools);
|
||||
Assert.IsAssignableFrom<AITool>(tools[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToAITools_SanitizesDecorationFieldsOnNonFunctionTools()
|
||||
{
|
||||
var json = TestDataUtil.GetToolboxVersionWithDecorationFieldsJson();
|
||||
var version = ModelReaderWriter.Read<ToolboxVersion>(BinaryData.FromString(json))!;
|
||||
|
||||
var tools = version.ToAITools();
|
||||
|
||||
Assert.Single(tools);
|
||||
Assert.IsAssignableFrom<AITool>(tools[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SanitizeAndConvert_FunctionTool_PreservesNameAndDescription()
|
||||
{
|
||||
const string ToolJson = @"{""type"":""function"",""name"":""get_weather"",""description"":""Get weather"",""parameters"":{""type"":""object"",""properties"":{}}}";
|
||||
var tool = ModelReaderWriter.Read<ProjectsAgentTool>(BinaryData.FromString(ToolJson))!;
|
||||
|
||||
var aiTool = FoundryToolbox.SanitizeAndConvert(tool);
|
||||
|
||||
Assert.NotNull(aiTool);
|
||||
Assert.IsAssignableFrom<AITool>(aiTool);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SanitizeAndConvert_CodeInterpreterWithExtraFields_StripsDecorationFields()
|
||||
{
|
||||
const string ToolJson = @"{""type"":""code_interpreter"",""name"":""code_interpreter"",""description"":""Execute code""}";
|
||||
var tool = ModelReaderWriter.Read<ProjectsAgentTool>(BinaryData.FromString(ToolJson))!;
|
||||
|
||||
var aiTool = FoundryToolbox.SanitizeAndConvert(tool);
|
||||
|
||||
Assert.NotNull(aiTool);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Integration tests with mock HTTP
|
||||
|
||||
[Fact]
|
||||
public async Task GetToolboxVersionAsync_WithExplicitVersion_FetchesVersionDirectlyAsync()
|
||||
{
|
||||
var versionJson = TestDataUtil.GetToolboxVersionResponseJson();
|
||||
using var httpHandler = new HttpHandlerAssert((request) =>
|
||||
{
|
||||
Assert.Contains("/toolboxes/research_tools/versions/v5", request.RequestUri!.PathAndQuery);
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(versionJson, Encoding.UTF8, "application/json")
|
||||
};
|
||||
});
|
||||
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(httpHandler);
|
||||
#pragma warning restore CA5399
|
||||
var clientOptions = new AgentAdministrationClientOptions { Transport = new HttpClientPipelineTransport(httpClient) };
|
||||
|
||||
var result = await FoundryToolbox.GetToolboxVersionAsync(
|
||||
s_testEndpoint,
|
||||
new FakeAuthenticationTokenProvider(),
|
||||
"research_tools",
|
||||
version: "v5",
|
||||
clientOptions: clientOptions,
|
||||
cancellationToken: default);
|
||||
|
||||
Assert.Equal("research_tools", result.Name);
|
||||
Assert.Equal("v5", result.Version);
|
||||
Assert.Single(result.Tools);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetToolboxVersionAsync_WithoutVersion_ResolvesDefaultThenFetchesAsync()
|
||||
{
|
||||
var recordJson = TestDataUtil.GetToolboxRecordResponseJson();
|
||||
var versionJson = TestDataUtil.GetToolboxVersionResponseJson();
|
||||
var callCount = 0;
|
||||
|
||||
using var httpHandler = new HttpHandlerAssert((request) =>
|
||||
{
|
||||
callCount++;
|
||||
var path = request.RequestUri!.PathAndQuery;
|
||||
|
||||
if (!path.Contains("/versions/"))
|
||||
{
|
||||
Assert.Contains("/toolboxes/research_tools", path);
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(recordJson, Encoding.UTF8, "application/json")
|
||||
};
|
||||
}
|
||||
|
||||
Assert.Contains("/toolboxes/research_tools/versions/v5", path);
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(versionJson, Encoding.UTF8, "application/json")
|
||||
};
|
||||
});
|
||||
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(httpHandler);
|
||||
#pragma warning restore CA5399
|
||||
var clientOptions = new AgentAdministrationClientOptions { Transport = new HttpClientPipelineTransport(httpClient) };
|
||||
|
||||
var result = await FoundryToolbox.GetToolboxVersionAsync(
|
||||
s_testEndpoint,
|
||||
new FakeAuthenticationTokenProvider(),
|
||||
"research_tools",
|
||||
version: null,
|
||||
clientOptions: clientOptions,
|
||||
cancellationToken: default);
|
||||
|
||||
Assert.Equal(2, callCount);
|
||||
Assert.Equal("research_tools", result.Name);
|
||||
Assert.Equal("v5", result.Version);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetToolboxVersionAsync_ApiError_ThrowsClientResultExceptionAsync()
|
||||
{
|
||||
using var httpHandler = new HttpHandlerAssert((_) =>
|
||||
new HttpResponseMessage(HttpStatusCode.NotFound)
|
||||
{
|
||||
Content = new StringContent("{\"error\":\"not found\"}", Encoding.UTF8, "application/json")
|
||||
});
|
||||
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(httpHandler);
|
||||
#pragma warning restore CA5399
|
||||
var clientOptions = new AgentAdministrationClientOptions { Transport = new HttpClientPipelineTransport(httpClient) };
|
||||
|
||||
await Assert.ThrowsAsync<ClientResultException>(() =>
|
||||
FoundryToolbox.GetToolboxVersionAsync(
|
||||
s_testEndpoint,
|
||||
new FakeAuthenticationTokenProvider(),
|
||||
"nonexistent-toolbox",
|
||||
version: "v1",
|
||||
clientOptions: clientOptions,
|
||||
cancellationToken: default));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetToolsAsync_ReturnsConvertedAIToolsAsync()
|
||||
{
|
||||
var versionJson = TestDataUtil.GetToolboxVersionResponseJson();
|
||||
using var httpHandler = new HttpHandlerAssert((_) =>
|
||||
new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(versionJson, Encoding.UTF8, "application/json")
|
||||
});
|
||||
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(httpHandler);
|
||||
#pragma warning restore CA5399
|
||||
var clientOptions = new AgentAdministrationClientOptions { Transport = new HttpClientPipelineTransport(httpClient) };
|
||||
|
||||
var result = await FoundryToolbox.GetToolboxVersionAsync(
|
||||
s_testEndpoint,
|
||||
new FakeAuthenticationTokenProvider(),
|
||||
"research_tools",
|
||||
version: "v5",
|
||||
clientOptions: clientOptions,
|
||||
cancellationToken: default);
|
||||
|
||||
var tools = result.ToAITools();
|
||||
|
||||
Assert.Single(tools);
|
||||
Assert.IsAssignableFrom<AITool>(tools[0]);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region AIProjectClient extension tests
|
||||
|
||||
[Fact]
|
||||
public async Task AIProjectClientExtension_GetToolboxToolsAsync_ReturnsAIToolsAsync()
|
||||
{
|
||||
var versionJson = TestDataUtil.GetToolboxVersionResponseJson();
|
||||
using var httpHandler = new HttpHandlerAssert((_) =>
|
||||
new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(versionJson, Encoding.UTF8, "application/json")
|
||||
});
|
||||
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(httpHandler);
|
||||
#pragma warning restore CA5399
|
||||
var clientOptions = new AIProjectClientOptions();
|
||||
clientOptions.Transport = new HttpClientPipelineTransport(httpClient);
|
||||
var client = new AIProjectClient(s_testEndpoint, new FakeAuthenticationTokenProvider(), clientOptions);
|
||||
|
||||
var tools = await client.GetToolboxToolsAsync("research_tools", version: "v5");
|
||||
|
||||
Assert.Single(tools);
|
||||
Assert.IsAssignableFrom<AITool>(tools[0]);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Azure.AI.AgentServer.Responses;
|
||||
using Azure.AI.AgentServer.Responses.Models;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using Microsoft.Extensions.AI;
|
||||
@@ -146,11 +145,7 @@ public class InputConverterTests
|
||||
[Fact]
|
||||
public void ConvertToChatOptions_SetsTemperatureAndTopP()
|
||||
{
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(
|
||||
temperature: 0.7,
|
||||
topP: 0.9,
|
||||
maxOutputTokens: 1000,
|
||||
model: "gpt-4o");
|
||||
var request = new CreateResponse { Temperature = 0.7, TopP = 0.9, MaxOutputTokens = 1000, Model = "gpt-4o" };
|
||||
|
||||
var options = InputConverter.ConvertToChatOptions(request);
|
||||
|
||||
@@ -211,9 +206,9 @@ public class InputConverterTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertOutputItemsToMessages_FunctionToolCallOutputResource_ReturnsToolMessage()
|
||||
public void ConvertOutputItemsToMessages_FunctionToolCallOutput_ReturnsToolMessage()
|
||||
{
|
||||
var funcOutput = new FunctionToolCallOutputResource(
|
||||
var funcOutput = new OutputItemFunctionToolCallOutput(
|
||||
callId: "call_def",
|
||||
output: BinaryData.FromString("result data"));
|
||||
|
||||
@@ -229,8 +224,7 @@ public class InputConverterTests
|
||||
[Fact]
|
||||
public void ConvertOutputItemsToMessages_ReasoningItem_ReturnsNull()
|
||||
{
|
||||
var reasoning = AzureAIAgentServerResponsesModelFactory.OutputItemReasoningItem(
|
||||
id: "reason_001");
|
||||
var reasoning = new OutputItemReasoningItem("reason_001", []);
|
||||
|
||||
var messages = InputConverter.ConvertOutputItemsToMessages([reasoning]);
|
||||
|
||||
@@ -661,7 +655,7 @@ public class InputConverterTests
|
||||
[Fact]
|
||||
public void ConvertToChatOptions_ModelId_NotSetFromRequest()
|
||||
{
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "my-model");
|
||||
var request = new CreateResponse { Model = "my-model" };
|
||||
|
||||
var options = InputConverter.ConvertToChatOptions(request);
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ public class OutputConverterTests
|
||||
private static (ResponseEventStream stream, Mock<ResponseContext> mockContext) CreateTestStream()
|
||||
{
|
||||
var mockContext = new Mock<ResponseContext>("resp_" + new string('0', 46)) { CallBase = true };
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test-model");
|
||||
var request = new CreateResponse { Model = "test-model" };
|
||||
var stream = new ResponseEventStream(mockContext.Object, request);
|
||||
return (stream, mockContext);
|
||||
}
|
||||
|
||||
+3
-5
@@ -160,9 +160,7 @@ public class WorkflowIntegrationTests
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(
|
||||
model: "test",
|
||||
agentReference: new AgentReference("my-workflow"));
|
||||
var request = new CreateResponse { Model = "test", AgentReference = new AgentReference("my-workflow") };
|
||||
request.Input = CreateUserInput("Test keyed workflow");
|
||||
var mockContext = CreateMockContext();
|
||||
|
||||
@@ -363,7 +361,7 @@ public class WorkflowIntegrationTests
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test");
|
||||
var request = new CreateResponse { Model = "test" };
|
||||
request.Input = CreateUserInput(userMessage);
|
||||
var mockContext = CreateMockContext();
|
||||
|
||||
@@ -393,7 +391,7 @@ public class WorkflowIntegrationTests
|
||||
private static (ResponseEventStream stream, Mock<ResponseContext> mockContext) CreateTestStream()
|
||||
{
|
||||
var mockContext = new Mock<ResponseContext>("resp_" + new string('0', 46)) { CallBase = true };
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test-model");
|
||||
var request = new CreateResponse { Model = "test-model" };
|
||||
var stream = new ResponseEventStream(mockContext.Object, request);
|
||||
return (stream, mockContext);
|
||||
}
|
||||
|
||||
+11
-2
@@ -10,7 +10,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="'$(TargetFrameworkIdentifier)' != '.NETCoreApp'">
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="'$(TargetFrameworkIdentifier)' == '.NETCoreApp'">
|
||||
@@ -34,7 +34,7 @@
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Evaluation tests require net8.0+ (MEAI.Evaluation does not support legacy TFMs) -->
|
||||
<!-- FoundryEval tests require net8.0+ (MEAI.Evaluation does not support legacy TFMs) -->
|
||||
<ItemGroup Condition="!$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0'))">
|
||||
<Compile Remove="FoundryEvalConverterTests.cs" />
|
||||
<Compile Remove="FoundryEvalsTests.cs" />
|
||||
@@ -50,6 +50,15 @@
|
||||
<None Update="TestData\OpenAIDefaultResponse.json">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="TestData\ToolboxRecordResponse.json">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="TestData\ToolboxVersionResponse.json">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="TestData\ToolboxVersionWithDecorationFields.json">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"id": "tbx-123",
|
||||
"name": "research_tools",
|
||||
"default_version": "v5"
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"metadata": {},
|
||||
"id": "tbv-research_tools-v5",
|
||||
"name": "research_tools",
|
||||
"version": "v5",
|
||||
"description": "Example research toolbox",
|
||||
"created_at": 1775779200,
|
||||
"tools": [
|
||||
{ "type": "code_interpreter" }
|
||||
]
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"metadata": {},
|
||||
"id": "tbv-dirty-v1",
|
||||
"name": "dirty_toolbox",
|
||||
"version": "v1",
|
||||
"description": "Toolbox with decoration fields on tools",
|
||||
"created_at": 1775779200,
|
||||
"tools": [
|
||||
{ "type": "code_interpreter", "name": "code_interpreter", "description": "Execute Python code" }
|
||||
]
|
||||
}
|
||||
@@ -14,6 +14,9 @@ internal static class TestDataUtil
|
||||
private static readonly string s_agentResponseJson = File.ReadAllText("TestData/AgentResponse.json");
|
||||
private static readonly string s_agentVersionResponseJson = File.ReadAllText("TestData/AgentVersionResponse.json");
|
||||
private static readonly string s_openAIDefaultResponseJson = File.ReadAllText("TestData/OpenAIDefaultResponse.json");
|
||||
private static readonly string s_toolboxRecordResponseJson = File.ReadAllText("TestData/ToolboxRecordResponse.json");
|
||||
private static readonly string s_toolboxVersionResponseJson = File.ReadAllText("TestData/ToolboxVersionResponse.json");
|
||||
private static readonly string s_toolboxVersionWithDecorationFieldsJson = File.ReadAllText("TestData/ToolboxVersionWithDecorationFields.json");
|
||||
|
||||
private const string AgentDefinitionPlaceholder = "\"agent-definition-placeholder\"";
|
||||
|
||||
@@ -162,4 +165,19 @@ internal static class TestDataUtil
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the toolbox record response JSON.
|
||||
/// </summary>
|
||||
public static string GetToolboxRecordResponseJson() => s_toolboxRecordResponseJson;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the toolbox version response JSON.
|
||||
/// </summary>
|
||||
public static string GetToolboxVersionResponseJson() => s_toolboxVersionResponseJson;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the toolbox version response JSON with decoration fields on tools.
|
||||
/// </summary>
|
||||
public static string GetToolboxVersionWithDecorationFieldsJson() => s_toolboxVersionWithDecorationFieldsJson;
|
||||
}
|
||||
|
||||
@@ -586,6 +586,457 @@ public sealed class A2AAgentHandlerTests
|
||||
|
||||
#pragma warning restore MEAI001
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that in streaming mode, each update from RunStreamingAsync produces a message event.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_Streaming_EnqueuesMessageForEachUpdateAsync()
|
||||
{
|
||||
// Arrange
|
||||
AgentResponseUpdate[] updates =
|
||||
[
|
||||
new AgentResponseUpdate(ChatRole.Assistant, "chunk 1") { ResponseId = "r1" },
|
||||
new AgentResponseUpdate(ChatRole.Assistant, "chunk 2") { ResponseId = "r2" }
|
||||
];
|
||||
A2AAgentHandler handler = CreateHandler(CreateStreamingAgentMock(updates));
|
||||
|
||||
// Act
|
||||
var events = await CollectEventsAsync(handler, new RequestContext
|
||||
{
|
||||
StreamingResponse = true,
|
||||
TaskId = "",
|
||||
ContextId = "ctx",
|
||||
Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, events.Messages.Count);
|
||||
Assert.Equal("chunk 1", events.Messages[0].Parts![0].Text);
|
||||
Assert.Equal("chunk 2", events.Messages[1].Parts![0].Text);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that in streaming mode, when metadata is present, options with AdditionalProperties
|
||||
/// are passed to RunStreamingAsync.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_Streaming_WithMetadata_PassesOptionsWithAdditionalPropertiesAsync()
|
||||
{
|
||||
// Arrange
|
||||
AgentRunOptions? capturedOptions = null;
|
||||
A2AAgentHandler handler = CreateHandler(CreateStreamingAgentMockWithOptionsCapture(
|
||||
options => capturedOptions = options));
|
||||
|
||||
// Act
|
||||
await InvokeExecuteAsync(handler, new RequestContext
|
||||
{
|
||||
StreamingResponse = true,
|
||||
TaskId = "",
|
||||
ContextId = "ctx",
|
||||
Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] },
|
||||
Metadata = new Dictionary<string, JsonElement>
|
||||
{
|
||||
["key1"] = JsonSerializer.SerializeToElement("value1")
|
||||
}
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedOptions);
|
||||
Assert.NotNull(capturedOptions.AdditionalProperties);
|
||||
Assert.Equal("value1", capturedOptions.AdditionalProperties["key1"]?.ToString());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that in streaming mode, when metadata is null, null options are passed to RunStreamingAsync.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_Streaming_WithNullMetadata_PassesNullOptionsAsync()
|
||||
{
|
||||
// Arrange
|
||||
AgentRunOptions? capturedOptions = null;
|
||||
bool optionsCaptured = false;
|
||||
A2AAgentHandler handler = CreateHandler(CreateStreamingAgentMockWithOptionsCapture(
|
||||
options => { capturedOptions = options; optionsCaptured = true; }));
|
||||
|
||||
// Act
|
||||
await InvokeExecuteAsync(handler, new RequestContext
|
||||
{
|
||||
StreamingResponse = true,
|
||||
TaskId = "",
|
||||
ContextId = "ctx",
|
||||
Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.True(optionsCaptured);
|
||||
Assert.Null(capturedOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that in streaming mode, ReferenceTaskIds throws NotSupportedException.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_Streaming_WithReferenceTaskIds_ThrowsNotSupportedExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
A2AAgentHandler handler = CreateHandler(CreateStreamingAgentMock([]));
|
||||
|
||||
// Act & Assert
|
||||
var eventQueue = new AgentEventQueue();
|
||||
await Assert.ThrowsAsync<NotSupportedException>(() =>
|
||||
handler.ExecuteAsync(
|
||||
new RequestContext
|
||||
{
|
||||
StreamingResponse = true,
|
||||
TaskId = "",
|
||||
ContextId = "ctx",
|
||||
Message = new Message
|
||||
{
|
||||
MessageId = "test-id",
|
||||
Role = Role.User,
|
||||
Parts = [new Part { Text = "Hello" }],
|
||||
ReferenceTaskIds = ["other-task-id"]
|
||||
}
|
||||
},
|
||||
eventQueue,
|
||||
CancellationToken.None));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that in streaming mode, when ContextId is null, a new one is generated.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_Streaming_WhenContextIdIsNull_GeneratesContextIdAsync()
|
||||
{
|
||||
// Arrange
|
||||
AgentResponseUpdate[] updates =
|
||||
[
|
||||
new AgentResponseUpdate(ChatRole.Assistant, "Reply") { ResponseId = "r1" }
|
||||
];
|
||||
A2AAgentHandler handler = CreateHandler(CreateStreamingAgentMock(updates));
|
||||
|
||||
// Act
|
||||
var events = await CollectEventsAsync(handler, new RequestContext
|
||||
{
|
||||
StreamingResponse = true,
|
||||
TaskId = "",
|
||||
ContextId = null!,
|
||||
Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
|
||||
});
|
||||
|
||||
// Assert
|
||||
Message message = Assert.Single(events.Messages);
|
||||
Assert.NotNull(message.ContextId);
|
||||
Assert.NotEmpty(message.ContextId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that in streaming mode, the provided ContextId is used in the response.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_Streaming_UsesProvidedContextIdAsync()
|
||||
{
|
||||
// Arrange
|
||||
AgentResponseUpdate[] updates =
|
||||
[
|
||||
new AgentResponseUpdate(ChatRole.Assistant, "Reply") { ResponseId = "r1" }
|
||||
];
|
||||
A2AAgentHandler handler = CreateHandler(CreateStreamingAgentMock(updates));
|
||||
|
||||
// Act
|
||||
var events = await CollectEventsAsync(handler, new RequestContext
|
||||
{
|
||||
StreamingResponse = true,
|
||||
TaskId = "",
|
||||
ContextId = "my-streaming-ctx",
|
||||
Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
|
||||
});
|
||||
|
||||
// Assert
|
||||
Message message = Assert.Single(events.Messages);
|
||||
Assert.Equal("my-streaming-ctx", message.ContextId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that in streaming mode, when Message is null, the handler succeeds with empty messages.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_Streaming_WhenMessageIsNull_SucceedsWithEmptyMessagesAsync()
|
||||
{
|
||||
// Arrange
|
||||
AgentResponseUpdate[] updates =
|
||||
[
|
||||
new AgentResponseUpdate(ChatRole.Assistant, "Reply") { ResponseId = "r1" }
|
||||
];
|
||||
A2AAgentHandler handler = CreateHandler(CreateStreamingAgentMock(updates));
|
||||
|
||||
// Act
|
||||
var events = await CollectEventsAsync(handler, new RequestContext
|
||||
{
|
||||
StreamingResponse = true,
|
||||
TaskId = "",
|
||||
ContextId = "ctx",
|
||||
Message = null!
|
||||
});
|
||||
|
||||
// Assert
|
||||
Message message = Assert.Single(events.Messages);
|
||||
Assert.Equal("ctx", message.ContextId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that in streaming mode, the ResponseId from the update is used as the MessageId in the response.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_Streaming_ResponseIdIsUsedAsMessageIdAsync()
|
||||
{
|
||||
// Arrange
|
||||
AgentResponseUpdate[] updates =
|
||||
[
|
||||
new AgentResponseUpdate(ChatRole.Assistant, "chunk") { ResponseId = "resp-42" }
|
||||
];
|
||||
A2AAgentHandler handler = CreateHandler(CreateStreamingAgentMock(updates));
|
||||
|
||||
// Act
|
||||
var events = await CollectEventsAsync(handler, new RequestContext
|
||||
{
|
||||
StreamingResponse = true,
|
||||
TaskId = "",
|
||||
ContextId = "ctx",
|
||||
Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
|
||||
});
|
||||
|
||||
// Assert
|
||||
Message message = Assert.Single(events.Messages);
|
||||
Assert.Equal("resp-42", message.MessageId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that in streaming mode, when ResponseId is null, a MessageId is still generated.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_Streaming_WhenResponseIdIsNull_GeneratesMessageIdAsync()
|
||||
{
|
||||
// Arrange
|
||||
AgentResponseUpdate[] updates =
|
||||
[
|
||||
new AgentResponseUpdate(ChatRole.Assistant, "chunk") { ResponseId = null }
|
||||
];
|
||||
A2AAgentHandler handler = CreateHandler(CreateStreamingAgentMock(updates));
|
||||
|
||||
// Act
|
||||
var events = await CollectEventsAsync(handler, new RequestContext
|
||||
{
|
||||
StreamingResponse = true,
|
||||
TaskId = "",
|
||||
ContextId = "ctx",
|
||||
Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
|
||||
});
|
||||
|
||||
// Assert
|
||||
Message message = Assert.Single(events.Messages);
|
||||
Assert.NotNull(message.MessageId);
|
||||
Assert.NotEmpty(message.MessageId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that in streaming mode, when the update has AdditionalProperties, the message has metadata.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_Streaming_WithResponseAdditionalProperties_ReturnsMessageWithMetadataAsync()
|
||||
{
|
||||
// Arrange
|
||||
AdditionalPropertiesDictionary additionalProps = new()
|
||||
{
|
||||
["streamKey"] = "streamValue"
|
||||
};
|
||||
AgentResponseUpdate[] updates =
|
||||
[
|
||||
new AgentResponseUpdate(ChatRole.Assistant, "chunk") { ResponseId = "r1", AdditionalProperties = additionalProps }
|
||||
];
|
||||
A2AAgentHandler handler = CreateHandler(CreateStreamingAgentMock(updates));
|
||||
|
||||
// Act
|
||||
var events = await CollectEventsAsync(handler, new RequestContext
|
||||
{
|
||||
StreamingResponse = true,
|
||||
TaskId = "",
|
||||
ContextId = "ctx",
|
||||
Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
|
||||
});
|
||||
|
||||
// Assert
|
||||
Message message = Assert.Single(events.Messages);
|
||||
Assert.NotNull(message.Metadata);
|
||||
Assert.True(message.Metadata.ContainsKey("streamKey"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that in streaming mode, when the update has null AdditionalProperties, the message has null metadata.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_Streaming_WithNullAdditionalProperties_ReturnsMessageWithNullMetadataAsync()
|
||||
{
|
||||
// Arrange
|
||||
AgentResponseUpdate[] updates =
|
||||
[
|
||||
new AgentResponseUpdate(ChatRole.Assistant, "chunk") { ResponseId = "r1", AdditionalProperties = null }
|
||||
];
|
||||
A2AAgentHandler handler = CreateHandler(CreateStreamingAgentMock(updates));
|
||||
|
||||
// Act
|
||||
var events = await CollectEventsAsync(handler, new RequestContext
|
||||
{
|
||||
StreamingResponse = true,
|
||||
TaskId = "",
|
||||
ContextId = "ctx",
|
||||
Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
|
||||
});
|
||||
|
||||
// Assert
|
||||
Message message = Assert.Single(events.Messages);
|
||||
Assert.Null(message.Metadata);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that in streaming mode, the session is saved after all updates are processed.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_Streaming_SavesSessionAfterProcessingAsync()
|
||||
{
|
||||
// Arrange
|
||||
var mockSessionStore = new Mock<AgentSessionStore>();
|
||||
mockSessionStore
|
||||
.Setup(x => x.GetSessionAsync(
|
||||
It.IsAny<AIAgent>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new TestAgentSession());
|
||||
mockSessionStore
|
||||
.Setup(x => x.SaveSessionAsync(
|
||||
It.IsAny<AIAgent>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<AgentSession>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Returns(ValueTask.CompletedTask);
|
||||
|
||||
AgentResponseUpdate[] updates =
|
||||
[
|
||||
new AgentResponseUpdate(ChatRole.Assistant, "chunk") { ResponseId = "r1" }
|
||||
];
|
||||
A2AAgentHandler handler = CreateHandler(CreateStreamingAgentMock(updates), agentSessionStore: mockSessionStore.Object);
|
||||
|
||||
// Act
|
||||
await InvokeExecuteAsync(handler, new RequestContext
|
||||
{
|
||||
StreamingResponse = true,
|
||||
TaskId = "",
|
||||
ContextId = "ctx-stream",
|
||||
Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
|
||||
});
|
||||
|
||||
// Assert - verify session was saved
|
||||
mockSessionStore.Verify(
|
||||
x => x.SaveSessionAsync(
|
||||
It.IsAny<AIAgent>(),
|
||||
It.Is<string>(s => s == "ctx-stream"),
|
||||
It.IsAny<AgentSession>(),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that in streaming mode, when RunStreamingAsync yields no updates,
|
||||
/// no messages are enqueued and the session is still saved.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_Streaming_WhenNoUpdates_EnqueuesNoMessagesAndSavesSessionAsync()
|
||||
{
|
||||
// Arrange
|
||||
var mockSessionStore = new Mock<AgentSessionStore>();
|
||||
mockSessionStore
|
||||
.Setup(x => x.GetSessionAsync(
|
||||
It.IsAny<AIAgent>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new TestAgentSession());
|
||||
mockSessionStore
|
||||
.Setup(x => x.SaveSessionAsync(
|
||||
It.IsAny<AIAgent>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<AgentSession>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Returns(ValueTask.CompletedTask);
|
||||
|
||||
A2AAgentHandler handler = CreateHandler(CreateStreamingAgentMock([]), agentSessionStore: mockSessionStore.Object);
|
||||
|
||||
// Act
|
||||
var events = await CollectEventsAsync(handler, new RequestContext
|
||||
{
|
||||
StreamingResponse = true,
|
||||
TaskId = "",
|
||||
ContextId = "ctx",
|
||||
Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.Empty(events.Messages);
|
||||
mockSessionStore.Verify(
|
||||
x => x.SaveSessionAsync(
|
||||
It.IsAny<AIAgent>(),
|
||||
It.Is<string>(s => s == "ctx"),
|
||||
It.IsAny<AgentSession>(),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the CancellationToken is propagated to RunStreamingAsync in the streaming path.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_Streaming_CancellationTokenIsPropagatedToRunStreamingAsync()
|
||||
{
|
||||
// Arrange
|
||||
CancellationToken capturedToken = default;
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
Mock<AIAgent> agentMock = new() { CallBase = true };
|
||||
agentMock.SetupGet(x => x.Name).Returns("TestAgent");
|
||||
agentMock
|
||||
.Protected()
|
||||
.Setup<ValueTask<AgentSession>>("CreateSessionCoreAsync", ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(new TestAgentSession());
|
||||
agentMock
|
||||
.Protected()
|
||||
.Setup<IAsyncEnumerable<AgentResponseUpdate>>("RunCoreStreamingAsync",
|
||||
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
|
||||
ItExpr.IsAny<AgentSession?>(),
|
||||
ItExpr.IsAny<AgentRunOptions?>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Callback<IEnumerable<ChatMessage>, AgentSession?, AgentRunOptions?, CancellationToken>(
|
||||
(_, _, _, ct) => capturedToken = ct)
|
||||
.Returns(() => ToAsyncEnumerableAsync([new AgentResponseUpdate(ChatRole.Assistant, "reply") { ResponseId = "r1" }]));
|
||||
|
||||
A2AAgentHandler handler = CreateHandler(agentMock);
|
||||
|
||||
// Act
|
||||
var eventQueue = new AgentEventQueue();
|
||||
await handler.ExecuteAsync(
|
||||
new RequestContext
|
||||
{
|
||||
TaskId = "",
|
||||
ContextId = "ctx",
|
||||
StreamingResponse = true,
|
||||
Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
|
||||
},
|
||||
eventQueue,
|
||||
cts.Token);
|
||||
eventQueue.Complete(null);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(cts.Token, capturedToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when no session store is provided, the handler uses InMemoryAgentSessionStore
|
||||
/// and can execute successfully.
|
||||
@@ -821,6 +1272,308 @@ public sealed class A2AAgentHandlerTests
|
||||
Assert.True(capturedOptions.AllowBackgroundResponses);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that in the non-streaming path, SaveSessionAsync is called with
|
||||
/// CancellationToken.None even when RunAsync throws an exception.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_NonStreaming_WhenRunAsyncThrows_SavesSessionWithUncancelledTokenAsync()
|
||||
{
|
||||
// Arrange
|
||||
var mockSessionStore = new Mock<AgentSessionStore>();
|
||||
mockSessionStore
|
||||
.Setup(x => x.GetSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new TestAgentSession());
|
||||
mockSessionStore
|
||||
.Setup(x => x.SaveSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<AgentSession>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(ValueTask.CompletedTask);
|
||||
|
||||
Mock<AIAgent> agentMock = new() { CallBase = true };
|
||||
agentMock.SetupGet(x => x.Name).Returns("TestAgent");
|
||||
agentMock.Protected()
|
||||
.Setup<ValueTask<AgentSession>>("CreateSessionCoreAsync", ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(new TestAgentSession());
|
||||
agentMock.Protected()
|
||||
.Setup<Task<AgentResponse>>("RunCoreAsync",
|
||||
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
|
||||
ItExpr.IsAny<AgentSession?>(),
|
||||
ItExpr.IsAny<AgentRunOptions?>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ThrowsAsync(new InvalidOperationException("Agent failed"));
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
A2AAgentHandler handler = CreateHandler(agentMock, agentSessionStore: mockSessionStore.Object);
|
||||
|
||||
// Act
|
||||
var eventQueue = new AgentEventQueue();
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
handler.ExecuteAsync(
|
||||
new RequestContext
|
||||
{
|
||||
TaskId = "", ContextId = "ctx", StreamingResponse = false,
|
||||
Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
|
||||
},
|
||||
eventQueue,
|
||||
cts.Token));
|
||||
|
||||
// Assert - SaveSessionAsync was called with CancellationToken.None despite the exception
|
||||
mockSessionStore.Verify(
|
||||
x => x.SaveSessionAsync(
|
||||
It.IsAny<AIAgent>(),
|
||||
It.Is<string>(s => s == "ctx"),
|
||||
It.IsAny<AgentSession>(),
|
||||
It.Is<CancellationToken>(ct => ct == CancellationToken.None)),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that in the streaming path, SaveSessionAsync is called with
|
||||
/// CancellationToken.None even when RunStreamingAsync throws an exception.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_Streaming_WhenRunStreamingAsyncThrows_SavesSessionWithUncancelledTokenAsync()
|
||||
{
|
||||
// Arrange
|
||||
var mockSessionStore = new Mock<AgentSessionStore>();
|
||||
mockSessionStore
|
||||
.Setup(x => x.GetSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new TestAgentSession());
|
||||
mockSessionStore
|
||||
.Setup(x => x.SaveSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<AgentSession>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(ValueTask.CompletedTask);
|
||||
|
||||
Mock<AIAgent> agentMock = new() { CallBase = true };
|
||||
agentMock.SetupGet(x => x.Name).Returns("TestAgent");
|
||||
agentMock.Protected()
|
||||
.Setup<ValueTask<AgentSession>>("CreateSessionCoreAsync", ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(new TestAgentSession());
|
||||
agentMock.Protected()
|
||||
.Setup<IAsyncEnumerable<AgentResponseUpdate>>("RunCoreStreamingAsync",
|
||||
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
|
||||
ItExpr.IsAny<AgentSession?>(),
|
||||
ItExpr.IsAny<AgentRunOptions?>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Returns(() => ToThrowingAsyncEnumerableAsync(new InvalidOperationException("Stream failed")));
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
A2AAgentHandler handler = CreateHandler(agentMock, agentSessionStore: mockSessionStore.Object);
|
||||
|
||||
// Act
|
||||
var eventQueue = new AgentEventQueue();
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
handler.ExecuteAsync(
|
||||
new RequestContext
|
||||
{
|
||||
TaskId = "", ContextId = "ctx-stream", StreamingResponse = true,
|
||||
Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
|
||||
},
|
||||
eventQueue,
|
||||
cts.Token));
|
||||
|
||||
// Assert - SaveSessionAsync was called with CancellationToken.None despite the exception
|
||||
mockSessionStore.Verify(
|
||||
x => x.SaveSessionAsync(
|
||||
It.IsAny<AIAgent>(),
|
||||
It.Is<string>(s => s == "ctx-stream"),
|
||||
It.IsAny<AgentSession>(),
|
||||
It.Is<CancellationToken>(ct => ct == CancellationToken.None)),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that on the continuation path, SaveSessionAsync is called with
|
||||
/// CancellationToken.None even when RunAsync throws an exception.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_OnContinuation_WhenRunAsyncThrows_SavesSessionWithUncancelledTokenAsync()
|
||||
{
|
||||
// Arrange
|
||||
var mockSessionStore = new Mock<AgentSessionStore>();
|
||||
mockSessionStore
|
||||
.Setup(x => x.GetSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new TestAgentSession());
|
||||
mockSessionStore
|
||||
.Setup(x => x.SaveSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<AgentSession>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(ValueTask.CompletedTask);
|
||||
|
||||
Mock<AIAgent> agentMock = new() { CallBase = true };
|
||||
agentMock.SetupGet(x => x.Name).Returns("TestAgent");
|
||||
agentMock.Protected()
|
||||
.Setup<ValueTask<AgentSession>>("CreateSessionCoreAsync", ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(new TestAgentSession());
|
||||
agentMock.Protected()
|
||||
.Setup<Task<AgentResponse>>("RunCoreAsync",
|
||||
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
|
||||
ItExpr.IsAny<AgentSession?>(),
|
||||
ItExpr.IsAny<AgentRunOptions?>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ThrowsAsync(new InvalidOperationException("Agent failed"));
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
A2AAgentHandler handler = CreateHandler(agentMock, agentSessionStore: mockSessionStore.Object);
|
||||
|
||||
// Act
|
||||
var eventQueue = new AgentEventQueue();
|
||||
var events = new EventCollector();
|
||||
var readerTask = ReadEventsAsync(eventQueue, events);
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
handler.ExecuteAsync(
|
||||
new RequestContext
|
||||
{
|
||||
StreamingResponse = false,
|
||||
TaskId = "task-1", ContextId = "ctx-cont",
|
||||
Message = new Message { MessageId = "empty", Role = Role.User, Parts = [] },
|
||||
Task = new AgentTask { Id = "task-1", ContextId = "ctx-cont", History = [new Message { Role = Role.User, Parts = [new Part { Text = "Hello" }] }] }
|
||||
},
|
||||
eventQueue,
|
||||
cts.Token));
|
||||
eventQueue.Complete(null);
|
||||
await readerTask;
|
||||
|
||||
// Assert - SaveSessionAsync was called with CancellationToken.None despite the exception
|
||||
mockSessionStore.Verify(
|
||||
x => x.SaveSessionAsync(
|
||||
It.IsAny<AIAgent>(),
|
||||
It.Is<string>(s => s == "ctx-cont"),
|
||||
It.IsAny<AgentSession>(),
|
||||
It.Is<CancellationToken>(ct => ct == CancellationToken.None)),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that in the non-streaming path, SaveSessionAsync is called with
|
||||
/// CancellationToken.None rather than the caller's cancellation token.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_NonStreaming_SavesSessionWithUncancelledTokenAsync()
|
||||
{
|
||||
// Arrange
|
||||
var mockSessionStore = new Mock<AgentSessionStore>();
|
||||
mockSessionStore
|
||||
.Setup(x => x.GetSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new TestAgentSession());
|
||||
mockSessionStore
|
||||
.Setup(x => x.SaveSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<AgentSession>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(ValueTask.CompletedTask);
|
||||
|
||||
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Reply")]);
|
||||
A2AAgentHandler handler = CreateHandler(CreateAgentMockWithResponse(response), agentSessionStore: mockSessionStore.Object);
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
// Act
|
||||
var eventQueue = new AgentEventQueue();
|
||||
await handler.ExecuteAsync(
|
||||
new RequestContext
|
||||
{
|
||||
TaskId = "", ContextId = "ctx", StreamingResponse = false,
|
||||
Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
|
||||
},
|
||||
eventQueue,
|
||||
cts.Token);
|
||||
eventQueue.Complete(null);
|
||||
|
||||
// Assert - SaveSessionAsync was called with CancellationToken.None, not the caller's token
|
||||
mockSessionStore.Verify(
|
||||
x => x.SaveSessionAsync(
|
||||
It.IsAny<AIAgent>(),
|
||||
It.Is<string>(s => s == "ctx"),
|
||||
It.IsAny<AgentSession>(),
|
||||
It.Is<CancellationToken>(ct => ct == CancellationToken.None)),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that in the streaming path, SaveSessionAsync is called with
|
||||
/// CancellationToken.None rather than the caller's cancellation token.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_Streaming_SavesSessionWithUncancelledTokenAsync()
|
||||
{
|
||||
// Arrange
|
||||
var mockSessionStore = new Mock<AgentSessionStore>();
|
||||
mockSessionStore
|
||||
.Setup(x => x.GetSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new TestAgentSession());
|
||||
mockSessionStore
|
||||
.Setup(x => x.SaveSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<AgentSession>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(ValueTask.CompletedTask);
|
||||
|
||||
AgentResponseUpdate[] updates = [new AgentResponseUpdate(ChatRole.Assistant, "chunk") { ResponseId = "r1" }];
|
||||
A2AAgentHandler handler = CreateHandler(CreateStreamingAgentMock(updates), agentSessionStore: mockSessionStore.Object);
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
// Act
|
||||
var eventQueue = new AgentEventQueue();
|
||||
await handler.ExecuteAsync(
|
||||
new RequestContext
|
||||
{
|
||||
TaskId = "", ContextId = "ctx-stream", StreamingResponse = true,
|
||||
Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
|
||||
},
|
||||
eventQueue,
|
||||
cts.Token);
|
||||
eventQueue.Complete(null);
|
||||
|
||||
// Assert - SaveSessionAsync was called with CancellationToken.None, not the caller's token
|
||||
mockSessionStore.Verify(
|
||||
x => x.SaveSessionAsync(
|
||||
It.IsAny<AIAgent>(),
|
||||
It.Is<string>(s => s == "ctx-stream"),
|
||||
It.IsAny<AgentSession>(),
|
||||
It.Is<CancellationToken>(ct => ct == CancellationToken.None)),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that on the continuation path, SaveSessionAsync is called with
|
||||
/// CancellationToken.None rather than the caller's cancellation token.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_OnContinuation_SavesSessionWithUncancelledTokenAsync()
|
||||
{
|
||||
// Arrange
|
||||
var mockSessionStore = new Mock<AgentSessionStore>();
|
||||
mockSessionStore
|
||||
.Setup(x => x.GetSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new TestAgentSession());
|
||||
mockSessionStore
|
||||
.Setup(x => x.SaveSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<AgentSession>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(ValueTask.CompletedTask);
|
||||
|
||||
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Done!")]);
|
||||
A2AAgentHandler handler = CreateHandler(CreateAgentMockWithResponse(response), agentSessionStore: mockSessionStore.Object);
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
// Act
|
||||
var eventQueue = new AgentEventQueue();
|
||||
var events = new EventCollector();
|
||||
var readerTask = ReadEventsAsync(eventQueue, events);
|
||||
await handler.ExecuteAsync(
|
||||
new RequestContext
|
||||
{
|
||||
StreamingResponse = false,
|
||||
TaskId = "task-1", ContextId = "ctx-cont",
|
||||
Message = new Message { MessageId = "empty", Role = Role.User, Parts = [] },
|
||||
Task = new AgentTask { Id = "task-1", ContextId = "ctx-cont", History = [new Message { Role = Role.User, Parts = [new Part { Text = "Hello" }] }] }
|
||||
},
|
||||
eventQueue,
|
||||
cts.Token);
|
||||
eventQueue.Complete(null);
|
||||
await readerTask;
|
||||
|
||||
// Assert - SaveSessionAsync was called with CancellationToken.None, not the caller's token
|
||||
mockSessionStore.Verify(
|
||||
x => x.SaveSessionAsync(
|
||||
It.IsAny<AIAgent>(),
|
||||
It.Is<string>(s => s == "ctx-cont"),
|
||||
It.IsAny<AgentSession>(),
|
||||
It.Is<CancellationToken>(ct => ct == CancellationToken.None)),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
private static A2AAgentHandler CreateHandler(
|
||||
Mock<AIAgent> agentMock,
|
||||
AgentRunMode? runMode = null,
|
||||
@@ -905,6 +1658,68 @@ public sealed class A2AAgentHandlerTests
|
||||
return agentMock;
|
||||
}
|
||||
|
||||
private static Mock<AIAgent> CreateStreamingAgentMock(IEnumerable<AgentResponseUpdate> updates)
|
||||
{
|
||||
Mock<AIAgent> agentMock = new() { CallBase = true };
|
||||
agentMock.SetupGet(x => x.Name).Returns("TestAgent");
|
||||
agentMock
|
||||
.Protected()
|
||||
.Setup<ValueTask<AgentSession>>("CreateSessionCoreAsync", ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(new TestAgentSession());
|
||||
agentMock
|
||||
.Protected()
|
||||
.Setup<IAsyncEnumerable<AgentResponseUpdate>>("RunCoreStreamingAsync",
|
||||
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
|
||||
ItExpr.IsAny<AgentSession?>(),
|
||||
ItExpr.IsAny<AgentRunOptions?>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Returns(() => ToAsyncEnumerableAsync(updates));
|
||||
|
||||
return agentMock;
|
||||
}
|
||||
|
||||
private static Mock<AIAgent> CreateStreamingAgentMockWithOptionsCapture(
|
||||
Action<AgentRunOptions?> optionsCallback)
|
||||
{
|
||||
Mock<AIAgent> agentMock = new() { CallBase = true };
|
||||
agentMock.SetupGet(x => x.Name).Returns("TestAgent");
|
||||
agentMock
|
||||
.Protected()
|
||||
.Setup<ValueTask<AgentSession>>("CreateSessionCoreAsync", ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(new TestAgentSession());
|
||||
agentMock
|
||||
.Protected()
|
||||
.Setup<IAsyncEnumerable<AgentResponseUpdate>>("RunCoreStreamingAsync",
|
||||
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
|
||||
ItExpr.IsAny<AgentSession?>(),
|
||||
ItExpr.IsAny<AgentRunOptions?>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Callback<IEnumerable<ChatMessage>, AgentSession?, AgentRunOptions?, CancellationToken>(
|
||||
(_, _, options, _) => optionsCallback(options))
|
||||
.Returns(() => ToAsyncEnumerableAsync([new AgentResponseUpdate(ChatRole.Assistant, "reply") { ResponseId = "r1" }]));
|
||||
|
||||
return agentMock;
|
||||
}
|
||||
|
||||
private static async IAsyncEnumerable<T> ToAsyncEnumerableAsync<T>(IEnumerable<T> items)
|
||||
{
|
||||
await Task.Yield();
|
||||
foreach (var item in items)
|
||||
{
|
||||
yield return item;
|
||||
}
|
||||
}
|
||||
|
||||
private static async IAsyncEnumerable<AgentResponseUpdate> ToThrowingAsyncEnumerableAsync(Exception exception)
|
||||
{
|
||||
await Task.Yield();
|
||||
throw exception;
|
||||
|
||||
#pragma warning disable CS0162 // Unreachable code detected - yield is required for async iterator
|
||||
yield break;
|
||||
#pragma warning restore CS0162
|
||||
}
|
||||
|
||||
private static async Task InvokeExecuteAsync(A2AAgentHandler handler, RequestContext context)
|
||||
{
|
||||
var eventQueue = new AgentEventQueue();
|
||||
|
||||
+63
@@ -147,4 +147,67 @@ public class MessageConverterTests
|
||||
Assert.Equal("First message", result[0].Text);
|
||||
Assert.Equal("Second message", result[1].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToParts_AgentResponseUpdate_WithNoContents_ReturnsEmptyList()
|
||||
{
|
||||
// Arrange
|
||||
var update = new AgentResponseUpdate();
|
||||
|
||||
// Act
|
||||
var result = update.ToParts();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToParts_AgentResponseUpdate_WithTextContent_ReturnsTextPart()
|
||||
{
|
||||
// Arrange
|
||||
var update = new AgentResponseUpdate(ChatRole.Assistant, "Hello from streaming!");
|
||||
|
||||
// Act
|
||||
var result = update.ToParts();
|
||||
|
||||
// Assert
|
||||
Assert.Single(result);
|
||||
Assert.Equal("Hello from streaming!", result[0].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToParts_AgentResponseUpdate_WithMultipleContents_ReturnsAllParts()
|
||||
{
|
||||
// Arrange
|
||||
var update = new AgentResponseUpdate(ChatRole.Assistant, [
|
||||
new TextContent("First chunk"),
|
||||
new TextContent("Second chunk")
|
||||
]);
|
||||
|
||||
// Act
|
||||
var result = update.ToParts();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, result.Count);
|
||||
Assert.Equal("First chunk", result[0].Text);
|
||||
Assert.Equal("Second chunk", result[1].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToParts_AgentResponseUpdate_WithUnsupportedContent_FiltersOutNulls()
|
||||
{
|
||||
// Arrange - FunctionCallContent maps to null Part since it's not a supported A2A content type
|
||||
var update = new AgentResponseUpdate(ChatRole.Assistant, [
|
||||
new TextContent("Supported text"),
|
||||
new FunctionCallContent("call-1", "myFunction")
|
||||
]);
|
||||
|
||||
// Act
|
||||
var result = update.ToParts();
|
||||
|
||||
// Assert - only the text part should be returned
|
||||
Assert.Single(result);
|
||||
Assert.Equal("Supported text", result[0].Text);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,20 +4,48 @@ Agent-to-Agent (A2A) protocol support for inter-agent communication.
|
||||
|
||||
## Main Classes
|
||||
|
||||
- **`A2AAgent`** - Agent wrapper that exposes an agent via the A2A protocol
|
||||
- **`A2AAgent`** - Client to connect to remote A2A-compliant agents.
|
||||
- **`A2AExecutor`** - Bridge to expose Agent Framework agents via the A2A protocol.
|
||||
|
||||
## Usage
|
||||
|
||||
### A2AAgent (Client)
|
||||
|
||||
```python
|
||||
from agent_framework.a2a import A2AAgent
|
||||
|
||||
a2a_agent = A2AAgent(agent=my_agent)
|
||||
# Connect to a remote A2A agent
|
||||
a2a_agent = A2AAgent(url="http://remote-agent/a2a")
|
||||
response = await a2a_agent.run("Hello!")
|
||||
```
|
||||
|
||||
### A2AExecutor (Server/Bridge)
|
||||
|
||||
```python
|
||||
from agent_framework.a2a import A2AExecutor
|
||||
from a2a.server.apps import A2AStarletteApplication
|
||||
from a2a.server.request_handlers import DefaultRequestHandler
|
||||
from a2a.server.tasks import InMemoryTaskStore
|
||||
|
||||
# Create an A2A executor for your agent
|
||||
executor = A2AExecutor(agent=my_agent)
|
||||
|
||||
# Set up the request handler and server application
|
||||
request_handler = DefaultRequestHandler(
|
||||
agent_executor=executor,
|
||||
task_store=InMemoryTaskStore(),
|
||||
)
|
||||
|
||||
app = A2AStarletteApplication(
|
||||
agent_card=my_agent_card,
|
||||
http_handler=request_handler,
|
||||
).build()
|
||||
```
|
||||
|
||||
## Import Path
|
||||
|
||||
```python
|
||||
from agent_framework.a2a import A2AAgent
|
||||
from agent_framework.a2a import A2AAgent, A2AExecutor
|
||||
# or directly:
|
||||
from agent_framework_a2a import A2AAgent
|
||||
from agent_framework_a2a import A2AAgent, A2AExecutor
|
||||
```
|
||||
|
||||
@@ -10,11 +10,49 @@ pip install agent-framework-a2a --pre
|
||||
|
||||
The A2A agent integration enables communication with remote A2A-compliant agents using the standardized A2A protocol. This allows your Agent Framework applications to connect to agents running on different platforms, languages, or services.
|
||||
|
||||
### A2AAgent (Client)
|
||||
|
||||
The `A2AAgent` class is a client that wraps an A2A Client to connect the Agent Framework with external A2A-compliant agents.
|
||||
|
||||
```python
|
||||
from agent_framework.a2a import A2AAgent
|
||||
|
||||
# Connect to a remote A2A agent
|
||||
a2a_agent = A2AAgent(url="http://remote-agent/a2a")
|
||||
response = await a2a_agent.run("Hello!")
|
||||
```
|
||||
|
||||
### A2AExecutor (Hosting)
|
||||
|
||||
The `A2AExecutor` class bridges local AI agents built with the `agent_framework` library to the A2A protocol, allowing them to be hosted and accessed by other A2A-compliant clients.
|
||||
|
||||
```python
|
||||
from agent_framework.a2a import A2AExecutor
|
||||
from a2a.server.apps import A2AStarletteApplication
|
||||
from a2a.server.request_handlers import DefaultRequestHandler
|
||||
from a2a.server.tasks import InMemoryTaskStore
|
||||
|
||||
# Create an A2A executor for your agent
|
||||
executor = A2AExecutor(agent=my_agent)
|
||||
|
||||
# Set up the request handler and server application
|
||||
request_handler = DefaultRequestHandler(
|
||||
agent_executor=executor,
|
||||
task_store=InMemoryTaskStore(),
|
||||
)
|
||||
|
||||
app = A2AStarletteApplication(
|
||||
agent_card=my_agent_card,
|
||||
http_handler=request_handler,
|
||||
).build()
|
||||
```
|
||||
|
||||
### Basic Usage Example
|
||||
|
||||
See the [A2A agent examples](../../samples/04-hosting/a2a/) which demonstrate:
|
||||
|
||||
- Connecting to remote A2A agents
|
||||
- Hosting local agents via A2A protocol
|
||||
- Sending messages and receiving responses
|
||||
- Handling different content types (text, files, data)
|
||||
- Streaming responses and real-time interaction
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import importlib.metadata
|
||||
|
||||
from ._a2a_executor import A2AExecutor
|
||||
from ._agent import A2AAgent, A2AContinuationToken
|
||||
|
||||
try:
|
||||
@@ -12,5 +13,6 @@ except importlib.metadata.PackageNotFoundError:
|
||||
__all__ = [
|
||||
"A2AAgent",
|
||||
"A2AContinuationToken",
|
||||
"A2AExecutor",
|
||||
"__version__",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import logging
|
||||
from asyncio import CancelledError
|
||||
from collections.abc import Mapping
|
||||
from functools import partial
|
||||
from typing import Any
|
||||
|
||||
from a2a.server.agent_execution import AgentExecutor, RequestContext
|
||||
from a2a.server.events import EventQueue
|
||||
from a2a.server.tasks import TaskUpdater
|
||||
from a2a.types import FilePart, FileWithBytes, FileWithUri, Part, TaskState, TextPart
|
||||
from a2a.utils import new_task
|
||||
from agent_framework import (
|
||||
AgentResponseUpdate,
|
||||
AgentSession,
|
||||
Message,
|
||||
SupportsAgentRun,
|
||||
)
|
||||
from typing_extensions import override
|
||||
|
||||
from agent_framework_a2a._utils import get_uri_data
|
||||
|
||||
logger = logging.getLogger("agent_framework.a2a")
|
||||
|
||||
|
||||
class A2AExecutor(AgentExecutor):
|
||||
"""Execute AI agents using the A2A (Agent-to-Agent) protocol.
|
||||
|
||||
The A2AExecutor bridges AI agents built with the agent_framework library and the A2A protocol,
|
||||
enabling structured agent execution with event-driven communication. It handles execution
|
||||
contexts, delegates history management to the agent's session, and converts agent
|
||||
responses into A2A protocol events.
|
||||
|
||||
The executor supports executing an Agent or WorkflowAgent. It provides comprehensive
|
||||
error handling with task status updates and supports various content types including text,
|
||||
binary data, and URI-based content.
|
||||
|
||||
Example:
|
||||
.. code-block:: python
|
||||
|
||||
from a2a.server.apps import A2AStarletteApplication
|
||||
from a2a.server.request_handlers import DefaultRequestHandler
|
||||
from a2a.server.tasks import InMemoryTaskStore
|
||||
from a2a.types import AgentCapabilities, AgentCard
|
||||
from agent_framework.a2a import A2AExecutor
|
||||
from agent_framework.openai import OpenAIResponsesClient
|
||||
|
||||
public_agent_card = AgentCard(
|
||||
name="Food Agent",
|
||||
description="A simple agent that provides food-related information.",
|
||||
url="http://localhost:9999/",
|
||||
version="1.0.0",
|
||||
defaultInputModes=["text"],
|
||||
defaultOutputModes=["text"],
|
||||
capabilities=AgentCapabilities(streaming=True),
|
||||
skills=[],
|
||||
)
|
||||
|
||||
# Create an agent
|
||||
agent = OpenAIResponsesClient().as_agent(
|
||||
name="Food Agent",
|
||||
instructions="A simple agent that provides food-related information.",
|
||||
)
|
||||
|
||||
# Set up the A2A server with the A2AExecutor enabled for streaming
|
||||
# and passing custom keyword arguments to the agent's run method.
|
||||
request_handler = DefaultRequestHandler(
|
||||
agent_executor=A2AExecutor(agent, stream=True, run_kwargs={"client_kwargs": {"max_tokens": 500}}),
|
||||
task_store=InMemoryTaskStore(),
|
||||
)
|
||||
|
||||
server = A2AStarletteApplication(
|
||||
agent_card=public_agent_card,
|
||||
http_handler=request_handler,
|
||||
).build()
|
||||
|
||||
Args:
|
||||
agent: The AI agent to execute.
|
||||
stream: Whether to stream the agent response. Defaults to False.
|
||||
run_kwargs: Additional keyword arguments to pass to the agent's run method.
|
||||
"""
|
||||
|
||||
def __init__(self, agent: SupportsAgentRun, stream: bool = False, run_kwargs: Mapping[str, Any] | None = None):
|
||||
"""Initialize the A2AExecutor with the specified agent.
|
||||
|
||||
Args:
|
||||
agent: The AI agent or workflow to execute.
|
||||
stream: Whether to stream the agent response. Defaults to False.
|
||||
run_kwargs: Additional keyword arguments to pass to the agent's run method.
|
||||
Cannot contain 'session' or 'stream' as these are managed by the executor.
|
||||
|
||||
Raises:
|
||||
ValueError: If run_kwargs contains 'session' or 'stream'.
|
||||
"""
|
||||
super().__init__()
|
||||
self._agent: SupportsAgentRun = agent
|
||||
self._stream: bool = stream
|
||||
if run_kwargs:
|
||||
if "session" in run_kwargs:
|
||||
raise ValueError("run_kwargs cannot contain 'session' as it is managed by the executor.")
|
||||
if "stream" in run_kwargs:
|
||||
raise ValueError("run_kwargs cannot contain 'stream' as it is managed by the executor.")
|
||||
self._run_kwargs: Mapping[str, Any] = run_kwargs or {}
|
||||
|
||||
@override
|
||||
async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None:
|
||||
"""Cancel agent execution for the given request context.
|
||||
|
||||
Uses a TaskUpdater to send a cancellation event through the provided event queue.
|
||||
|
||||
Args:
|
||||
context: The request context identifying the task to cancel.
|
||||
event_queue: The event queue to publish the cancellation event to.
|
||||
|
||||
Raises:
|
||||
ValueError: If context_id is not provided in the RequestContext.
|
||||
"""
|
||||
if context.context_id is None:
|
||||
raise ValueError("Context ID must be provided in the RequestContext")
|
||||
|
||||
updater = TaskUpdater(
|
||||
event_queue=event_queue,
|
||||
task_id=context.task_id or "",
|
||||
context_id=context.context_id,
|
||||
)
|
||||
|
||||
await updater.cancel()
|
||||
|
||||
@override
|
||||
async def execute(self, context: RequestContext, event_queue: EventQueue) -> None:
|
||||
"""Execute the agent with the given context and event queue.
|
||||
|
||||
Orchestrates the agent execution process: sets up the agent session,
|
||||
executes the agent, processes response messages, and handles errors with appropriate task status updates.
|
||||
"""
|
||||
if context.context_id is None:
|
||||
raise ValueError("Context ID must be provided in the RequestContext")
|
||||
if context.message is None:
|
||||
raise ValueError("Message must be provided in the RequestContext")
|
||||
|
||||
query = context.get_user_input()
|
||||
task = context.current_task
|
||||
|
||||
if not task:
|
||||
task = new_task(context.message)
|
||||
await event_queue.enqueue_event(task)
|
||||
|
||||
updater = TaskUpdater(event_queue, task.id, context.context_id)
|
||||
await updater.submit()
|
||||
|
||||
try:
|
||||
await updater.start_work()
|
||||
|
||||
session = self._agent.create_session(session_id=task.context_id)
|
||||
|
||||
if self._stream:
|
||||
await self._run_stream(query, session, updater)
|
||||
else:
|
||||
await self._run(query, session, updater)
|
||||
|
||||
# Mark as complete
|
||||
await updater.complete()
|
||||
except CancelledError:
|
||||
await updater.update_status(state=TaskState.canceled, final=True)
|
||||
except Exception as e:
|
||||
logger.exception("A2AExecutor encountered an error during execution.", exc_info=e)
|
||||
await updater.update_status(
|
||||
state=TaskState.failed,
|
||||
final=True,
|
||||
message=updater.new_agent_message([Part(root=TextPart(text=str(e)))]),
|
||||
)
|
||||
|
||||
async def _run_stream(self, query: Any, session: AgentSession, updater: TaskUpdater) -> None:
|
||||
"""Run the agent in streaming mode and publish updates to the task updater."""
|
||||
response_stream = self._agent.run(query, session=session, stream=True, **self._run_kwargs)
|
||||
streamed_artifact_ids: set[str] = set()
|
||||
await (
|
||||
response_stream.with_transform_hook(
|
||||
partial(self.handle_events, updater=updater, streamed_artifact_ids=streamed_artifact_ids)
|
||||
)
|
||||
).get_final_response()
|
||||
|
||||
async def _run(self, query: Any, session: AgentSession, updater: TaskUpdater) -> None:
|
||||
"""Run the agent in non-streaming mode and publish messages to the task updater."""
|
||||
response = await self._agent.run(query, session=session, stream=False, **self._run_kwargs)
|
||||
response_messages = response.messages
|
||||
|
||||
if not isinstance(response_messages, list):
|
||||
response_messages = [response_messages]
|
||||
|
||||
for message in response_messages:
|
||||
await self.handle_events(message, updater)
|
||||
|
||||
async def handle_events(
|
||||
self, item: Message | AgentResponseUpdate, updater: TaskUpdater, streamed_artifact_ids: set[str] | None = None
|
||||
) -> None:
|
||||
"""Convert agent response items (Messages or Updates) to A2A protocol events.
|
||||
|
||||
Processes Message or AgentResponseUpdate objects and converts them into A2A protocol format.
|
||||
Handles text, data, and URI content. USER role messages are skipped.
|
||||
|
||||
Users can override this method in a subclass to implement custom transformations
|
||||
from their agent's output format to A2A protocol events.
|
||||
|
||||
Args:
|
||||
item: The agent response item (Message or AgentResponseUpdate) to process.
|
||||
updater: The task updater to publish events to.
|
||||
streamed_artifact_ids: A set of artifact IDs that have already been streamed.
|
||||
Used to prevent duplicate updates for the same artifact.
|
||||
|
||||
Example:
|
||||
.. code-block:: python
|
||||
|
||||
class CustomA2AExecutor(A2AExecutor):
|
||||
async def handle_events(
|
||||
self,
|
||||
item: Message | AgentResponseUpdate,
|
||||
updater: TaskUpdater,
|
||||
streamed_artifact_ids: set[str] | None = None,
|
||||
) -> None:
|
||||
# Custom logic to transform item contents
|
||||
if item.role == "assistant" and item.contents:
|
||||
parts = [Part(root=TextPart(text=f"Custom: {item.contents[0].text}"))]
|
||||
await updater.update_status(
|
||||
state=TaskState.working,
|
||||
message=updater.new_agent_message(parts=parts),
|
||||
)
|
||||
else:
|
||||
await super().handle_events(item, updater)
|
||||
"""
|
||||
role = getattr(item, "role", None)
|
||||
if role == "user":
|
||||
# This is a user message, we can ignore it in the context of task updates
|
||||
return
|
||||
|
||||
parts: list[Part] = []
|
||||
metadata = getattr(item, "additional_properties", None)
|
||||
|
||||
# AgentResponseUpdate uses 'contents', Message uses 'contents'
|
||||
contents = getattr(item, "contents", [])
|
||||
|
||||
for content in contents:
|
||||
if content.type == "text" and content.text:
|
||||
parts.append(Part(root=TextPart(text=content.text)))
|
||||
elif content.type == "data" and content.uri:
|
||||
base64_str = get_uri_data(content.uri)
|
||||
parts.append(Part(root=FilePart(file=FileWithBytes(bytes=base64_str, mime_type=content.media_type))))
|
||||
elif content.type == "uri" and content.uri:
|
||||
parts.append(Part(root=FilePart(file=FileWithUri(uri=content.uri, mime_type=content.media_type))))
|
||||
else:
|
||||
# Silently skip unsupported content types
|
||||
logger.warning("A2AExecutor does not yet support content type: %s. Omitted.", content.type)
|
||||
|
||||
if parts:
|
||||
if isinstance(item, AgentResponseUpdate):
|
||||
# For streaming updates, we send TaskArtifactUpdateEvent via add_artifact
|
||||
await updater.add_artifact(
|
||||
parts=parts,
|
||||
artifact_id=item.message_id,
|
||||
metadata=metadata,
|
||||
append=(
|
||||
True
|
||||
if streamed_artifact_ids is not None and item.message_id in (streamed_artifact_ids or set())
|
||||
else None
|
||||
),
|
||||
)
|
||||
if item.message_id and streamed_artifact_ids is not None:
|
||||
streamed_artifact_ids.add(item.message_id)
|
||||
else:
|
||||
# For final messages, we send TaskStatusUpdateEvent with 'working' state
|
||||
await updater.update_status(
|
||||
state=TaskState.working,
|
||||
message=updater.new_agent_message(parts=parts, metadata=metadata),
|
||||
)
|
||||
@@ -4,7 +4,6 @@ from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
import uuid
|
||||
from collections.abc import AsyncIterable, Awaitable, Mapping, Sequence
|
||||
from typing import Any, Final, Literal, TypeAlias, overload
|
||||
@@ -49,7 +48,7 @@ from agent_framework.observability import AgentTelemetryLayer
|
||||
|
||||
__all__ = ["A2AAgent", "A2AContinuationToken"]
|
||||
|
||||
URI_PATTERN = re.compile(r"^data:(?P<media_type>[^;]+);base64,(?P<base64_data>[A-Za-z0-9+/=]+)$")
|
||||
from agent_framework_a2a._utils import get_uri_data
|
||||
|
||||
|
||||
class A2AContinuationToken(ContinuationToken):
|
||||
@@ -78,14 +77,6 @@ A2AClientEvent: TypeAlias = tuple[Task, TaskStatusUpdateEvent | TaskArtifactUpda
|
||||
A2AStreamItem: TypeAlias = A2AMessage | A2AClientEvent
|
||||
|
||||
|
||||
def _get_uri_data(uri: str) -> str:
|
||||
match = URI_PATTERN.match(uri)
|
||||
if not match:
|
||||
raise ValueError(f"Invalid data URI format: {uri}")
|
||||
|
||||
return match.group("base64_data")
|
||||
|
||||
|
||||
class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
"""Agent2Agent (A2A) protocol implementation.
|
||||
|
||||
@@ -652,7 +643,7 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
A2APart(
|
||||
root=FilePart(
|
||||
file=FileWithBytes(
|
||||
bytes=_get_uri_data(content.uri),
|
||||
bytes=get_uri_data(content.uri),
|
||||
mime_type=content.media_type,
|
||||
),
|
||||
metadata=content.additional_properties,
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import re
|
||||
|
||||
URI_PATTERN = re.compile(r"^data:(?P<media_type>[^;]+);base64,(?P<base64_data>[A-Za-z0-9+/=]+)$")
|
||||
|
||||
|
||||
def get_uri_data(uri: str) -> str:
|
||||
"""Extracts the base64-encoded data from a data URI.
|
||||
|
||||
Args:
|
||||
uri: The data URI to parse.
|
||||
|
||||
Returns:
|
||||
The base64-encoded data part of the URI.
|
||||
|
||||
Raises:
|
||||
ValueError: If the URI format is invalid.
|
||||
"""
|
||||
match = URI_PATTERN.match(uri)
|
||||
if not match:
|
||||
raise ValueError(f"Invalid data URI format: {uri}")
|
||||
|
||||
return match.group("base64_data")
|
||||
@@ -35,7 +35,7 @@ from agent_framework.a2a import A2AAgent
|
||||
from pytest import fixture, mark, raises
|
||||
|
||||
from agent_framework_a2a import A2AContinuationToken
|
||||
from agent_framework_a2a._agent import _get_uri_data # type: ignore
|
||||
from agent_framework_a2a._utils import get_uri_data
|
||||
|
||||
|
||||
class MockA2AClient:
|
||||
@@ -353,18 +353,18 @@ def test_parse_message_from_artifact(a2a_agent: A2AAgent) -> None:
|
||||
|
||||
|
||||
def test_get_uri_data_valid_uri() -> None:
|
||||
"""Test _get_uri_data with valid data URI."""
|
||||
"""Test get_uri_data with valid data URI."""
|
||||
|
||||
uri = "data:application/json;base64,eyJ0ZXN0IjoidmFsdWUifQ=="
|
||||
result = _get_uri_data(uri)
|
||||
result = get_uri_data(uri)
|
||||
assert result == "eyJ0ZXN0IjoidmFsdWUifQ=="
|
||||
|
||||
|
||||
def test_get_uri_data_invalid_uri() -> None:
|
||||
"""Test _get_uri_data with invalid URI format."""
|
||||
"""Test get_uri_data with invalid URI format."""
|
||||
|
||||
with raises(ValueError, match="Invalid data URI format"):
|
||||
_get_uri_data("not-a-valid-data-uri")
|
||||
get_uri_data("not-a-valid-data-uri")
|
||||
|
||||
|
||||
def test_parse_contents_from_a2a_conversion(a2a_agent: A2AAgent) -> None:
|
||||
|
||||
@@ -0,0 +1,910 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
from asyncio import CancelledError
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
from a2a.types import Task, TaskState, TextPart
|
||||
from agent_framework import (
|
||||
AgentResponseUpdate,
|
||||
Content,
|
||||
Message,
|
||||
SupportsAgentRun,
|
||||
)
|
||||
from agent_framework._types import AgentResponse
|
||||
from agent_framework.a2a import A2AExecutor
|
||||
from pytest import fixture, raises
|
||||
|
||||
|
||||
@fixture
|
||||
def mock_agent() -> MagicMock:
|
||||
"""Fixture that provides a mock SupportsAgentRun."""
|
||||
agent = MagicMock(spec=SupportsAgentRun)
|
||||
agent.run = AsyncMock()
|
||||
return agent
|
||||
|
||||
|
||||
@fixture
|
||||
def mock_request_context() -> MagicMock:
|
||||
"""Fixture that provides a mock RequestContext."""
|
||||
request_context = MagicMock()
|
||||
request_context.context_id = str(uuid4())
|
||||
request_context.get_user_input = MagicMock(return_value="Test query")
|
||||
request_context.current_task = None
|
||||
request_context.message = None
|
||||
return request_context
|
||||
|
||||
|
||||
@fixture
|
||||
def mock_event_queue() -> MagicMock:
|
||||
"""Fixture that provides a mock EventQueue."""
|
||||
queue = AsyncMock()
|
||||
queue.enqueue_event = AsyncMock()
|
||||
return queue
|
||||
|
||||
|
||||
@fixture
|
||||
def mock_task() -> Task:
|
||||
"""Fixture that provides a mock Task."""
|
||||
task = MagicMock(spec=Task)
|
||||
task.id = str(uuid4())
|
||||
task.context_id = str(uuid4())
|
||||
task.state = TaskState.completed
|
||||
return task
|
||||
|
||||
|
||||
@fixture
|
||||
def mock_task_updater() -> MagicMock:
|
||||
"""Fixture that provides a mock TaskUpdater."""
|
||||
updater = MagicMock()
|
||||
updater.submit = AsyncMock()
|
||||
updater.start_work = AsyncMock()
|
||||
updater.complete = AsyncMock()
|
||||
updater.update_status = AsyncMock()
|
||||
updater.new_agent_message = MagicMock()
|
||||
return updater
|
||||
|
||||
|
||||
@fixture
|
||||
def executor(mock_agent: MagicMock) -> A2AExecutor:
|
||||
"""Fixture that provides an A2AExecutor."""
|
||||
return A2AExecutor(agent=mock_agent)
|
||||
|
||||
|
||||
class TestA2AExecutorInitialization:
|
||||
"""Tests for A2AExecutor initialization."""
|
||||
|
||||
def test_initialization_with_agent_only(self, mock_agent: MagicMock) -> None:
|
||||
"""Arrange: Create mock agent
|
||||
Act: Initialize A2AExecutor with only agent
|
||||
Assert: Executor is created with default values
|
||||
"""
|
||||
# Act
|
||||
executor = A2AExecutor(agent=mock_agent)
|
||||
|
||||
# Assert
|
||||
assert executor._agent is mock_agent
|
||||
assert executor._stream is False
|
||||
assert executor._run_kwargs == {}
|
||||
|
||||
def test_initialization_with_stream_and_kwargs(self, mock_agent: MagicMock) -> None:
|
||||
"""Arrange: Create mock agent
|
||||
Act: Initialize A2AExecutor with stream and run_kwargs
|
||||
Assert: Executor is created with specified values
|
||||
"""
|
||||
# Arrange
|
||||
run_kwargs = {"temperature": 0.5}
|
||||
|
||||
# Act
|
||||
executor = A2AExecutor(agent=mock_agent, stream=True, run_kwargs=run_kwargs)
|
||||
|
||||
# Assert
|
||||
assert executor._agent is mock_agent
|
||||
assert executor._stream is True
|
||||
assert executor._run_kwargs == run_kwargs
|
||||
|
||||
def test_initialization_with_invalid_run_kwargs(self, mock_agent: MagicMock) -> None:
|
||||
"""Arrange: Create mock agent
|
||||
Act: Initialize A2AExecutor with reserved keys in run_kwargs
|
||||
Assert: ValueError is raised
|
||||
"""
|
||||
# Act & Assert
|
||||
with raises(ValueError, match="run_kwargs cannot contain 'session'"):
|
||||
A2AExecutor(agent=mock_agent, run_kwargs={"session": "something"})
|
||||
|
||||
with raises(ValueError, match="run_kwargs cannot contain 'stream'"):
|
||||
A2AExecutor(agent=mock_agent, run_kwargs={"stream": True})
|
||||
|
||||
|
||||
class TestA2AExecutorCancel:
|
||||
"""Tests for the cancel method."""
|
||||
|
||||
async def test_cancel_method_completes(
|
||||
self,
|
||||
executor: A2AExecutor,
|
||||
mock_request_context: MagicMock,
|
||||
mock_event_queue: MagicMock,
|
||||
) -> None:
|
||||
"""Arrange: Create executor with dependencies
|
||||
Act: Call cancel method
|
||||
Assert: Method completes without raising error
|
||||
"""
|
||||
# Arrange
|
||||
mock_request_context.task_id = "task-123"
|
||||
|
||||
# Act & Assert (should not raise)
|
||||
await executor.cancel(mock_request_context, mock_event_queue) # type: ignore
|
||||
|
||||
async def test_cancel_handles_different_contexts(
|
||||
self,
|
||||
executor: A2AExecutor,
|
||||
mock_event_queue: MagicMock,
|
||||
) -> None:
|
||||
"""Arrange: Create executor with multiple request contexts
|
||||
Act: Call cancel with different contexts
|
||||
Assert: Each cancel completes successfully
|
||||
"""
|
||||
# Arrange
|
||||
context1 = MagicMock()
|
||||
context1.context_id = "ctx-1"
|
||||
context1.task_id = "task-1"
|
||||
context2 = MagicMock()
|
||||
context2.context_id = "ctx-2"
|
||||
context2.task_id = "task-2"
|
||||
|
||||
# Act & Assert
|
||||
await executor.cancel(context1, mock_event_queue) # type: ignore
|
||||
await executor.cancel(context2, mock_event_queue) # type: ignore
|
||||
|
||||
async def test_cancel_raises_error_when_context_id_missing(
|
||||
self,
|
||||
executor: A2AExecutor,
|
||||
mock_event_queue: MagicMock,
|
||||
) -> None:
|
||||
"""Arrange: Create context without context_id
|
||||
Act: Call cancel method
|
||||
Assert: ValueError is raised
|
||||
"""
|
||||
# Arrange
|
||||
mock_context = MagicMock()
|
||||
mock_context.context_id = None
|
||||
|
||||
# Act & Assert
|
||||
with raises(ValueError) as excinfo:
|
||||
await executor.cancel(mock_context, mock_event_queue) # type: ignore
|
||||
|
||||
# Assert
|
||||
assert "Context ID" in str(excinfo.value)
|
||||
|
||||
|
||||
class TestA2AExecutorExecute:
|
||||
"""Tests for the execute method."""
|
||||
|
||||
async def test_execute_with_existing_task_succeeds(
|
||||
self,
|
||||
executor: A2AExecutor,
|
||||
mock_request_context: MagicMock,
|
||||
mock_event_queue: MagicMock,
|
||||
mock_task: Task,
|
||||
) -> None:
|
||||
"""Arrange: Create executor with mocked dependencies and existing task
|
||||
Act: Call execute method
|
||||
Assert: Execution completes successfully
|
||||
"""
|
||||
# Arrange
|
||||
mock_request_context.get_user_input = MagicMock(return_value="Hello")
|
||||
mock_request_context.current_task = mock_task
|
||||
mock_request_context.context_id = "ctx-123"
|
||||
mock_request_context.message = MagicMock()
|
||||
|
||||
response_message = Message(role="assistant", contents=[Content.from_text(text="Hello back")])
|
||||
response = MagicMock(spec=AgentResponse)
|
||||
response.messages = [response_message]
|
||||
executor._agent.run = AsyncMock(return_value=response)
|
||||
executor._agent.create_session = MagicMock()
|
||||
|
||||
with patch("agent_framework_a2a._a2a_executor.TaskUpdater") as mock_updater_class:
|
||||
mock_updater = MagicMock()
|
||||
mock_updater.submit = AsyncMock()
|
||||
mock_updater.start_work = AsyncMock()
|
||||
mock_updater.complete = AsyncMock()
|
||||
mock_updater.update_status = AsyncMock()
|
||||
mock_updater.new_agent_message = MagicMock(return_value="message_obj")
|
||||
mock_updater_class.return_value = mock_updater
|
||||
|
||||
# Act
|
||||
await executor.execute(mock_request_context, mock_event_queue)
|
||||
|
||||
# Assert
|
||||
mock_updater.submit.assert_called_once()
|
||||
mock_updater.start_work.assert_called_once()
|
||||
mock_updater.complete.assert_called_once()
|
||||
executor._agent.create_session.assert_called_once()
|
||||
executor._agent.run.assert_called_once()
|
||||
|
||||
async def test_execute_creates_task_when_not_exists(
|
||||
self,
|
||||
executor: A2AExecutor,
|
||||
mock_request_context: MagicMock,
|
||||
mock_event_queue: MagicMock,
|
||||
) -> None:
|
||||
"""Arrange: Create executor with request context without task
|
||||
Act: Call execute method
|
||||
Assert: New task is created and enqueued
|
||||
"""
|
||||
# Arrange
|
||||
mock_message = MagicMock()
|
||||
mock_request_context.get_user_input = MagicMock(return_value="Hello")
|
||||
mock_request_context.current_task = None
|
||||
mock_request_context.message = mock_message
|
||||
mock_request_context.context_id = "ctx-123"
|
||||
|
||||
response_message = Message(role="assistant", contents=[Content.from_text(text="Response")])
|
||||
response = MagicMock(spec=AgentResponse)
|
||||
response.messages = [response_message]
|
||||
executor._agent.run = AsyncMock(return_value=response)
|
||||
executor._agent.create_session = MagicMock()
|
||||
|
||||
with patch("agent_framework_a2a._a2a_executor.new_task") as mock_new_task:
|
||||
mock_task = MagicMock(spec=Task)
|
||||
mock_task.id = "task-new"
|
||||
mock_task.context_id = "ctx-123"
|
||||
mock_new_task.return_value = mock_task
|
||||
|
||||
with patch("agent_framework_a2a._a2a_executor.TaskUpdater") as mock_updater_class:
|
||||
mock_updater = MagicMock()
|
||||
mock_updater.submit = AsyncMock()
|
||||
mock_updater.start_work = AsyncMock()
|
||||
mock_updater.complete = AsyncMock()
|
||||
mock_updater.update_status = AsyncMock()
|
||||
mock_updater.new_agent_message = MagicMock(return_value="message_obj")
|
||||
mock_updater_class.return_value = mock_updater
|
||||
|
||||
# Act
|
||||
await executor.execute(mock_request_context, mock_event_queue)
|
||||
|
||||
# Assert
|
||||
mock_new_task.assert_called_once()
|
||||
mock_event_queue.enqueue_event.assert_called_once()
|
||||
|
||||
async def test_execute_raises_error_when_context_id_missing(
|
||||
self,
|
||||
executor: A2AExecutor,
|
||||
mock_request_context: MagicMock,
|
||||
mock_event_queue: MagicMock,
|
||||
) -> None:
|
||||
"""Arrange: Create context without context_id
|
||||
Act: Call execute method
|
||||
Assert: ValueError is raised
|
||||
"""
|
||||
# Arrange
|
||||
mock_request_context.context_id = None
|
||||
mock_request_context.message = MagicMock()
|
||||
|
||||
# Act & Assert
|
||||
with raises(ValueError) as excinfo:
|
||||
await executor.execute(mock_request_context, mock_event_queue)
|
||||
|
||||
# Assert
|
||||
assert "Context ID" in str(excinfo.value)
|
||||
|
||||
async def test_execute_raises_error_when_message_missing(
|
||||
self,
|
||||
executor: A2AExecutor,
|
||||
mock_request_context: MagicMock,
|
||||
mock_event_queue: MagicMock,
|
||||
) -> None:
|
||||
"""Arrange: Create context without message
|
||||
Act: Call execute method
|
||||
Assert: ValueError is raised
|
||||
"""
|
||||
# Arrange
|
||||
mock_request_context.context_id = "ctx-123"
|
||||
mock_request_context.message = None
|
||||
|
||||
# Act & Assert
|
||||
with raises(ValueError) as excinfo:
|
||||
await executor.execute(mock_request_context, mock_event_queue)
|
||||
|
||||
# Assert
|
||||
assert "Message" in str(excinfo.value)
|
||||
|
||||
async def test_execute_handles_cancelled_error(
|
||||
self,
|
||||
executor: A2AExecutor,
|
||||
mock_request_context: MagicMock,
|
||||
mock_event_queue: MagicMock,
|
||||
mock_task: Task,
|
||||
) -> None:
|
||||
"""Arrange: Create executor that raises CancelledError
|
||||
Act: Call execute method
|
||||
Assert: Error is caught and task is marked as canceled
|
||||
"""
|
||||
# Arrange
|
||||
mock_request_context.get_user_input = MagicMock(return_value="Hello")
|
||||
mock_request_context.current_task = mock_task
|
||||
mock_request_context.context_id = "ctx-123"
|
||||
mock_request_context.message = MagicMock()
|
||||
|
||||
executor._agent.run = AsyncMock(side_effect=CancelledError())
|
||||
executor._agent.create_session = MagicMock()
|
||||
|
||||
with patch("agent_framework_a2a._a2a_executor.TaskUpdater") as mock_updater_class:
|
||||
mock_updater = MagicMock()
|
||||
mock_updater.submit = AsyncMock()
|
||||
mock_updater.start_work = AsyncMock()
|
||||
mock_updater.update_status = AsyncMock()
|
||||
mock_updater_class.return_value = mock_updater
|
||||
|
||||
# Act
|
||||
await executor.execute(mock_request_context, mock_event_queue) # type: ignore
|
||||
|
||||
# Assert
|
||||
mock_updater.update_status.assert_called()
|
||||
call_args_list = mock_updater.update_status.call_args_list
|
||||
assert any(
|
||||
call[1].get("state") == TaskState.canceled and call[1].get("final") is True for call in call_args_list
|
||||
)
|
||||
|
||||
async def test_execute_handles_generic_exception(
|
||||
self,
|
||||
executor: A2AExecutor,
|
||||
mock_request_context: MagicMock,
|
||||
mock_event_queue: MagicMock,
|
||||
mock_task: Task,
|
||||
) -> None:
|
||||
"""Arrange: Create executor that raises generic exception
|
||||
Act: Call execute method
|
||||
Assert: Error is caught and task is marked as failed
|
||||
"""
|
||||
# Arrange
|
||||
mock_request_context.get_user_input = MagicMock(return_value="Hello")
|
||||
mock_request_context.current_task = mock_task
|
||||
mock_request_context.context_id = "ctx-123"
|
||||
mock_request_context.message = MagicMock()
|
||||
|
||||
error_message = "Test error"
|
||||
executor._agent.run = AsyncMock(side_effect=ValueError(error_message))
|
||||
executor._agent.create_session = MagicMock()
|
||||
|
||||
with patch("agent_framework_a2a._a2a_executor.TaskUpdater") as mock_updater_class:
|
||||
mock_updater = MagicMock()
|
||||
mock_updater.submit = AsyncMock()
|
||||
mock_updater.start_work = AsyncMock()
|
||||
mock_updater.update_status = AsyncMock()
|
||||
mock_updater.new_agent_message = MagicMock(return_value="error_message_obj")
|
||||
mock_updater_class.return_value = mock_updater
|
||||
|
||||
# Act
|
||||
await executor.execute(mock_request_context, mock_event_queue)
|
||||
|
||||
# Assert
|
||||
mock_updater.new_agent_message.assert_called_once()
|
||||
args, _ = mock_updater.new_agent_message.call_args
|
||||
parts = args[0]
|
||||
assert len(parts) == 1
|
||||
assert isinstance(parts[0].root, TextPart)
|
||||
assert parts[0].root.text == error_message
|
||||
|
||||
call_args_list = mock_updater.update_status.call_args_list
|
||||
assert any(
|
||||
call[1].get("state") == TaskState.failed
|
||||
and call[1].get("final") is True
|
||||
and call[1].get("message") == "error_message_obj"
|
||||
for call in call_args_list
|
||||
)
|
||||
|
||||
async def test_execute_processes_multiple_response_messages(
|
||||
self,
|
||||
executor: A2AExecutor,
|
||||
mock_request_context: MagicMock,
|
||||
mock_event_queue: MagicMock,
|
||||
mock_task: Task,
|
||||
) -> None:
|
||||
"""Arrange: Create executor that returns multiple response messages
|
||||
Act: Call execute method
|
||||
Assert: All messages are processed through handle_events
|
||||
"""
|
||||
# Arrange
|
||||
mock_request_context.get_user_input = MagicMock(return_value="Hello")
|
||||
mock_request_context.current_task = mock_task
|
||||
mock_request_context.context_id = "ctx-123"
|
||||
mock_request_context.message = MagicMock()
|
||||
|
||||
response_message1 = Message(role="assistant", contents=[Content.from_text(text="First")])
|
||||
response_message2 = Message(role="assistant", contents=[Content.from_text(text="Second")])
|
||||
response = MagicMock(spec=AgentResponse)
|
||||
response.messages = [response_message1, response_message2]
|
||||
executor._agent.run = AsyncMock(return_value=response)
|
||||
executor._agent.create_session = MagicMock()
|
||||
|
||||
# Mock handle_events
|
||||
executor.handle_events = AsyncMock()
|
||||
|
||||
with patch("agent_framework_a2a._a2a_executor.TaskUpdater") as mock_updater_class:
|
||||
mock_updater = MagicMock()
|
||||
mock_updater.submit = AsyncMock()
|
||||
mock_updater.start_work = AsyncMock()
|
||||
mock_updater.complete = AsyncMock()
|
||||
mock_updater.update_status = AsyncMock()
|
||||
mock_updater_class.return_value = mock_updater
|
||||
|
||||
# Act
|
||||
await executor.execute(mock_request_context, mock_event_queue)
|
||||
|
||||
# Assert
|
||||
assert executor.handle_events.call_count == 2
|
||||
|
||||
async def test_execute_passes_query_to_run(
|
||||
self,
|
||||
executor: A2AExecutor,
|
||||
mock_request_context: MagicMock,
|
||||
mock_event_queue: MagicMock,
|
||||
mock_task: Task,
|
||||
) -> None:
|
||||
"""Arrange: Create executor with request
|
||||
Act: Call execute method
|
||||
Assert: Query text is passed to run method with default stream and kwargs
|
||||
"""
|
||||
# Arrange
|
||||
query_text = "Hello agent"
|
||||
mock_request_context.get_user_input = MagicMock(return_value=query_text)
|
||||
mock_request_context.current_task = mock_task
|
||||
mock_request_context.context_id = "ctx-123"
|
||||
mock_request_context.message = MagicMock()
|
||||
|
||||
response_message = Message(role="assistant", contents=[Content.from_text(text="Response")])
|
||||
response = MagicMock(spec=AgentResponse)
|
||||
response.messages = [response_message]
|
||||
executor._agent.run = AsyncMock(return_value=response)
|
||||
executor._agent.create_session = MagicMock()
|
||||
|
||||
with patch("agent_framework_a2a._a2a_executor.TaskUpdater") as mock_updater_class:
|
||||
mock_updater = MagicMock()
|
||||
mock_updater.submit = AsyncMock()
|
||||
mock_updater.start_work = AsyncMock()
|
||||
mock_updater.complete = AsyncMock()
|
||||
mock_updater.update_status = AsyncMock()
|
||||
mock_updater.new_agent_message = MagicMock(return_value="message_obj")
|
||||
mock_updater_class.return_value = mock_updater
|
||||
|
||||
# Act
|
||||
await executor.execute(mock_request_context, mock_event_queue)
|
||||
|
||||
# Assert
|
||||
executor._agent.run.assert_called_once_with(
|
||||
query_text, session=executor._agent.create_session(), stream=False
|
||||
)
|
||||
|
||||
async def test_execute_with_stream_enabled(
|
||||
self,
|
||||
mock_agent: MagicMock,
|
||||
mock_request_context: MagicMock,
|
||||
mock_event_queue: MagicMock,
|
||||
mock_task: Task,
|
||||
) -> None:
|
||||
"""Arrange: Create executor with stream=True
|
||||
Act: Call execute method
|
||||
Assert: _run_stream is called and passes stream=True to run
|
||||
"""
|
||||
# Arrange
|
||||
executor = A2AExecutor(agent=mock_agent, stream=True)
|
||||
query_text = "Hello agent"
|
||||
mock_request_context.get_user_input = MagicMock(return_value=query_text)
|
||||
mock_request_context.current_task = mock_task
|
||||
mock_request_context.context_id = "ctx-123"
|
||||
mock_request_context.message = MagicMock()
|
||||
|
||||
mock_response_stream = MagicMock()
|
||||
mock_response_stream.with_transform_hook = MagicMock(return_value=mock_response_stream)
|
||||
mock_response_stream.get_final_response = AsyncMock()
|
||||
mock_agent.run = MagicMock(return_value=mock_response_stream)
|
||||
mock_agent.create_session = MagicMock()
|
||||
|
||||
with patch("agent_framework_a2a._a2a_executor.TaskUpdater") as mock_updater_class:
|
||||
mock_updater = MagicMock()
|
||||
mock_updater.submit = AsyncMock()
|
||||
mock_updater.start_work = AsyncMock()
|
||||
mock_updater.complete = AsyncMock()
|
||||
mock_updater.update_status = AsyncMock()
|
||||
mock_updater_class.return_value = mock_updater
|
||||
|
||||
# Act
|
||||
await executor.execute(mock_request_context, mock_event_queue)
|
||||
|
||||
# Assert
|
||||
mock_agent.run.assert_called_once_with(query_text, session=mock_agent.create_session(), stream=True)
|
||||
mock_response_stream.with_transform_hook.assert_called_once()
|
||||
mock_response_stream.get_final_response.assert_called_once()
|
||||
|
||||
async def test_execute_with_run_kwargs(
|
||||
self,
|
||||
mock_agent: MagicMock,
|
||||
mock_request_context: MagicMock,
|
||||
mock_event_queue: MagicMock,
|
||||
mock_task: Task,
|
||||
) -> None:
|
||||
"""Arrange: Create executor with run_kwargs
|
||||
Act: Call execute method
|
||||
Assert: run_kwargs are passed to run method
|
||||
"""
|
||||
# Arrange
|
||||
run_kwargs = {"temperature": 0.5, "max_tokens": 100}
|
||||
executor = A2AExecutor(agent=mock_agent, run_kwargs=run_kwargs)
|
||||
query_text = "Hello agent"
|
||||
mock_request_context.get_user_input = MagicMock(return_value=query_text)
|
||||
mock_request_context.current_task = mock_task
|
||||
mock_request_context.context_id = "ctx-123"
|
||||
mock_request_context.message = MagicMock()
|
||||
|
||||
response_message = Message(role="assistant", contents=[Content.from_text(text="Response")])
|
||||
response = MagicMock(spec=AgentResponse)
|
||||
response.messages = [response_message]
|
||||
mock_agent.run = AsyncMock(return_value=response)
|
||||
mock_agent.create_session = MagicMock()
|
||||
|
||||
with patch("agent_framework_a2a._a2a_executor.TaskUpdater") as mock_updater_class:
|
||||
mock_updater = MagicMock()
|
||||
mock_updater.submit = AsyncMock()
|
||||
mock_updater.start_work = AsyncMock()
|
||||
mock_updater.complete = AsyncMock()
|
||||
mock_updater.update_status = AsyncMock()
|
||||
mock_updater_class.return_value = mock_updater
|
||||
|
||||
# Act
|
||||
await executor.execute(mock_request_context, mock_event_queue)
|
||||
|
||||
# Assert
|
||||
mock_agent.run.assert_called_once_with(
|
||||
query_text, session=mock_agent.create_session(), stream=False, **run_kwargs
|
||||
)
|
||||
|
||||
|
||||
class TestA2AExecutorHandleEvents:
|
||||
"""Tests for A2AExecutor.handle_events method."""
|
||||
|
||||
async def test_run_method_with_single_message(self, executor: A2AExecutor, mock_updater: MagicMock) -> None:
|
||||
"""Test the private _run method with a single message (not a list)."""
|
||||
# Arrange
|
||||
query = "test query"
|
||||
session = MagicMock()
|
||||
response_message = Message(role="assistant", contents=[Content.from_text(text="Response")])
|
||||
response = MagicMock(spec=AgentResponse)
|
||||
response.messages = response_message # Not a list
|
||||
executor._agent.run = AsyncMock(return_value=response)
|
||||
executor.handle_events = AsyncMock()
|
||||
|
||||
# Act
|
||||
await executor._run(query, session, mock_updater)
|
||||
|
||||
# Assert
|
||||
executor.handle_events.assert_called_once_with(response_message, mock_updater)
|
||||
|
||||
@fixture
|
||||
def mock_updater(self) -> MagicMock:
|
||||
"""Create a mock execution context."""
|
||||
updater = MagicMock()
|
||||
updater.update_status = AsyncMock()
|
||||
updater.new_agent_message = MagicMock(return_value="mock_message")
|
||||
return updater
|
||||
|
||||
async def test_ignore_user_messages(self, executor: A2AExecutor, mock_updater: MagicMock) -> None:
|
||||
"""Test that messages from USER role are ignored."""
|
||||
# Arrange
|
||||
message = Message(
|
||||
contents=[Content.from_text(text="User input")],
|
||||
role="user",
|
||||
)
|
||||
|
||||
# Act
|
||||
await executor.handle_events(message, mock_updater)
|
||||
|
||||
# Assert
|
||||
mock_updater.update_status.assert_not_called()
|
||||
|
||||
async def test_ignore_messages_with_no_contents(self, executor: A2AExecutor, mock_updater: MagicMock) -> None:
|
||||
"""Test that messages with no contents are ignored."""
|
||||
# Arrange
|
||||
message = Message(
|
||||
contents=[],
|
||||
role="assistant",
|
||||
)
|
||||
|
||||
# Act
|
||||
await executor.handle_events(message, mock_updater)
|
||||
|
||||
# Assert
|
||||
mock_updater.update_status.assert_not_called()
|
||||
|
||||
async def test_handle_text_content(self, executor: A2AExecutor, mock_updater: MagicMock) -> None:
|
||||
"""Test handling messages with text content."""
|
||||
# Arrange
|
||||
text = "Hello, this is a test message"
|
||||
message = Message(
|
||||
contents=[Content.from_text(text=text)],
|
||||
role="assistant",
|
||||
)
|
||||
|
||||
# Act
|
||||
await executor.handle_events(message, mock_updater)
|
||||
|
||||
# Assert
|
||||
mock_updater.update_status.assert_called_once()
|
||||
call_args = mock_updater.update_status.call_args
|
||||
assert call_args.kwargs["state"] == TaskState.working
|
||||
assert mock_updater.new_agent_message.called
|
||||
|
||||
async def test_handle_multiple_text_contents(self, executor: A2AExecutor, mock_updater: MagicMock) -> None:
|
||||
"""Test handling messages with multiple text contents."""
|
||||
# Arrange
|
||||
message = Message(
|
||||
contents=[
|
||||
Content.from_text(text="First message"),
|
||||
Content.from_text(text="Second message"),
|
||||
],
|
||||
role="assistant",
|
||||
)
|
||||
|
||||
# Act
|
||||
await executor.handle_events(message, mock_updater)
|
||||
|
||||
# Assert
|
||||
mock_updater.update_status.assert_called_once()
|
||||
assert mock_updater.new_agent_message.called
|
||||
|
||||
async def test_handle_data_content(self, executor: A2AExecutor, mock_updater: MagicMock) -> None:
|
||||
"""Test handling messages with data content."""
|
||||
# Arrange
|
||||
data = b"test file data"
|
||||
message = Message(
|
||||
contents=[Content.from_data(data=data, media_type="application/octet-stream")],
|
||||
role="assistant",
|
||||
)
|
||||
|
||||
# Act
|
||||
await executor.handle_events(message, mock_updater)
|
||||
|
||||
# Assert
|
||||
mock_updater.update_status.assert_called_once()
|
||||
call_args = mock_updater.update_status.call_args
|
||||
assert call_args.kwargs["state"] == TaskState.working
|
||||
|
||||
async def test_handle_uri_content(self, executor: A2AExecutor, mock_updater: MagicMock) -> None:
|
||||
"""Test handling messages with URI content."""
|
||||
# Arrange
|
||||
uri = "https://example.com/file.pdf"
|
||||
message = Message(
|
||||
contents=[Content.from_uri(uri=uri, media_type="application/pdf")],
|
||||
role="assistant",
|
||||
)
|
||||
|
||||
# Act
|
||||
await executor.handle_events(message, mock_updater)
|
||||
|
||||
# Assert
|
||||
mock_updater.update_status.assert_called_once()
|
||||
call_args = mock_updater.update_status.call_args
|
||||
assert call_args.kwargs["state"] == TaskState.working
|
||||
|
||||
async def test_handle_mixed_content_types(self, executor: A2AExecutor, mock_updater: MagicMock) -> None:
|
||||
"""Test handling messages with mixed content types."""
|
||||
# Arrange
|
||||
data = b"file data"
|
||||
|
||||
message = Message(
|
||||
contents=[
|
||||
Content.from_text(text="Processing file..."),
|
||||
Content.from_data(data=data, media_type="application/octet-stream"),
|
||||
Content.from_uri(uri="https://example.com/reference.pdf", media_type="application/pdf"),
|
||||
],
|
||||
role="assistant",
|
||||
)
|
||||
|
||||
# Act
|
||||
await executor.handle_events(message, mock_updater)
|
||||
|
||||
# Assert
|
||||
mock_updater.update_status.assert_called_once()
|
||||
call_args = mock_updater.update_status.call_args
|
||||
assert call_args.kwargs["state"] == TaskState.working
|
||||
|
||||
async def test_handle_with_additional_properties(self, executor: A2AExecutor, mock_updater: MagicMock) -> None:
|
||||
"""Test handling messages with additional properties metadata."""
|
||||
# Arrange
|
||||
additional_props = {"custom_field": "custom_value", "priority": "high"}
|
||||
message = Message(
|
||||
contents=[Content.from_text(text="Test message")],
|
||||
role="assistant",
|
||||
additional_properties=additional_props,
|
||||
)
|
||||
|
||||
# Act
|
||||
await executor.handle_events(message, mock_updater)
|
||||
|
||||
# Assert
|
||||
mock_updater.update_status.assert_called_once()
|
||||
mock_updater.new_agent_message.assert_called_once()
|
||||
call_args = mock_updater.new_agent_message.call_args
|
||||
assert call_args.kwargs["metadata"] == additional_props
|
||||
|
||||
async def test_handle_with_no_additional_properties(self, executor: A2AExecutor, mock_updater: MagicMock) -> None:
|
||||
"""Test handling messages without additional properties."""
|
||||
# Arrange
|
||||
message = Message(
|
||||
contents=[Content.from_text(text="Test message")],
|
||||
role="assistant",
|
||||
additional_properties=None,
|
||||
)
|
||||
|
||||
# Act
|
||||
await executor.handle_events(message, mock_updater)
|
||||
|
||||
# Assert
|
||||
mock_updater.update_status.assert_called_once()
|
||||
mock_updater.new_agent_message.assert_called_once()
|
||||
call_args = mock_updater.new_agent_message.call_args
|
||||
assert call_args.kwargs["metadata"] == {}
|
||||
|
||||
async def test_parts_list_passed_to_new_agent_message(self, executor: A2AExecutor, mock_updater: MagicMock) -> None:
|
||||
"""Test that parts list is correctly passed to new_agent_message."""
|
||||
# Arrange
|
||||
message = Message(
|
||||
contents=[
|
||||
Content.from_text(text="Message 1"),
|
||||
Content.from_text(text="Message 2"),
|
||||
],
|
||||
role="assistant",
|
||||
)
|
||||
|
||||
# Act
|
||||
await executor.handle_events(message, mock_updater)
|
||||
|
||||
# Assert
|
||||
mock_updater.new_agent_message.assert_called_once()
|
||||
call_kwargs = mock_updater.new_agent_message.call_args.kwargs
|
||||
assert "parts" in call_kwargs
|
||||
parts_list = call_kwargs["parts"]
|
||||
assert len(parts_list) == 2
|
||||
|
||||
async def test_task_state_always_working(self, executor: A2AExecutor, mock_updater: MagicMock) -> None:
|
||||
"""Test that task state is always set to working."""
|
||||
# Arrange
|
||||
message = Message(
|
||||
contents=[Content.from_text(text="Any message")],
|
||||
role="assistant",
|
||||
)
|
||||
|
||||
# Act
|
||||
await executor.handle_events(message, mock_updater)
|
||||
|
||||
# Assert
|
||||
call_kwargs = mock_updater.update_status.call_args.kwargs
|
||||
assert call_kwargs["state"] == TaskState.working
|
||||
|
||||
async def test_handle_agent_response_update_no_streamed_set(
|
||||
self, executor: A2AExecutor, mock_updater: MagicMock
|
||||
) -> None:
|
||||
"""Test handling AgentResponseUpdate (streaming) without a tracking set."""
|
||||
# Arrange
|
||||
update = AgentResponseUpdate(
|
||||
contents=[Content.from_text(text="Streaming chunk")],
|
||||
role="assistant",
|
||||
message_id="msg-1",
|
||||
)
|
||||
mock_updater.add_artifact = AsyncMock()
|
||||
|
||||
# Act
|
||||
await executor.handle_events(update, mock_updater)
|
||||
|
||||
# Assert
|
||||
mock_updater.add_artifact.assert_called_once()
|
||||
call_kwargs = mock_updater.add_artifact.call_args.kwargs
|
||||
assert call_kwargs["artifact_id"] == "msg-1"
|
||||
assert call_kwargs["append"] is None
|
||||
|
||||
async def test_handle_agent_response_update_first_time(
|
||||
self, executor: A2AExecutor, mock_updater: MagicMock
|
||||
) -> None:
|
||||
"""Test handling AgentResponseUpdate (streaming) for the first time with a tracking set."""
|
||||
# Arrange
|
||||
update = AgentResponseUpdate(
|
||||
contents=[Content.from_text(text="Streaming chunk")],
|
||||
role="assistant",
|
||||
message_id="msg-1",
|
||||
)
|
||||
mock_updater.add_artifact = AsyncMock()
|
||||
streamed_artifact_ids = set()
|
||||
|
||||
# Act
|
||||
await executor.handle_events(update, mock_updater, streamed_artifact_ids=streamed_artifact_ids)
|
||||
|
||||
# Assert
|
||||
mock_updater.add_artifact.assert_called_once()
|
||||
call_kwargs = mock_updater.add_artifact.call_args.kwargs
|
||||
assert call_kwargs["append"] is None
|
||||
assert "msg-1" in streamed_artifact_ids
|
||||
|
||||
async def test_handle_agent_response_update_subsequent_time(
|
||||
self, executor: A2AExecutor, mock_updater: MagicMock
|
||||
) -> None:
|
||||
"""Test handling AgentResponseUpdate (streaming) for subsequent times with a tracking set."""
|
||||
# Arrange
|
||||
update = AgentResponseUpdate(
|
||||
contents=[Content.from_text(text="Next chunk")],
|
||||
role="assistant",
|
||||
message_id="msg-1",
|
||||
)
|
||||
mock_updater.add_artifact = AsyncMock()
|
||||
streamed_artifact_ids = {"msg-1"}
|
||||
|
||||
# Act
|
||||
await executor.handle_events(update, mock_updater, streamed_artifact_ids=streamed_artifact_ids)
|
||||
|
||||
# Assert
|
||||
mock_updater.add_artifact.assert_called_once()
|
||||
call_kwargs = mock_updater.add_artifact.call_args.kwargs
|
||||
assert call_kwargs["append"] is True
|
||||
|
||||
async def test_handle_unsupported_content_type(self, executor: A2AExecutor, mock_updater: MagicMock) -> None:
|
||||
"""Test handling messages with unsupported content types."""
|
||||
# Arrange
|
||||
message = Message(
|
||||
contents=[Content(type="unknown", text="Some text")],
|
||||
role="assistant",
|
||||
)
|
||||
|
||||
# Act
|
||||
with patch("agent_framework_a2a._a2a_executor.logger") as mock_logger:
|
||||
await executor.handle_events(message, mock_updater)
|
||||
|
||||
# Assert
|
||||
mock_logger.warning.assert_called_once()
|
||||
mock_updater.update_status.assert_not_called()
|
||||
|
||||
|
||||
class TestA2AExecutorIntegration:
|
||||
"""Integration tests for A2AExecutor."""
|
||||
|
||||
async def test_full_execution_flow_with_responses(
|
||||
self,
|
||||
executor: A2AExecutor,
|
||||
mock_request_context: MagicMock,
|
||||
mock_event_queue: MagicMock,
|
||||
mock_task: Task,
|
||||
) -> None:
|
||||
"""Arrange: Create executor with all mocked dependencies
|
||||
Act: Execute full flow from request to completion
|
||||
Assert: All components interact correctly
|
||||
"""
|
||||
# Arrange
|
||||
mock_request_context.get_user_input = MagicMock(return_value="Hello agent")
|
||||
mock_request_context.current_task = mock_task
|
||||
mock_request_context.context_id = "ctx-123"
|
||||
mock_request_context.message = MagicMock()
|
||||
|
||||
response = MagicMock(spec=AgentResponse)
|
||||
response_message = MagicMock(spec=Message)
|
||||
response.messages = [response_message]
|
||||
response_message.contents = [Content.from_text(text="Hello user")]
|
||||
response_message.role = "assistant"
|
||||
response_message.additional_properties = None
|
||||
|
||||
executor._agent.run = AsyncMock(return_value=response)
|
||||
executor._agent.create_session = MagicMock()
|
||||
executor.handle_events = AsyncMock()
|
||||
|
||||
with patch("agent_framework_a2a._a2a_executor.TaskUpdater") as mock_updater_class:
|
||||
mock_updater = MagicMock()
|
||||
mock_updater.submit = AsyncMock()
|
||||
mock_updater.start_work = AsyncMock()
|
||||
mock_updater.complete = AsyncMock()
|
||||
mock_updater.update_status = AsyncMock()
|
||||
mock_updater_class.return_value = mock_updater
|
||||
|
||||
# Act
|
||||
await executor.execute(mock_request_context, mock_event_queue)
|
||||
|
||||
# Assert
|
||||
mock_updater.submit.assert_called_once()
|
||||
mock_updater.start_work.assert_called_once()
|
||||
executor.handle_events.assert_called_once()
|
||||
mock_updater.complete.assert_called_once()
|
||||
@@ -0,0 +1,41 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_framework_a2a._utils import get_uri_data
|
||||
|
||||
|
||||
def test_get_uri_data_valid() -> None:
|
||||
"""Test get_uri_data with valid data URIs."""
|
||||
# Simple text/plain
|
||||
uri = "data:text/plain;base64,SGVsbG8sIFdvcmxkIQ=="
|
||||
assert get_uri_data(uri) == "SGVsbG8sIFdvcmxkIQ=="
|
||||
|
||||
# Image png
|
||||
uri = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=="
|
||||
assert get_uri_data(uri) == "iVBORw0KGgoAAAANSUhEUgfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=="
|
||||
|
||||
# Application octet-stream
|
||||
uri = "data:application/octet-stream;base64,AQIDBA=="
|
||||
assert get_uri_data(uri) == "AQIDBA=="
|
||||
|
||||
|
||||
def test_get_uri_data_invalid_format() -> None:
|
||||
"""Test get_uri_data with invalid URI formats."""
|
||||
invalid_uris = [
|
||||
"not-a-uri",
|
||||
"http://example.com",
|
||||
"data:text/plain;SGVsbG8sIFdvcmxkIQ==", # Missing base64 marker
|
||||
"data:base64,SGVsbG8sIFdvcmxkIQ==", # Missing media type
|
||||
"data:text/plain;charset=utf-8;base64,SGVsbG8sIFdvcmxkIQ==", # Extra parameters (current regex doesn't support)
|
||||
"data:text/plain;base64,SGVsbG8sIFdvcmxkIQ== extra",
|
||||
]
|
||||
for uri in invalid_uris:
|
||||
with pytest.raises(ValueError, match="Invalid data URI format"):
|
||||
get_uri_data(uri)
|
||||
|
||||
|
||||
def test_get_uri_data_empty() -> None:
|
||||
"""Test get_uri_data with empty string."""
|
||||
with pytest.raises(ValueError, match="Invalid data URI format"):
|
||||
get_uri_data("")
|
||||
@@ -263,27 +263,21 @@ def _deduplicate_messages(messages: list[Message]) -> list[Message]:
|
||||
return unique_messages
|
||||
|
||||
|
||||
def _parse_multimodal_media_part(part: dict[str, Any]) -> Content | None:
|
||||
"""Convert a multimodal media part into Agent Framework content."""
|
||||
part_type = str(part.get("type", "")).lower()
|
||||
source = part.get("source")
|
||||
def _extract_multimodal_source_fields(
|
||||
part: dict[str, Any],
|
||||
) -> tuple[str | None, str | None, str | None, str | None]:
|
||||
"""Extract ``(url, data, binary_id, mime_type)`` from an AG-UI multimodal part.
|
||||
|
||||
mime_type = cast(
|
||||
str | None,
|
||||
part.get("mimeType")
|
||||
or part.get("mime_type")
|
||||
or {
|
||||
"image": "image/*",
|
||||
"audio": "audio/*",
|
||||
"video": "video/*",
|
||||
"document": "application/octet-stream",
|
||||
"binary": "application/octet-stream",
|
||||
}.get(part_type, "application/octet-stream"),
|
||||
)
|
||||
Handles both the current AG-UI spec (``source.value`` for base64 payloads) and the
|
||||
legacy ``source.data`` field for backward compatibility. Returned values are the
|
||||
raw extracted strings (or ``None`` when absent); callers apply their own defaults.
|
||||
"""
|
||||
mime_type = cast(str | None, part.get("mimeType") or part.get("mime_type"))
|
||||
url = cast(str | None, part.get("url") or part.get("uri"))
|
||||
data = cast(str | None, part.get("data"))
|
||||
binary_id = cast(str | None, part.get("id"))
|
||||
|
||||
source = part.get("source")
|
||||
if isinstance(source, dict):
|
||||
source_dict = cast(dict[str, Any], source)
|
||||
source_type = str(source_dict.get("type", "")).lower()
|
||||
@@ -294,14 +288,31 @@ def _parse_multimodal_media_part(part: dict[str, Any]) -> Content | None:
|
||||
if source_type in {"url", "uri"}:
|
||||
url = cast(str | None, source_dict.get("url") or source_dict.get("uri"))
|
||||
elif source_type in {"base64", "data", "binary"}:
|
||||
data = cast(str | None, source_dict.get("data"))
|
||||
data = cast(str | None, source_dict.get("value") or source_dict.get("data"))
|
||||
elif source_type in {"id", "file"}:
|
||||
binary_id = cast(str | None, source_dict.get("id"))
|
||||
else:
|
||||
url = cast(str | None, source_dict.get("url") or source_dict.get("uri") or url)
|
||||
data = cast(str | None, source_dict.get("data") or data)
|
||||
data = cast(str | None, source_dict.get("value") or source_dict.get("data") or data)
|
||||
binary_id = cast(str | None, source_dict.get("id") or binary_id)
|
||||
|
||||
return url, data, binary_id, mime_type
|
||||
|
||||
|
||||
def _parse_multimodal_media_part(part: dict[str, Any]) -> Content | None:
|
||||
"""Convert a multimodal media part into Agent Framework content."""
|
||||
part_type = str(part.get("type", "")).lower()
|
||||
url, data, binary_id, mime_type = _extract_multimodal_source_fields(part)
|
||||
|
||||
if not mime_type:
|
||||
mime_type = {
|
||||
"image": "image/*",
|
||||
"audio": "audio/*",
|
||||
"video": "video/*",
|
||||
"document": "application/octet-stream",
|
||||
"binary": "application/octet-stream",
|
||||
}.get(part_type, "application/octet-stream")
|
||||
|
||||
if isinstance(url, str) and url:
|
||||
return Content.from_uri(uri=url, media_type=mime_type)
|
||||
|
||||
@@ -389,30 +400,7 @@ def _normalize_snapshot_content(content: Any) -> Any:
|
||||
def _legacy_binary_part(part: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Convert draft/legacy multimodal parts to AG-UI snapshot binary shape."""
|
||||
normalized: dict[str, Any] = {"type": "binary"}
|
||||
|
||||
mime_type = cast(str | None, part.get("mimeType") or part.get("mime_type"))
|
||||
url = cast(str | None, part.get("url") or part.get("uri"))
|
||||
data = cast(str | None, part.get("data"))
|
||||
binary_id = cast(str | None, part.get("id"))
|
||||
|
||||
source = part.get("source")
|
||||
if isinstance(source, dict):
|
||||
source_part = cast(dict[str, Any], source)
|
||||
source_mime = source_part.get("mimeType") or source_part.get("mime_type")
|
||||
if isinstance(source_mime, str) and source_mime:
|
||||
mime_type = source_mime
|
||||
|
||||
source_type = str(source_part.get("type", "")).lower()
|
||||
if source_type in {"url", "uri"}:
|
||||
url = cast(str | None, source_part.get("url") or source_part.get("uri"))
|
||||
elif source_type in {"base64", "data", "binary"}:
|
||||
data = cast(str | None, source_part.get("data"))
|
||||
elif source_type in {"id", "file"}:
|
||||
binary_id = cast(str | None, source_part.get("id"))
|
||||
else:
|
||||
url = cast(str | None, source_part.get("url") or source_part.get("uri") or url)
|
||||
data = cast(str | None, source_part.get("data") or data)
|
||||
binary_id = cast(str | None, source_part.get("id") or binary_id)
|
||||
url, data, binary_id, mime_type = _extract_multimodal_source_fields(part)
|
||||
|
||||
if isinstance(mime_type, str) and mime_type:
|
||||
normalized["mimeType"] = mime_type
|
||||
|
||||
@@ -596,7 +596,7 @@ def _emit_text_reasoning(content: Content, flow: FlowState | None = None) -> lis
|
||||
events.extend(_close_reasoning_block(flow))
|
||||
# Open new reasoning block.
|
||||
events.append(ReasoningStartEvent(message_id=message_id))
|
||||
events.append(ReasoningMessageStartEvent(message_id=message_id, role="assistant"))
|
||||
events.append(ReasoningMessageStartEvent(message_id=message_id, role="reasoning"))
|
||||
flow.reasoning_message_id = message_id
|
||||
|
||||
if text:
|
||||
@@ -613,7 +613,7 @@ def _emit_text_reasoning(content: Content, flow: FlowState | None = None) -> lis
|
||||
else:
|
||||
# No flow -- backward-compatible full sequence per call.
|
||||
events.append(ReasoningStartEvent(message_id=message_id))
|
||||
events.append(ReasoningMessageStartEvent(message_id=message_id, role="assistant"))
|
||||
events.append(ReasoningMessageStartEvent(message_id=message_id, role="reasoning"))
|
||||
|
||||
if text:
|
||||
events.append(ReasoningMessageContentEvent(message_id=message_id, delta=text))
|
||||
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.1.1,<2",
|
||||
"ag-ui-protocol==0.1.13",
|
||||
"ag-ui-protocol>=0.1.16,<0.2",
|
||||
"fastapi>=0.115.0,<0.133.1",
|
||||
"uvicorn[standard]>=0.30.0,<0.42.0"
|
||||
]
|
||||
|
||||
@@ -536,6 +536,77 @@ def test_agui_snapshot_format_preserves_multimodal_content():
|
||||
assert content_parts[1]["url"] == "https://example.com/image.png"
|
||||
|
||||
|
||||
def test_agui_snapshot_format_reads_base64_value_field():
|
||||
"""Snapshot normalization reads the spec 'value' field for base64 sources."""
|
||||
payload = base64.b64encode(b"abc").decode("utf-8")
|
||||
normalized = agui_messages_to_snapshot_format(
|
||||
[
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "image",
|
||||
"source": {"type": "base64", "value": payload, "mimeType": "image/png"},
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
binary_part = normalized[0]["content"][0]
|
||||
assert binary_part["type"] == "binary"
|
||||
assert binary_part["mimeType"] == "image/png"
|
||||
assert binary_part["data"] == payload
|
||||
|
||||
|
||||
def test_agui_snapshot_format_base64_value_preferred_over_data():
|
||||
"""Snapshot normalization prefers 'value' when both 'value' and 'data' are set."""
|
||||
value_payload = base64.b64encode(b"new-spec").decode("utf-8")
|
||||
data_payload = base64.b64encode(b"legacy").decode("utf-8")
|
||||
normalized = agui_messages_to_snapshot_format(
|
||||
[
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"value": value_payload,
|
||||
"data": data_payload,
|
||||
"mimeType": "image/png",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
binary_part = normalized[0]["content"][0]
|
||||
assert binary_part["data"] == value_payload
|
||||
|
||||
|
||||
def test_agui_snapshot_format_base64_data_field_backward_compat():
|
||||
"""Snapshot normalization still reads the legacy 'data' field when 'value' is absent."""
|
||||
payload = base64.b64encode(b"legacy").decode("utf-8")
|
||||
normalized = agui_messages_to_snapshot_format(
|
||||
[
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "image",
|
||||
"source": {"type": "base64", "data": payload, "mimeType": "image/png"},
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
binary_part = normalized[0]["content"][0]
|
||||
assert binary_part["data"] == payload
|
||||
|
||||
|
||||
def test_agui_with_tool_calls_to_agent_framework():
|
||||
"""Assistant message with tool_calls is converted to FunctionCallContent."""
|
||||
agui_msg = {
|
||||
@@ -1760,3 +1831,67 @@ class TestReasoningRoundTrip:
|
||||
assert "First answer" in texts
|
||||
assert "Follow-up question" in texts
|
||||
assert "Prior reasoning" not in texts
|
||||
|
||||
|
||||
def test_parse_multimodal_media_part_base64_value_field():
|
||||
"""Source with type='base64' reads data from the 'value' field per AG-UI spec."""
|
||||
from agent_framework_ag_ui._message_adapters import _parse_multimodal_media_part
|
||||
|
||||
result = _parse_multimodal_media_part(
|
||||
{"type": "image", "source": {"type": "base64", "value": "aGVsbG8=", "mimeType": "image/png"}}
|
||||
)
|
||||
assert result is not None
|
||||
assert "aGVsbG8=" in result.uri
|
||||
|
||||
|
||||
def test_parse_multimodal_media_part_data_source_value_field():
|
||||
"""Source with type='data' reads data from the 'value' field per AG-UI spec."""
|
||||
from agent_framework_ag_ui._message_adapters import _parse_multimodal_media_part
|
||||
|
||||
result = _parse_multimodal_media_part(
|
||||
{"type": "image", "source": {"type": "data", "value": "aGVsbG8=", "mimeType": "image/png"}}
|
||||
)
|
||||
assert result is not None
|
||||
assert "aGVsbG8=" in result.uri
|
||||
|
||||
|
||||
def test_parse_multimodal_media_part_base64_data_field_backward_compat():
|
||||
"""Source with type='base64' still supports deprecated 'data' field."""
|
||||
from agent_framework_ag_ui._message_adapters import _parse_multimodal_media_part
|
||||
|
||||
result = _parse_multimodal_media_part(
|
||||
{"type": "image", "source": {"type": "base64", "data": "aGVsbG8=", "mimeType": "image/png"}}
|
||||
)
|
||||
assert result is not None
|
||||
assert "aGVsbG8=" in result.uri
|
||||
|
||||
|
||||
def test_parse_multimodal_media_part_value_preferred_over_data():
|
||||
"""When both 'value' and 'data' are present, 'value' takes precedence."""
|
||||
from agent_framework_ag_ui._message_adapters import _parse_multimodal_media_part
|
||||
|
||||
result = _parse_multimodal_media_part(
|
||||
{
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"value": "dmFsdWU=",
|
||||
"data": "ZGF0YQ==",
|
||||
"mimeType": "image/png",
|
||||
},
|
||||
}
|
||||
)
|
||||
assert result is not None
|
||||
# 'value' field content should be used (base64 of "value")
|
||||
assert "dmFsdWU=" in result.uri
|
||||
|
||||
|
||||
def test_parse_multimodal_media_part_unknown_source_value_fallback():
|
||||
"""Unknown source type falls back to 'value' field before 'data' field."""
|
||||
from agent_framework_ag_ui._message_adapters import _parse_multimodal_media_part
|
||||
|
||||
result = _parse_multimodal_media_part(
|
||||
{"type": "image", "source": {"type": "custom", "value": "aGVsbG8=", "mimeType": "image/png"}}
|
||||
)
|
||||
assert result is not None
|
||||
assert "aGVsbG8=" in result.uri
|
||||
|
||||
@@ -1244,7 +1244,7 @@ class TestEmitTextReasoning:
|
||||
assert events[0].message_id == "reason_1"
|
||||
assert isinstance(events[1], ReasoningMessageStartEvent)
|
||||
assert events[1].message_id == "reason_1"
|
||||
assert events[1].role == "assistant"
|
||||
assert events[1].role == "reasoning"
|
||||
assert isinstance(events[2], ReasoningMessageContentEvent)
|
||||
assert events[2].message_id == "reason_1"
|
||||
assert events[2].delta == "The user is asking about weather, so I should call the weather tool."
|
||||
@@ -1642,6 +1642,37 @@ class TestReasoningInSnapshot:
|
||||
assert close[0].message_id == "block2"
|
||||
|
||||
|
||||
class TestReasoningEventRole:
|
||||
"""Tests that reasoning events use role='reasoning' per AG-UI spec."""
|
||||
|
||||
def test_reasoning_role_without_flow(self):
|
||||
"""ReasoningMessageStartEvent uses role='reasoning' in non-flow mode."""
|
||||
content = Content.from_text_reasoning(
|
||||
id="reason_role_1",
|
||||
text="Thinking about the question.",
|
||||
)
|
||||
|
||||
events = _emit_text_reasoning(content)
|
||||
|
||||
msg_starts = [e for e in events if isinstance(e, ReasoningMessageStartEvent)]
|
||||
assert len(msg_starts) == 1
|
||||
assert msg_starts[0].role == "reasoning"
|
||||
|
||||
def test_reasoning_role_with_flow(self):
|
||||
"""ReasoningMessageStartEvent uses role='reasoning' in streaming flow mode."""
|
||||
flow = FlowState()
|
||||
content = Content.from_text_reasoning(
|
||||
id="reason_role_2",
|
||||
text="Reasoning in streaming mode.",
|
||||
)
|
||||
|
||||
events = _emit_text_reasoning(content, flow)
|
||||
|
||||
msg_starts = [e for e in events if isinstance(e, ReasoningMessageStartEvent)]
|
||||
assert len(msg_starts) == 1
|
||||
assert msg_starts[0].role == "reasoning"
|
||||
|
||||
|
||||
async def test_session_id_matches_thread_id():
|
||||
"""Session created by run_agent_stream uses the client thread_id as session_id."""
|
||||
from conftest import StubAgent
|
||||
|
||||
@@ -7,6 +7,7 @@ This module lazily re-exports objects from:
|
||||
|
||||
Supported classes:
|
||||
- A2AAgent
|
||||
- A2AExecutor
|
||||
"""
|
||||
|
||||
import importlib
|
||||
@@ -14,7 +15,7 @@ from typing import Any
|
||||
|
||||
IMPORT_PATH = "agent_framework_a2a"
|
||||
PACKAGE_NAME = "agent-framework-a2a"
|
||||
_IMPORTS = ["A2AAgent"]
|
||||
_IMPORTS = ["A2AAgent", "A2AExecutor"]
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from agent_framework_a2a import (
|
||||
A2AAgent,
|
||||
)
|
||||
from agent_framework_a2a import A2AAgent, A2AExecutor
|
||||
|
||||
__all__ = [
|
||||
"A2AAgent",
|
||||
]
|
||||
__all__ = ["A2AAgent", "A2AExecutor"]
|
||||
|
||||
@@ -9,6 +9,7 @@ Supported classes:
|
||||
- GitHubCopilotAgent
|
||||
- GitHubCopilotOptions
|
||||
- GitHubCopilotSettings
|
||||
- RawGitHubCopilotAgent
|
||||
"""
|
||||
|
||||
import importlib
|
||||
@@ -18,6 +19,7 @@ _IMPORTS: dict[str, tuple[str, str]] = {
|
||||
"GitHubCopilotAgent": ("agent_framework_github_copilot", "agent-framework-github-copilot"),
|
||||
"GitHubCopilotOptions": ("agent_framework_github_copilot", "agent-framework-github-copilot"),
|
||||
"GitHubCopilotSettings": ("agent_framework_github_copilot", "agent-framework-github-copilot"),
|
||||
"RawGitHubCopilotAgent": ("agent_framework_github_copilot", "agent-framework-github-copilot"),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -4,10 +4,12 @@ from agent_framework_github_copilot import (
|
||||
GitHubCopilotAgent,
|
||||
GitHubCopilotOptions,
|
||||
GitHubCopilotSettings,
|
||||
RawGitHubCopilotAgent,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"GitHubCopilotAgent",
|
||||
"GitHubCopilotOptions",
|
||||
"GitHubCopilotSettings",
|
||||
"RawGitHubCopilotAgent",
|
||||
]
|
||||
|
||||
@@ -34,7 +34,7 @@ from azure.ai.projects.aio import AIProjectClient
|
||||
from azure.core.credentials import TokenCredential
|
||||
from azure.core.credentials_async import AsyncTokenCredential
|
||||
|
||||
from ._tools import sanitize_foundry_response_tool
|
||||
from ._tools import _sanitize_foundry_response_tool # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
if sys.version_info >= (3, 13):
|
||||
from typing import TypeVar # type: ignore # pragma: no cover
|
||||
@@ -321,7 +321,7 @@ class RawFoundryAgentChatClient( # type: ignore[misc]
|
||||
surface.
|
||||
"""
|
||||
response_tools = super()._prepare_tools_for_openai(tools)
|
||||
return [sanitize_foundry_response_tool(tool_item) for tool_item in response_tools]
|
||||
return [_sanitize_foundry_response_tool(tool_item) for tool_item in response_tools]
|
||||
|
||||
def _prepare_messages_for_azure_ai(self, messages: Sequence[Message]) -> tuple[list[Message], str | None]:
|
||||
"""Extract system/developer messages as instructions for Azure AI.
|
||||
|
||||
@@ -33,7 +33,7 @@ from azure.ai.projects.models import MCPTool as FoundryMCPTool
|
||||
from azure.core.credentials import TokenCredential
|
||||
from azure.core.credentials_async import AsyncTokenCredential
|
||||
|
||||
from ._tools import fetch_toolbox, sanitize_foundry_response_tool
|
||||
from ._tools import _sanitize_foundry_response_tool, fetch_toolbox # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
if sys.version_info >= (3, 13):
|
||||
from typing import TypeVar # type: ignore # pragma: no cover
|
||||
@@ -235,7 +235,7 @@ class RawFoundryChatClient( # type: ignore[misc]
|
||||
them downstream.
|
||||
"""
|
||||
response_tools = super()._prepare_tools_for_openai(tools)
|
||||
return [sanitize_foundry_response_tool(tool_item) for tool_item in response_tools]
|
||||
return [_sanitize_foundry_response_tool(tool_item) for tool_item in response_tools]
|
||||
|
||||
async def configure_azure_monitor(
|
||||
self,
|
||||
|
||||
@@ -155,8 +155,7 @@ def _validate_hosted_tool_payload(sanitized: Mapping[str, Any]) -> None:
|
||||
)
|
||||
|
||||
|
||||
@experimental(feature_id=ExperimentalFeature.TOOLBOXES)
|
||||
def sanitize_foundry_response_tool(tool_item: Any) -> Any:
|
||||
def _sanitize_foundry_response_tool(tool_item: Any) -> Any: # pyright: ignore[reportUnusedFunction]
|
||||
"""Return a Responses-API-safe tool payload for Foundry hosted tools.
|
||||
|
||||
Reconciles known mismatches between toolbox reads and the Responses API:
|
||||
|
||||
@@ -28,13 +28,35 @@ from azure.ai.agentserver.responses import (
|
||||
)
|
||||
from azure.ai.agentserver.responses.hosting import ResponsesAgentServerHost
|
||||
from azure.ai.agentserver.responses.models import (
|
||||
ApplyPatchToolCallItemParam,
|
||||
ApplyPatchToolCallOutputItemParam,
|
||||
ComputerCallOutputItemParam,
|
||||
ComputerScreenshotContent,
|
||||
CreateResponse,
|
||||
FunctionCallOutputItemParam,
|
||||
FunctionShellAction,
|
||||
FunctionShellCallItemParam,
|
||||
FunctionShellCallOutputContent,
|
||||
FunctionShellCallOutputExitOutcome,
|
||||
FunctionShellCallOutputItemParam,
|
||||
Item,
|
||||
ItemCodeInterpreterToolCall,
|
||||
ItemComputerToolCall,
|
||||
ItemCustomToolCall,
|
||||
ItemCustomToolCallOutput,
|
||||
ItemFileSearchToolCall,
|
||||
ItemFunctionToolCall,
|
||||
ItemImageGenToolCall,
|
||||
ItemLocalShellToolCall,
|
||||
ItemLocalShellToolCallOutput,
|
||||
ItemMcpApprovalRequest,
|
||||
ItemMcpToolCall,
|
||||
ItemMessage,
|
||||
ItemOutputMessage,
|
||||
ItemReasoningItem,
|
||||
ItemWebSearchToolCall,
|
||||
LocalEnvironmentResource,
|
||||
MCPApprovalResponse,
|
||||
MessageContent,
|
||||
MessageContentInputFileContent,
|
||||
MessageContentInputImageContent,
|
||||
@@ -174,9 +196,11 @@ class ResponsesHostServer(ResponsesAgentServerHost):
|
||||
context: ResponseContext,
|
||||
) -> AsyncIterable[ResponseStreamEvent | dict[str, Any]]:
|
||||
"""Handle the creation of a response for a regular (non-workflow) agent."""
|
||||
input_text = await context.get_input_text()
|
||||
input_items = await context.get_input_items()
|
||||
input_messages = _items_to_messages(input_items)
|
||||
|
||||
history = await context.get_history()
|
||||
messages: list[str | Content | Message] = [*_to_messages(history), input_text]
|
||||
messages: list[str | Content | Message] = [*_output_items_to_messages(history), *input_messages]
|
||||
|
||||
chat_options, are_options_set = _to_chat_options(request)
|
||||
|
||||
@@ -243,7 +267,9 @@ class ResponsesHostServer(ResponsesAgentServerHost):
|
||||
The sandbox may be deactivated after some period of inactivity, and only data managed
|
||||
by the hosting infrastructure or files will be preserved upon deactivation.
|
||||
"""
|
||||
input_text = await context.get_input_text()
|
||||
input_items = await context.get_input_items()
|
||||
input_messages = _items_to_messages(input_items)
|
||||
|
||||
is_streaming_request = self._is_streaming_request(request)
|
||||
|
||||
_, are_options_set = _to_chat_options(request)
|
||||
@@ -296,7 +322,7 @@ class ResponsesHostServer(ResponsesAgentServerHost):
|
||||
|
||||
if not is_streaming_request:
|
||||
# Run the agent in non-streaming mode
|
||||
response = await self._agent.run(input_text, stream=False, checkpoint_storage=checkpoint_storage)
|
||||
response = await self._agent.run(input_messages, stream=False, checkpoint_storage=checkpoint_storage)
|
||||
|
||||
for message in response.messages:
|
||||
for content in message.contents:
|
||||
@@ -308,7 +334,7 @@ class ResponsesHostServer(ResponsesAgentServerHost):
|
||||
return
|
||||
|
||||
# Run the agent in streaming mode
|
||||
response_stream = self._agent.run(input_text, stream=True, checkpoint_storage=checkpoint_storage)
|
||||
response_stream = self._agent.run(input_messages, stream=True, checkpoint_storage=checkpoint_storage)
|
||||
|
||||
# Track the current active output item builder for streaming;
|
||||
# lazily created on matching content, closed when a different type arrives.
|
||||
@@ -532,7 +558,260 @@ def _to_chat_options(request: CreateResponse) -> tuple[ChatOptions, bool]:
|
||||
# region Input Message Conversion
|
||||
|
||||
|
||||
def _to_messages(history: Sequence[OutputItem]) -> list[Message]:
|
||||
def _items_to_messages(input_items: Sequence[Item]) -> list[Message]:
|
||||
"""Converts a sequence of input items to a list of Messages, one per item.
|
||||
|
||||
Args:
|
||||
input_items: The input items to convert.
|
||||
|
||||
Returns:
|
||||
A list of Messages, one per supported input item.
|
||||
"""
|
||||
messages: list[Message] = []
|
||||
for item in input_items:
|
||||
messages.append(_item_to_message(item))
|
||||
return messages
|
||||
|
||||
|
||||
def _item_to_message(item: Item) -> Message:
|
||||
"""Converts an Item to a Message.
|
||||
|
||||
Args:
|
||||
item: The Item to convert.
|
||||
|
||||
Returns:
|
||||
The converted Message.
|
||||
|
||||
Raises:
|
||||
ValueError: If the Item type is not supported.
|
||||
"""
|
||||
if item.type == "message":
|
||||
msg = cast(ItemMessage, item)
|
||||
if isinstance(msg.content, str):
|
||||
return Message(role=msg.role, contents=[Content.from_text(msg.content)])
|
||||
return Message(role=msg.role, contents=[_convert_message_content(part) for part in msg.content])
|
||||
|
||||
if item.type == "output_message":
|
||||
output_msg = cast(ItemOutputMessage, item)
|
||||
return Message(
|
||||
role=output_msg.role, contents=[_convert_output_message_content(part) for part in output_msg.content]
|
||||
)
|
||||
|
||||
if item.type == "function_call":
|
||||
fc = cast(ItemFunctionToolCall, item)
|
||||
return Message(
|
||||
role="assistant",
|
||||
contents=[Content.from_function_call(fc.call_id, fc.name, arguments=fc.arguments)],
|
||||
)
|
||||
|
||||
if item.type == "function_call_output":
|
||||
fco = cast(FunctionCallOutputItemParam, item)
|
||||
output = fco.output if isinstance(fco.output, str) else str(fco.output)
|
||||
return Message(
|
||||
role="tool",
|
||||
contents=[Content.from_function_result(fco.call_id, result=output)],
|
||||
)
|
||||
|
||||
if item.type == "reasoning":
|
||||
reasoning = cast(ItemReasoningItem, item)
|
||||
reason_contents: list[Content] = []
|
||||
if reasoning.summary:
|
||||
for summary in reasoning.summary:
|
||||
reason_contents.append(Content.from_text(summary.text))
|
||||
return Message(role="assistant", contents=reason_contents)
|
||||
|
||||
if item.type == "mcp_call":
|
||||
mcp = cast(ItemMcpToolCall, item)
|
||||
return Message(
|
||||
role="assistant",
|
||||
contents=[
|
||||
Content.from_mcp_server_tool_call(
|
||||
mcp.id,
|
||||
mcp.name,
|
||||
server_name=mcp.server_label,
|
||||
arguments=mcp.arguments,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
if item.type == "mcp_approval_request":
|
||||
mcp_req = cast(ItemMcpApprovalRequest, item)
|
||||
mcp_call_content = Content.from_mcp_server_tool_call(
|
||||
mcp_req.id,
|
||||
mcp_req.name,
|
||||
server_name=mcp_req.server_label,
|
||||
arguments=mcp_req.arguments,
|
||||
)
|
||||
return Message(
|
||||
role="assistant",
|
||||
contents=[Content.from_function_approval_request(mcp_req.id, mcp_call_content)],
|
||||
)
|
||||
|
||||
if item.type == "mcp_approval_response":
|
||||
mcp_resp = cast(MCPApprovalResponse, item)
|
||||
placeholder_content = Content.from_function_call(mcp_resp.approval_request_id, "mcp_approval")
|
||||
return Message(
|
||||
role="user",
|
||||
contents=[
|
||||
Content.from_function_approval_response(
|
||||
mcp_resp.approve, mcp_resp.approval_request_id, placeholder_content
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
if item.type == "code_interpreter_call":
|
||||
ci = cast(ItemCodeInterpreterToolCall, item)
|
||||
return Message(
|
||||
role="assistant",
|
||||
contents=[Content.from_code_interpreter_tool_call(call_id=ci.id)],
|
||||
)
|
||||
|
||||
if item.type == "image_generation_call":
|
||||
ig = cast(ItemImageGenToolCall, item)
|
||||
return Message(
|
||||
role="assistant",
|
||||
contents=[Content.from_image_generation_tool_call(image_id=ig.id)],
|
||||
)
|
||||
|
||||
if item.type == "shell_call":
|
||||
sc = cast(FunctionShellCallItemParam, item)
|
||||
return Message(
|
||||
role="assistant",
|
||||
contents=[
|
||||
Content.from_shell_tool_call(
|
||||
call_id=sc.call_id,
|
||||
commands=sc.action.commands,
|
||||
status=str(sc.status),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
if item.type == "shell_call_output":
|
||||
sco = cast(FunctionShellCallOutputItemParam, item)
|
||||
outputs = [
|
||||
Content.from_shell_command_output(
|
||||
stdout=out.stdout or "",
|
||||
stderr=out.stderr or "",
|
||||
exit_code=getattr(out.outcome, "exit_code", None) if hasattr(out, "outcome") else None,
|
||||
)
|
||||
for out in (sco.output or [])
|
||||
]
|
||||
return Message(
|
||||
role="tool",
|
||||
contents=[
|
||||
Content.from_shell_tool_result(
|
||||
call_id=sco.call_id,
|
||||
outputs=outputs,
|
||||
max_output_length=sco.max_output_length,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
if item.type == "local_shell_call":
|
||||
lsc = cast(ItemLocalShellToolCall, item)
|
||||
commands = lsc.action.command if hasattr(lsc.action, "command") and lsc.action.command else []
|
||||
return Message(
|
||||
role="assistant",
|
||||
contents=[
|
||||
Content.from_shell_tool_call(
|
||||
call_id=lsc.call_id,
|
||||
commands=commands,
|
||||
status=str(lsc.status),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
if item.type == "local_shell_call_output":
|
||||
lsco = cast(ItemLocalShellToolCallOutput, item)
|
||||
return Message(
|
||||
role="tool",
|
||||
contents=[
|
||||
Content.from_shell_tool_result(
|
||||
call_id=lsco.id,
|
||||
outputs=[Content.from_shell_command_output(stdout=lsco.output)],
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
if item.type == "file_search_call":
|
||||
fs = cast(ItemFileSearchToolCall, item)
|
||||
return Message(
|
||||
role="assistant",
|
||||
contents=[
|
||||
Content.from_function_call(
|
||||
fs.id,
|
||||
"file_search",
|
||||
arguments=json.dumps({"queries": fs.queries}),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
if item.type == "web_search_call":
|
||||
ws = cast(ItemWebSearchToolCall, item)
|
||||
return Message(
|
||||
role="assistant",
|
||||
contents=[Content.from_function_call(ws.id, "web_search")],
|
||||
)
|
||||
|
||||
if item.type == "computer_call":
|
||||
cc = cast(ItemComputerToolCall, item)
|
||||
return Message(
|
||||
role="assistant",
|
||||
contents=[
|
||||
Content.from_function_call(
|
||||
cc.call_id,
|
||||
"computer_use",
|
||||
arguments=str(cc.action),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
if item.type == "computer_call_output":
|
||||
cco = cast(ComputerCallOutputItemParam, item)
|
||||
return Message(
|
||||
role="tool",
|
||||
contents=[Content.from_function_result(cco.call_id, result=str(cco.output))],
|
||||
)
|
||||
|
||||
if item.type == "custom_tool_call":
|
||||
ct = cast(ItemCustomToolCall, item)
|
||||
return Message(
|
||||
role="assistant",
|
||||
contents=[Content.from_function_call(ct.call_id, ct.name, arguments=ct.input)],
|
||||
)
|
||||
|
||||
if item.type == "custom_tool_call_output":
|
||||
cto = cast(ItemCustomToolCallOutput, item)
|
||||
output = cto.output if isinstance(cto.output, str) else str(cto.output)
|
||||
return Message(
|
||||
role="tool",
|
||||
contents=[Content.from_function_result(cto.call_id, result=output)],
|
||||
)
|
||||
|
||||
if item.type == "apply_patch_call":
|
||||
ap = cast(ApplyPatchToolCallItemParam, item)
|
||||
return Message(
|
||||
role="assistant",
|
||||
contents=[
|
||||
Content.from_function_call(
|
||||
ap.call_id,
|
||||
"apply_patch",
|
||||
arguments=str(ap.operation),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
if item.type == "apply_patch_call_output":
|
||||
apo = cast(ApplyPatchToolCallOutputItemParam, item)
|
||||
return Message(
|
||||
role="tool",
|
||||
contents=[Content.from_function_result(apo.call_id, result=apo.output or "")],
|
||||
)
|
||||
|
||||
raise ValueError(f"Unsupported Item type: {item.type}")
|
||||
|
||||
|
||||
def _output_items_to_messages(history: Sequence[OutputItem]) -> list[Message]:
|
||||
"""Converts a sequence of OutputItem objects to a list of Message objects.
|
||||
|
||||
Args:
|
||||
@@ -543,11 +822,11 @@ def _to_messages(history: Sequence[OutputItem]) -> list[Message]:
|
||||
"""
|
||||
messages: list[Message] = []
|
||||
for item in history:
|
||||
messages.append(_to_message(item))
|
||||
messages.append(_output_item_to_message(item))
|
||||
return messages
|
||||
|
||||
|
||||
def _to_message(item: OutputItem) -> Message:
|
||||
def _output_item_to_message(item: OutputItem) -> Message:
|
||||
"""Converts an OutputItem to a Message.
|
||||
|
||||
Args:
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Foundry Hosting integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0a260423"
|
||||
version = "1.0.0a260424"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -24,9 +24,9 @@ classifiers = [
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.1.1,<2",
|
||||
"azure-ai-agentserver-core==2.0.0b2",
|
||||
"azure-ai-agentserver-responses==1.0.0b4",
|
||||
"azure-ai-agentserver-invocations==1.0.0b2",
|
||||
"azure-ai-agentserver-core==2.0.0b3",
|
||||
"azure-ai-agentserver-responses==1.0.0b5",
|
||||
"azure-ai-agentserver-invocations==1.0.0b3",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@
|
||||
|
||||
import importlib.metadata
|
||||
|
||||
from ._agent import GitHubCopilotAgent, GitHubCopilotOptions, GitHubCopilotSettings
|
||||
from ._agent import GitHubCopilotAgent, GitHubCopilotOptions, GitHubCopilotSettings, RawGitHubCopilotAgent
|
||||
|
||||
try:
|
||||
__version__ = importlib.metadata.version(__name__)
|
||||
@@ -13,5 +13,6 @@ __all__ = [
|
||||
"GitHubCopilotAgent",
|
||||
"GitHubCopilotOptions",
|
||||
"GitHubCopilotSettings",
|
||||
"RawGitHubCopilotAgent",
|
||||
"__version__",
|
||||
]
|
||||
|
||||
@@ -9,7 +9,13 @@ import sys
|
||||
from collections.abc import AsyncIterable, Awaitable, Callable, MutableMapping, Sequence
|
||||
from typing import Any, ClassVar, Generic, Literal, TypedDict, overload
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
from typing import Self # pragma: no cover
|
||||
else:
|
||||
from typing_extensions import Self # pragma: no cover
|
||||
|
||||
from agent_framework import (
|
||||
AgentMiddlewareLayer,
|
||||
AgentMiddlewareTypes,
|
||||
AgentResponse,
|
||||
AgentResponseUpdate,
|
||||
@@ -27,6 +33,7 @@ from agent_framework._settings import load_settings
|
||||
from agent_framework._tools import FunctionTool, ToolTypes
|
||||
from agent_framework._types import AgentRunInputs, normalize_tools
|
||||
from agent_framework.exceptions import AgentException
|
||||
from agent_framework.observability import AgentTelemetryLayer
|
||||
|
||||
try:
|
||||
from copilot import CopilotClient, CopilotSession, SubprocessConfig
|
||||
@@ -135,8 +142,11 @@ OptionsT = TypeVar(
|
||||
)
|
||||
|
||||
|
||||
class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
|
||||
"""A GitHub Copilot Agent.
|
||||
class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
|
||||
"""A GitHub Copilot Agent without telemetry layers.
|
||||
|
||||
This is the core GitHub Copilot agent implementation without OpenTelemetry instrumentation.
|
||||
For most use cases, prefer :class:`GitHubCopilotAgent` which includes telemetry support.
|
||||
|
||||
This agent wraps the GitHub Copilot SDK to provide Copilot agentic capabilities
|
||||
within the Agent Framework. It supports both streaming and non-streaming responses,
|
||||
@@ -149,7 +159,7 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
async with GitHubCopilotAgent() as agent:
|
||||
async with RawGitHubCopilotAgent() as agent:
|
||||
response = await agent.run("Hello, world!")
|
||||
print(response)
|
||||
|
||||
@@ -157,22 +167,11 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from agent_framework_github_copilot import GitHubCopilotAgent, GitHubCopilotOptions
|
||||
from agent_framework_github_copilot import RawGitHubCopilotAgent, GitHubCopilotOptions
|
||||
|
||||
agent: GitHubCopilotAgent[GitHubCopilotOptions] = GitHubCopilotAgent(
|
||||
agent: RawGitHubCopilotAgent[GitHubCopilotOptions] = RawGitHubCopilotAgent(
|
||||
default_options={"model": "claude-sonnet-4", "timeout": 120}
|
||||
)
|
||||
|
||||
With tools:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
def get_weather(city: str) -> str:
|
||||
return f"Weather in {city} is sunny"
|
||||
|
||||
|
||||
async with GitHubCopilotAgent(tools=[get_weather]) as agent:
|
||||
response = await agent.run("What's the weather in Seattle?")
|
||||
"""
|
||||
|
||||
AGENT_PROVIDER_NAME: ClassVar[str] = "github.copilot"
|
||||
@@ -200,9 +199,9 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
|
||||
Keyword Args:
|
||||
client: Optional pre-configured CopilotClient instance. If not provided,
|
||||
a new client will be created using the other parameters.
|
||||
id: ID of the GitHubCopilotAgent.
|
||||
name: Name of the GitHubCopilotAgent.
|
||||
description: Description of the GitHubCopilotAgent.
|
||||
id: ID of the RawGitHubCopilotAgent.
|
||||
name: Name of the RawGitHubCopilotAgent.
|
||||
description: Description of the RawGitHubCopilotAgent.
|
||||
context_providers: Context Providers, to be used by the agent.
|
||||
middleware: Agent middleware used by the agent.
|
||||
tools: Tools to use for the agent. Can be functions
|
||||
@@ -258,7 +257,7 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
|
||||
self._default_options = opts
|
||||
self._started = False
|
||||
|
||||
async def __aenter__(self) -> GitHubCopilotAgent[OptionsT]:
|
||||
async def __aenter__(self) -> Self:
|
||||
"""Start the agent when entering async context."""
|
||||
await self.start()
|
||||
return self
|
||||
@@ -308,6 +307,20 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
|
||||
|
||||
self._started = False
|
||||
|
||||
@property
|
||||
def default_options(self) -> dict[str, Any]:
|
||||
"""Expose default options including model from settings.
|
||||
|
||||
Returns a merged dict of ``_default_options`` with the resolved ``model``
|
||||
from settings injected under the ``model`` key. This is read by
|
||||
:class:`AgentTelemetryLayer` to include the model name in span attributes.
|
||||
"""
|
||||
opts = dict(self._default_options)
|
||||
model = self._settings.get("model")
|
||||
if model:
|
||||
opts["model"] = model
|
||||
return opts
|
||||
|
||||
@overload
|
||||
def run(
|
||||
self,
|
||||
@@ -315,7 +328,9 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
|
||||
*,
|
||||
stream: Literal[False] = False,
|
||||
session: AgentSession | None = None,
|
||||
middleware: Sequence[AgentMiddlewareTypes] | None = None,
|
||||
options: OptionsT | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Awaitable[AgentResponse]: ...
|
||||
|
||||
@overload
|
||||
@@ -325,7 +340,9 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
|
||||
*,
|
||||
stream: Literal[True],
|
||||
session: AgentSession | None = None,
|
||||
middleware: Sequence[AgentMiddlewareTypes] | None = None,
|
||||
options: OptionsT | None = None,
|
||||
**kwargs: Any,
|
||||
) -> ResponseStream[AgentResponseUpdate, AgentResponse]: ...
|
||||
|
||||
def run(
|
||||
@@ -334,7 +351,9 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
|
||||
*,
|
||||
stream: bool = False,
|
||||
session: AgentSession | None = None,
|
||||
middleware: Sequence[AgentMiddlewareTypes] | None = None,
|
||||
options: OptionsT | None = None,
|
||||
**kwargs: Any, # type: ignore[override]
|
||||
) -> Awaitable[AgentResponse] | ResponseStream[AgentResponseUpdate, AgentResponse]:
|
||||
"""Get a response from the agent.
|
||||
|
||||
@@ -348,7 +367,12 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
|
||||
Keyword Args:
|
||||
stream: Whether to stream the response. Defaults to False.
|
||||
session: The conversation session associated with the message(s).
|
||||
middleware: Not used by this agent directly. Accepted for interface
|
||||
compatibility; pass middleware via :class:`GitHubCopilotAgent` which
|
||||
forwards it through :class:`AgentTelemetryLayer`.
|
||||
options: Runtime options (model, timeout, etc.).
|
||||
kwargs: Additional keyword arguments for compatibility with the shared agent
|
||||
interface (e.g. compaction_strategy, tokenizer). Not used by this agent.
|
||||
|
||||
Returns:
|
||||
When stream=False: An Awaitable[AgentResponse].
|
||||
@@ -357,6 +381,12 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
|
||||
Raises:
|
||||
AgentException: If the request fails.
|
||||
"""
|
||||
if middleware:
|
||||
logger.warning(
|
||||
"Per-run middleware is not supported by RawGitHubCopilotAgent: the GitHub Copilot SDK "
|
||||
"handles tool execution internally, so chat/function middleware cannot be injected into "
|
||||
"the tool call path. Use agent-level middleware via the GitHubCopilotAgent constructor instead."
|
||||
)
|
||||
if stream:
|
||||
ctx_holder: dict[str, Any] = {}
|
||||
|
||||
@@ -767,3 +797,97 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
|
||||
mcp_servers=self._mcp_servers or None,
|
||||
provider=self._provider or None,
|
||||
)
|
||||
|
||||
|
||||
class GitHubCopilotAgent( # type: ignore[misc]
|
||||
AgentMiddlewareLayer,
|
||||
AgentTelemetryLayer,
|
||||
RawGitHubCopilotAgent[OptionsT],
|
||||
Generic[OptionsT],
|
||||
):
|
||||
"""A GitHub Copilot Agent with full middleware and telemetry support.
|
||||
|
||||
This is the recommended agent class for most use cases. It includes
|
||||
middleware support and OpenTelemetry-based telemetry for observability,
|
||||
with middleware running outside the telemetry span so middleware execution
|
||||
time is not captured in traces. For a minimal implementation without these
|
||||
layers, use :class:`RawGitHubCopilotAgent`.
|
||||
|
||||
Examples:
|
||||
Basic usage:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
async with GitHubCopilotAgent() as agent:
|
||||
response = await agent.run("Hello, world!")
|
||||
print(response)
|
||||
|
||||
With explicitly typed options:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from agent_framework_github_copilot import GitHubCopilotAgent, GitHubCopilotOptions
|
||||
|
||||
agent: GitHubCopilotAgent[GitHubCopilotOptions] = GitHubCopilotAgent(
|
||||
default_options={"model": "claude-sonnet-4-5", "timeout": 120}
|
||||
)
|
||||
|
||||
With observability:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from agent_framework.observability import configure_otel_providers
|
||||
|
||||
configure_otel_providers()
|
||||
async with GitHubCopilotAgent() as agent:
|
||||
response = await agent.run("Hello, world!")
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
instructions: str | None = None,
|
||||
*,
|
||||
client: CopilotClient | None = None,
|
||||
id: str | None = None,
|
||||
name: str | None = None,
|
||||
description: str | None = None,
|
||||
context_providers: Sequence[ContextProvider] | None = None,
|
||||
middleware: Sequence[AgentMiddlewareTypes] | None = None,
|
||||
tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None,
|
||||
default_options: OptionsT | None = None,
|
||||
env_file_path: str | None = None,
|
||||
env_file_encoding: str | None = None,
|
||||
) -> None:
|
||||
"""Initialize a GitHub Copilot Agent with full middleware and telemetry.
|
||||
|
||||
Args:
|
||||
instructions: System message for the agent.
|
||||
|
||||
Keyword Args:
|
||||
client: Optional pre-configured CopilotClient instance. If not provided,
|
||||
a new client will be created using the other parameters.
|
||||
id: ID of the agent.
|
||||
name: Name of the agent.
|
||||
description: Description of the agent.
|
||||
context_providers: Context providers to be used by the agent.
|
||||
middleware: Agent middleware used by the agent.
|
||||
tools: Tools to use for the agent. Can be functions or tool definition dicts.
|
||||
These are converted to Copilot SDK tools internally.
|
||||
default_options: Default options for the agent. Can include cli_path, model,
|
||||
timeout, log_level, etc.
|
||||
env_file_path: Optional path to .env file for loading configuration.
|
||||
env_file_encoding: Encoding of the .env file, defaults to 'utf-8'.
|
||||
"""
|
||||
super().__init__(
|
||||
instructions,
|
||||
client=client,
|
||||
id=id,
|
||||
name=name,
|
||||
description=description,
|
||||
context_providers=context_providers,
|
||||
middleware=middleware,
|
||||
tools=tools,
|
||||
default_options=default_options,
|
||||
env_file_path=env_file_path,
|
||||
env_file_encoding=env_file_encoding,
|
||||
)
|
||||
|
||||
@@ -189,6 +189,31 @@ class TestGitHubCopilotAgentInit:
|
||||
"content": "Direct instructions",
|
||||
}
|
||||
|
||||
def test_default_options_includes_model_for_telemetry(self) -> None:
|
||||
"""Test that default_options merges model from settings for AgentTelemetryLayer span attributes."""
|
||||
agent: GitHubCopilotAgent[GitHubCopilotOptions] = GitHubCopilotAgent(
|
||||
default_options={"model": "claude-sonnet-4-5", "timeout": 120}
|
||||
)
|
||||
opts = agent.default_options
|
||||
assert opts["model"] == "claude-sonnet-4-5"
|
||||
assert "timeout" not in opts # timeout is extracted into _settings, not returned in default_options
|
||||
|
||||
def test_default_options_without_model_configured(self) -> None:
|
||||
"""Test that default_options works correctly when no model is configured."""
|
||||
agent = GitHubCopilotAgent(instructions="Helper")
|
||||
opts = agent.default_options
|
||||
assert "model" not in opts
|
||||
assert opts.get("system_message") == {"mode": "append", "content": "Helper"}
|
||||
|
||||
def test_default_options_returns_independent_copy(self) -> None:
|
||||
"""Test that mutating the returned dict does not affect internal state."""
|
||||
agent: GitHubCopilotAgent[GitHubCopilotOptions] = GitHubCopilotAgent(
|
||||
default_options={"model": "gpt-5.1-mini"}
|
||||
)
|
||||
opts = agent.default_options
|
||||
opts["model"] = "mutated"
|
||||
assert agent._settings.get("model") == "gpt-5.1-mini"
|
||||
|
||||
|
||||
class TestGitHubCopilotAgentLifecycle:
|
||||
"""Test cases for agent lifecycle management."""
|
||||
|
||||
@@ -24,6 +24,22 @@ The following environment variables can be configured:
|
||||
| `GITHUB_COPILOT_TIMEOUT` | Request timeout in seconds | `60` |
|
||||
| `GITHUB_COPILOT_LOG_LEVEL` | CLI log level | `info` |
|
||||
|
||||
## Observability
|
||||
|
||||
`GitHubCopilotAgent` has OpenTelemetry tracing built-in. To enable it, call `configure_otel_providers()` before running the agent:
|
||||
|
||||
```python
|
||||
from agent_framework.observability import configure_otel_providers
|
||||
from agent_framework.github import GitHubCopilotAgent
|
||||
|
||||
configure_otel_providers(enable_console_exporters=True)
|
||||
|
||||
async with GitHubCopilotAgent() as agent:
|
||||
response = await agent.run("Hello!")
|
||||
```
|
||||
|
||||
See the [observability samples](../../../02-agents/observability/) for full examples with OTLP exporters.
|
||||
|
||||
## Examples
|
||||
|
||||
| File | Description |
|
||||
|
||||
@@ -12,6 +12,7 @@ The remaining files are supporting modules used by the server:
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| [`agent_framework_to_a2a.py`](agent_framework_to_a2a.py) | Exposes an agent_framework agent as an A2A-compliant server. Demonstrates how to wrap an agent_framework agent and expose it as an A2A service that other A2A clients can discover and communicate with. |
|
||||
| [`agent_definitions.py`](agent_definitions.py) | Agent and AgentCard factory definitions for invoice, policy, and logistics agents. |
|
||||
| [`agent_executor.py`](agent_executor.py) | Bridges the a2a-sdk `AgentExecutor` interface to Agent Framework agents. |
|
||||
| [`invoice_data.py`](invoice_data.py) | Mock invoice data and tool functions for the invoice agent. |
|
||||
@@ -60,6 +61,9 @@ In a separate terminal (from the same directory), point the client at a running
|
||||
```powershell
|
||||
$env:A2A_AGENT_HOST = "http://localhost:5001/"
|
||||
uv run python agent_with_a2a.py
|
||||
|
||||
# A2A server exposing an agent_framework agent
|
||||
uv run python agent_framework_to_a2a.py
|
||||
```
|
||||
|
||||
### 3. Run the Function Tools Sample
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import uvicorn
|
||||
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,
|
||||
AgentSkill,
|
||||
)
|
||||
from agent_framework import Agent
|
||||
from agent_framework.a2a import A2AExecutor
|
||||
from agent_framework.openai import OpenAIChatClient
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
if __name__ == "__main__":
|
||||
# --8<-- [start:AgentSkill]
|
||||
flight_skill = AgentSkill(
|
||||
id="Flight_Booking",
|
||||
name="Flight Booking",
|
||||
description="Search and book flights across Europe.",
|
||||
tags=["flights", "travel", "europe"],
|
||||
examples=[],
|
||||
)
|
||||
hotel_skill = AgentSkill(
|
||||
id="Hotel_Booking",
|
||||
name="Hotel Booking",
|
||||
description="Search and book hotels across Europe.",
|
||||
tags=["hotels", "travel", "accommodation"],
|
||||
examples=[],
|
||||
)
|
||||
# --8<-- [end:AgentSkill]
|
||||
|
||||
# --8<-- [start:AgentCard]
|
||||
# This will be the public-facing agent card
|
||||
public_agent_card = AgentCard(
|
||||
name="Europe Travel Agent",
|
||||
description="A helpful Europe Travel Agent that can help users search and book flights and hotels across Europe.",
|
||||
url="http://localhost:9999/",
|
||||
version="1.0.0",
|
||||
defaultInputModes=["text"],
|
||||
defaultOutputModes=["text"],
|
||||
capabilities=AgentCapabilities(streaming=True),
|
||||
skills=[flight_skill, hotel_skill],
|
||||
)
|
||||
# --8<-- [end:AgentCard]
|
||||
|
||||
agent = Agent(
|
||||
client=OpenAIChatClient(),
|
||||
name="Europe Travel Agent",
|
||||
instructions="You are a helpful Europe Travel Agent. You can help users search and book flights and hotels across Europe."
|
||||
)
|
||||
|
||||
request_handler = DefaultRequestHandler(
|
||||
agent_executor=A2AExecutor(agent),
|
||||
task_store=InMemoryTaskStore(),
|
||||
)
|
||||
|
||||
server = A2AStarletteApplication(
|
||||
agent_card=public_agent_card,
|
||||
http_handler=request_handler,
|
||||
)
|
||||
|
||||
server = server.build()
|
||||
# print(schemas.get_schema(server.routes))
|
||||
|
||||
uvicorn.run(server, host="0.0.0.0", port=9999)
|
||||
Generated
+17
-17
@@ -84,14 +84,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ag-ui-protocol"
|
||||
version = "0.1.13"
|
||||
version = "0.1.17"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/04/b5/fc0b65b561d00d88811c8a7d98ee735833f81554be244340950e7b65820c/ag_ui_protocol-0.1.13.tar.gz", hash = "sha256:811d7d7dcce4783dec252918f40b717ebfa559399bf6b071c4ba47c0c1e21bcb", size = 5671, upload-time = "2026-02-19T18:40:38.602Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cb/0f/5a8ce5eb5cd7adf3f733da87d7a5a8a38f24ec9e029c1300f9495ae9c7fa/ag_ui_protocol-0.1.17.tar.gz", hash = "sha256:5fae4cfced8245c8ac329b85702fd166ff226d99dc1ea8a1ae95890826aa69e5", size = 6273, upload-time = "2026-04-20T21:09:19.436Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/9f/b833c1ab1999da35ebad54841ae85d2c2764c931da9a6f52d8541b6901b2/ag_ui_protocol-0.1.13-py3-none-any.whl", hash = "sha256:1393fa894c1e8416efe184168a50689e760d05b32f4646eebb8ff423dddf8e8f", size = 8053, upload-time = "2026-02-19T18:40:37.27Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/82/e5f1686b4c4e232818c75598d651b82088f584e59adef71802920d74f82f/ag_ui_protocol-0.1.17-py3-none-any.whl", hash = "sha256:6a9065590d21c7b9b8ae9bb1a3410ecf4d18cb8041a077d25574f792dfa504fe", size = 8648, upload-time = "2026-04-20T21:09:20.488Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -183,7 +183,7 @@ dev = [
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "ag-ui-protocol", specifier = "==0.1.13" },
|
||||
{ name = "ag-ui-protocol", specifier = ">=0.1.16,<0.2" },
|
||||
{ name = "agent-framework-core", editable = "packages/core" },
|
||||
{ name = "fastapi", specifier = ">=0.115.0,<0.133.1" },
|
||||
{ name = "httpx", marker = "extra == 'dev'", specifier = "==0.28.1" },
|
||||
@@ -504,7 +504,7 @@ requires-dist = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework-foundry-hosting"
|
||||
version = "1.0.0a260423"
|
||||
version = "1.0.0a260424"
|
||||
source = { editable = "packages/foundry_hosting" }
|
||||
dependencies = [
|
||||
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -516,9 +516,9 @@ dependencies = [
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "agent-framework-core", editable = "packages/core" },
|
||||
{ name = "azure-ai-agentserver-core", specifier = "==2.0.0b2" },
|
||||
{ name = "azure-ai-agentserver-invocations", specifier = "==1.0.0b2" },
|
||||
{ name = "azure-ai-agentserver-responses", specifier = "==1.0.0b4" },
|
||||
{ name = "azure-ai-agentserver-core", specifier = "==2.0.0b3" },
|
||||
{ name = "azure-ai-agentserver-invocations", specifier = "==1.0.0b3" },
|
||||
{ name = "azure-ai-agentserver-responses", specifier = "==1.0.0b5" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1068,7 +1068,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "azure-ai-agentserver-core"
|
||||
version = "2.0.0b2"
|
||||
version = "2.0.0b3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "azure-monitor-opentelemetry-exporter", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -1078,26 +1078,26 @@ dependencies = [
|
||||
{ name = "opentelemetry-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "starlette", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a0/25/25865cfa76cbc20c18c4e9ed337456fd7374c01e930dd151463b4c183ac0/azure_ai_agentserver_core-2.0.0b2.tar.gz", hash = "sha256:cc6c90fdc4c2b2ce594f0e85288fda84910c04939d1427a64a485b2d48d6d684", size = 41605, upload-time = "2026-04-19T08:58:09.27Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/84/29/1a9606d5252b02d77070a1b633dd0c26fe65a0f4a0fb0cfdaa751e2ed458/azure_ai_agentserver_core-2.0.0b3.tar.gz", hash = "sha256:e295b19a65d53c513929f52f0862bbb815cc9e9fc29d2a2825452f3136260123", size = 42573, upload-time = "2026-04-23T04:13:16.717Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/69/35/cf8a034f86d653fa902edb5ffa0a86005ea941f2840d2fa27302484856c1/azure_ai_agentserver_core-2.0.0b2-py3-none-any.whl", hash = "sha256:931e7a2d82275a01d7eb5ef08a70dba230938e3646be64c03d82749dd7be8afc", size = 27494, upload-time = "2026-04-19T08:58:10.588Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/9b/1fc87c05b55821f33c46c5e8a3b97a573aa2fc4bff387e75cca1a87800b4/azure_ai_agentserver_core-2.0.0b3-py3-none-any.whl", hash = "sha256:5ef921eb9fd9c0f15682fe930320fae50dccfa915d7518f9a16d99014bbcb3cb", size = 29127, upload-time = "2026-04-23T04:13:17.976Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "azure-ai-agentserver-invocations"
|
||||
version = "1.0.0b2"
|
||||
version = "1.0.0b3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "azure-ai-agentserver-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9d/ef/11a161fa400f28390e9885854c434417fbd204ae006ca02b3a45ab285069/azure_ai_agentserver_invocations-1.0.0b2.tar.gz", hash = "sha256:cf352fd11b0057a2af28b1a921c84fb11f2fcbb9b4185cae9d93f2a45980227b", size = 30242, upload-time = "2026-04-19T09:43:31.439Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4d/95/ebab2b06777352b33dd4c407fa5624765b7443d3b4b5fb6cb1f51660643b/azure_ai_agentserver_invocations-1.0.0b3.tar.gz", hash = "sha256:1eaad3ae8dc6a28038b9a16c7b5f853fda33202c1ea57559992a6c6fe71952a4", size = 31002, upload-time = "2026-04-23T04:30:29.449Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/f4/057206e0fca266b30ea68a531fa425078fd883500e779d5552858fe33d5b/azure_ai_agentserver_invocations-1.0.0b2-py3-none-any.whl", hash = "sha256:e799a9e6e54a10499296ee4f61720377fb31f540204832b654bac6f20e801597", size = 11432, upload-time = "2026-04-19T09:43:32.744Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/43/a421671296ae33b62af3a034869fa82ff1979e5f455a29924d30ae1b8307/azure_ai_agentserver_invocations-1.0.0b3-py3-none-any.whl", hash = "sha256:771a15a3509e049b56f71c43c87a3fdeecd12addddcae0f80339990adc41e678", size = 11433, upload-time = "2026-04-23T04:30:30.412Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "azure-ai-agentserver-responses"
|
||||
version = "1.0.0b4"
|
||||
version = "1.0.0b5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -1105,9 +1105,9 @@ dependencies = [
|
||||
{ name = "azure-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "isodate", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cc/01/614dafa9366a5bdfe50ec112b15faa57e32a96866796bc2812ba329f4fec/azure_ai_agentserver_responses-1.0.0b4.tar.gz", hash = "sha256:2fa69db26ff52d8d2cd667a1461675e5124aabf8f268b842402e36f50d6c7176", size = 397007, upload-time = "2026-04-20T07:33:18.612Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e6/27/3ecb7fe704ff8764199bfbe4cc1e584a520a9affe042470d9d50b6e1e73a/azure_ai_agentserver_responses-1.0.0b5.tar.gz", hash = "sha256:0b627b810359c792ea7b6fa6782abaf6df32d9bc9e5a569ad722afcffd0ce8d9", size = 410908, upload-time = "2026-04-23T04:31:15.414Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/24/bd/c56df7c9257f10014ae1cd161ac08784bd9fe682233ab1a987c98b5b78c0/azure_ai_agentserver_responses-1.0.0b4-py3-none-any.whl", hash = "sha256:7684c6bef57bdcd1941cce2d6b5e2ea07edd7ce9f90e84f171804cc728b60fcc", size = 263375, upload-time = "2026-04-20T07:33:19.956Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/91/1e5c0d7ce95ca8b022e69e4ca6b23e413fc2d57f0191429c4633e02213d2/azure_ai_agentserver_responses-1.0.0b5-py3-none-any.whl", hash = "sha256:4c2a6ab56e71eeb330aa52b7cb2cc71b8ec6b5bbe0e7dc84310f2c7fbda393a3", size = 268362, upload-time = "2026-04-23T04:31:17.014Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user